ghostguild-org/app/pages/member/profile.vue

1273 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div>
<PageHeader
title="Your Profile"
subtitle="Manage your profile information and privacy settings"
theme="blue"
size="medium"
/>
<section class="py-12">
<UContainer>
<div
v-if="!memberData || loading"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading your profile...</p>
</div>
</div>
<div v-else>
<UTabs :items="tabItems" v-model="activeTab">
<template #profile>
<UForm
:state="formData"
@submit="handleSubmit"
class="space-y-12 mt-8 max-w-4xl"
>
<!-- Basic Information -->
<div>
<h2
class="text-2xl font-semibold mb-8 text-stone-100 ethereal-text"
>
Basic Information
</h2>
<div class="space-y-6">
<UFormField
label="Name"
name="name"
description="Your display name across Ghost Guild"
required
>
<UInput
v-model="formData.name"
placeholder="Your name"
disabled
/>
</UFormField>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<UFormField
label="Pronouns"
name="pronouns"
description="How you'd like to be addressed"
>
<UInput
v-model="formData.pronouns"
placeholder="e.g., she/her, they/them"
/>
</UFormField>
<PrivacyToggle
v-model="formData.pronounsPrivacy"
class="mt-2"
/>
</div>
<div>
<UFormField
label="Time Zone"
name="timeZone"
description="Helps coordinate meetings"
>
<UInput
v-model="formData.timeZone"
placeholder="e.g., America/Toronto"
/>
</UFormField>
<PrivacyToggle
v-model="formData.timeZonePrivacy"
class="mt-2"
/>
</div>
</div>
<div>
<UFormField
label="Avatar"
name="avatar"
description="Choose your ghost avatar"
>
<div class="grid grid-cols-3 sm:grid-cols-6 gap-4 mt-2">
<button
v-for="ghost in availableGhosts"
:key="ghost.value"
type="button"
class="relative aspect-square rounded-lg border-2 transition-all hover:scale-105"
:class="
formData.avatar === ghost.value
? 'border-blue-400 bg-blue-500/20'
: 'border-stone-700 bg-stone-800/50 hover:border-stone-600'
"
@click="formData.avatar = ghost.value"
>
<img
:src="ghost.image"
:alt="ghost.label"
class="w-full h-full object-contain p-2"
/>
<span
v-if="formData.avatar === ghost.value"
class="absolute -top-2 -right-2 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs"
>
</span>
</button>
</div>
</UFormField>
<PrivacyToggle
v-model="formData.avatarPrivacy"
class="mt-2"
/>
</div>
</div>
</div>
<!-- Professional Info -->
<div>
<h2
class="text-2xl font-semibold mb-8 text-stone-100 ethereal-text"
>
Professional Information
</h2>
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<UFormField
label="Studio/Organization"
name="studio"
description="Your current affiliation"
>
<UInput
v-model="formData.studio"
placeholder="Studio name"
/>
</UFormField>
<PrivacyToggle
v-model="formData.studioPrivacy"
class="mt-2"
/>
</div>
<div>
<UFormField
label="Location"
name="location"
description="City/region"
>
<UInput
v-model="formData.location"
placeholder="Toronto, ON"
/>
</UFormField>
<PrivacyToggle
v-model="formData.locationPrivacy"
class="mt-2"
/>
</div>
</div>
<div>
<UFormField
label="Bio"
name="bio"
description="Tell the community about yourself"
>
<UTextarea
v-model="formData.bio"
placeholder="Share your background, interests, and experience..."
:rows="4"
autoresize
/>
</UFormField>
<PrivacyToggle
v-model="formData.bioPrivacy"
class="mt-2"
/>
</div>
<div>
<UFormField
label="Skills"
name="skills"
description="Add your skills (press Enter or comma to add)"
>
<div class="space-y-3">
<UInput
v-model="currentSkillInput"
placeholder="Type a skill and press Enter"
@keydown.enter.prevent="addSkill"
@keydown.comma.prevent="addSkill"
/>
<div
v-if="formData.skills?.length"
class="flex flex-wrap gap-2"
>
<span
v-for="(skill, index) in formData.skills"
:key="skill"
class="px-3 py-1 bg-blue-500/20 text-blue-300 rounded-full text-sm border border-blue-500/30 flex items-center gap-2 group hover:bg-blue-500/30 transition-colors cursor-pointer"
@click="removeSkill(index)"
>
{{ skill }}
<span class="opacity-50 group-hover:opacity-100"
>×</span
>
</span>
</div>
</div>
</UFormField>
<PrivacyToggle
v-model="formData.skillsPrivacy"
class="mt-2"
/>
</div>
</div>
</div>
<!-- Community Connections -->
<div>
<h2
class="text-2xl font-semibold mb-8 text-stone-100 ethereal-text"
>
Community Connections
</h2>
<div class="space-y-6">
<div>
<UFormField
label="What I Can Contribute"
name="offering"
description="Skills, resources, or support you can offer the community"
>
<UTextarea
v-model="formData.offering"
placeholder="e.g., Mentorship in game design, playtesting, technical advice..."
:rows="3"
autoresize
/>
</UFormField>
<PrivacyToggle
v-model="formData.offeringPrivacy"
class="mt-2"
/>
</div>
<div>
<UFormField
label="What I'm Looking For"
name="lookingFor"
description="Support, collaboration, or resources you need"
>
<UTextarea
v-model="formData.lookingFor"
placeholder="e.g., Co-founder for studio, help with publishing, feedback on project..."
:rows="3"
autoresize
/>
</UFormField>
<PrivacyToggle
v-model="formData.lookingForPrivacy"
class="mt-2"
/>
</div>
</div>
</div>
<!-- Social Links -->
<div>
<h2
class="text-2xl font-semibold mb-8 text-stone-100 ethereal-text"
>
Social Links
</h2>
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormField label="Mastodon" name="mastodon">
<UInput
v-model="formData.socialLinks.mastodon"
placeholder="@username@instance.social"
/>
</UFormField>
<UFormField label="LinkedIn" name="linkedin">
<UInput
v-model="formData.socialLinks.linkedin"
placeholder="https://linkedin.com/in/username"
/>
</UFormField>
<UFormField label="Website" name="website">
<UInput
v-model="formData.socialLinks.website"
placeholder="https://yourwebsite.com"
/>
</UFormField>
<UFormField label="Other" name="other">
<UInput
v-model="formData.socialLinks.other"
placeholder="Any other relevant link"
/>
</UFormField>
</div>
<PrivacyToggle
v-model="formData.socialLinksPrivacy"
label="Privacy for all social links"
/>
</div>
</div>
<!-- Directory Settings -->
<div>
<h2
class="text-2xl font-semibold mb-8 text-stone-100 ethereal-text"
>
Directory Visibility
</h2>
<div class="flex items-start gap-4">
<USwitch v-model="formData.showInDirectory" />
<div>
<p class="font-medium text-stone-200">
Show in Member Directory
</p>
<p class="text-sm text-stone-400 mt-1">
Allow other members to discover and connect with you
through the directory
</p>
</div>
</div>
</div>
<!-- Success/Error Messages -->
<div
v-if="saveSuccess"
class="backdrop-blur-sm bg-green-500/10 border border-green-500/30 rounded-lg p-4"
>
<p class="text-green-300"> Profile updated successfully!</p>
</div>
<div
v-if="saveError"
class="backdrop-blur-sm bg-red-500/10 border border-red-500/30 rounded-lg p-4"
>
<p class="text-red-300">
{{ saveError }}
</p>
</div>
<!-- Actions -->
<div
class="flex justify-between items-center pt-4 border-t border-ghost-800/50"
>
<UButton
type="button"
variant="ghost"
color="gray"
@click="resetForm"
>
Reset Changes
</UButton>
<UButton
type="submit"
:loading="saving"
:disabled="!hasChanges"
>
Save Profile
</UButton>
</div>
</UForm>
</template>
<template #updates>
<div class="mt-8">
<!-- Loading State -->
<div
v-if="loadingUpdates && !myUpdates.length"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-stone-400">Loading your updates...</p>
</div>
</div>
<!-- Updates List -->
<div v-else-if="myUpdates.length" class="space-y-6">
<UpdateCard
v-for="update in myUpdates"
:key="update._id"
:update="update"
:show-preview="true"
@edit="handleEditUpdate"
@delete="handleDeleteUpdate"
/>
<!-- Load More -->
<div v-if="hasMoreUpdates" class="flex justify-center pt-4">
<UButton
variant="outline"
color="neutral"
:loading="loadingMoreUpdates"
@click="loadMoreUpdates"
>
Load More
</UButton>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-20">
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
class="text-stone-600"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-stone-300 mb-2">
No updates yet
</h3>
<p class="text-stone-400 mb-6">
Share your first update with the community!
</p>
<UButton to="/updates/new"> Post Your First Update </UButton>
</div>
</div>
</template>
<template #account>
<div class="space-y-8 mt-8">
<!-- Current Membership -->
<div>
<h2
class="text-2xl font-semibold mb-6 text-stone-100 ethereal-text"
>
Current Membership
</h2>
<div
class="backdrop-blur-sm bg-stone-800/50 border border-stone-700 rounded-lg p-6 space-y-4"
>
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-stone-400">Circle</p>
<p
class="text-lg font-medium text-stone-100 capitalize"
>
{{ memberData.circle }}
</p>
</div>
<div>
<p class="text-sm text-stone-400">Contribution Level</p>
<p class="text-lg font-medium text-stone-100">
${{ contributionTierDetails?.amount }}/month
</p>
</div>
</div>
<div v-if="memberData.subscriptionStartDate">
<p class="text-sm text-stone-400">Member Since</p>
<p class="text-stone-100">
{{ formatDate(memberData.subscriptionStartDate) }}
</p>
</div>
<div
v-if="
memberData.nextBillingDate &&
memberData.contributionTier !== '0'
"
>
<p class="text-sm text-stone-400">Next Billing Date</p>
<p class="text-stone-100">
{{ formatDate(memberData.nextBillingDate) }}
</p>
</div>
</div>
</div>
<!-- Change Contribution Level -->
<div>
<h2
class="text-2xl font-semibold mb-6 text-stone-100 ethereal-text"
>
Change Contribution Level
</h2>
<div
class="backdrop-blur-sm bg-stone-800/50 border border-stone-700 rounded-lg p-6"
>
<p class="text-stone-300 mb-6">
Choose a new contribution level that works for you.
Changes will take effect on your next billing cycle.
</p>
<div class="space-y-3 mb-6">
<button
v-for="tier in contributionOptions"
:key="tier.value"
type="button"
:class="[
'w-full text-left p-4 rounded-lg border-2 transition-all',
selectedContributionTier === tier.value
? 'border-blue-400 bg-blue-500/20'
: 'border-stone-600 bg-stone-900/30 hover:border-stone-500',
]"
@click="selectedContributionTier = tier.value"
>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-stone-100">
{{ tier.label }}
</p>
<p class="text-sm text-stone-400 mt-1">
{{ tier.features[0] }}
</p>
</div>
<div
v-if="selectedContributionTier === tier.value"
class="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs"
>
</div>
</div>
</button>
</div>
<div
v-if="contributionChangeError"
class="mb-4 backdrop-blur-sm bg-red-500/10 border border-red-500/30 rounded-lg p-4"
>
<p class="text-red-300">{{ contributionChangeError }}</p>
</div>
<div
v-if="contributionChangeSuccess"
class="mb-4 backdrop-blur-sm bg-green-500/10 border border-green-500/30 rounded-lg p-4"
>
<p class="text-green-300">
✓ Contribution level updated successfully!
</p>
</div>
<UButton
:disabled="
selectedContributionTier ===
memberData.contributionTier || updatingContribution
"
:loading="updatingContribution"
@click="updateContributionLevel"
>
Update Contribution Level
</UButton>
</div>
</div>
<!-- Cancel Membership -->
<div>
<h2
class="text-2xl font-semibold mb-6 text-stone-100 ethereal-text"
>
Cancel Membership
</h2>
<div
class="backdrop-blur-sm bg-stone-800/50 border border-stone-700 rounded-lg p-6"
>
<p class="text-stone-300 mb-4">
We're sorry to see you go. If you cancel, you'll lose
access to member benefits at the end of your current
billing period.
</p>
<p class="text-sm text-stone-400 mb-6">
Need a break? Consider switching to the free tier instead.
</p>
<UButton
color="red"
variant="outline"
@click="showCancelModal = true"
>
Cancel Membership
</UButton>
</div>
</div>
</div>
</template>
</UTabs>
<!-- Cancel Membership Modal -->
<UModal
v-model:open="showCancelModal"
title="Cancel Membership?"
description="Are you sure you want to cancel your Ghost Guild membership? You'll lose access to member benefits at the end of your billing period."
>
<template #footer>
<div class="flex justify-end gap-3">
<UButton
variant="ghost"
color="neutral"
@click="showCancelModal = false"
>
Keep Membership
</UButton>
<UButton
color="red"
:loading="cancelingMembership"
@click="cancelMembership"
>
Cancel Membership
</UButton>
</div>
</template>
</UModal>
<!-- Delete Update Modal -->
<UModal
v-model:open="showDeleteUpdateModal"
title="Delete Update?"
description="Are you sure you want to delete this update? This action cannot be undone."
>
<template #footer>
<div class="flex justify-end gap-3">
<UButton
variant="ghost"
color="neutral"
@click="showDeleteUpdateModal = false"
>
Cancel
</UButton>
<UButton
color="red"
:loading="deletingUpdate"
@click="confirmDeleteUpdate"
>
Delete
</UButton>
</div>
</template>
</UModal>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
const { memberData, checkMemberStatus } = useAuth();
const route = useRoute();
// Initialize active tab from URL hash or default to 'profile'
const getInitialTab = () => {
const hash = route.hash.replace("#", "");
return hash && ["profile", "updates", "account"].includes(hash)
? hash
: "profile";
};
const activeTab = ref(getInitialTab());
// Watch for hash changes
watch(
() => route.hash,
(newHash) => {
const hash = newHash.replace("#", "");
if (hash && ["profile", "updates", "account"].includes(hash)) {
activeTab.value = hash;
} else if (!hash) {
activeTab.value = "profile";
}
},
);
// Tab configuration
const tabItems = [
{
label: "Profile",
slot: "profile",
value: "profile",
},
{
label: "My Updates",
slot: "updates",
value: "updates",
},
{
label: "Account",
slot: "account",
value: "account",
},
];
// Available ghost avatars
const availableGhosts = [
{
value: "disbelieving",
label: "Disbelieving",
image: "/ghosties/Ghost-Disbelieving.png",
},
{
value: "double-take",
label: "Double Take",
image: "/ghosties/Ghost-Double-Take.png",
},
{
value: "exasperated",
label: "Exasperated",
image: "/ghosties/Ghost-Exasperated.png",
},
{
value: "mild",
label: "Mild",
image: "/ghosties/Ghost-Mild.png",
},
{
value: "sweet",
label: "Sweet",
image: "/ghosties/Ghost-Sweet.png",
},
{
value: "wtf",
label: "WTF",
image: "/ghosties/Ghost-WTF.png",
},
];
// Form state
const formData = reactive({
name: "",
pronouns: "",
timeZone: "",
avatar: "",
studio: "",
bio: "",
skills: [],
location: "",
offering: "",
lookingFor: "",
socialLinks: {
mastodon: "",
linkedin: "",
website: "",
other: "",
},
showInDirectory: true,
// Privacy settings
pronounsPrivacy: "members",
timeZonePrivacy: "members",
avatarPrivacy: "public",
studioPrivacy: "members",
bioPrivacy: "members",
skillsPrivacy: "members",
locationPrivacy: "members",
offeringPrivacy: "members",
lookingForPrivacy: "members",
socialLinksPrivacy: "members",
});
const currentSkillInput = ref("");
const loading = ref(false);
const saving = ref(false);
const saveSuccess = ref(false);
const saveError = ref(null);
const initialData = ref(null);
// Updates state
const myUpdates = ref([]);
const loadingUpdates = ref(false);
const loadingMoreUpdates = ref(false);
const hasMoreUpdates = ref(false);
const showDeleteUpdateModal = ref(false);
const updateToDelete = ref(null);
const deletingUpdate = ref(false);
// Account management state
const contributionOptions = ref([]);
const selectedContributionTier = ref("");
const updatingContribution = ref(false);
const contributionChangeError = ref(null);
const contributionChangeSuccess = ref(false);
const showCancelModal = ref(false);
const cancelingMembership = ref(false);
// Helcim payment integration
const {
initializeHelcimPay,
verifyPayment,
cleanup: cleanupHelcimPay,
} = useHelcimPay();
const customerId = ref("");
const customerCode = ref("");
// Computed
const contributionTierDetails = computed(() => {
return contributionOptions.value.find(
(tier) => tier.value === memberData.value?.contributionTier,
);
});
const hasChanges = computed(() => {
return JSON.stringify(formData) !== JSON.stringify(initialData.value);
});
// Add skill
const addSkill = () => {
const skill = currentSkillInput.value.trim().replace(/,$/, "");
if (skill && !formData.skills.includes(skill)) {
formData.skills.push(skill);
currentSkillInput.value = "";
}
};
// Remove skill
const removeSkill = (index) => {
formData.skills.splice(index, 1);
};
// Load member data
const loadProfile = () => {
if (memberData.value) {
formData.name = memberData.value.name || "";
formData.pronouns = memberData.value.pronouns || "";
formData.timeZone = memberData.value.timeZone || "";
formData.avatar = memberData.value.avatar || "";
formData.studio = memberData.value.studio || "";
formData.bio = memberData.value.bio || "";
formData.skills = memberData.value.skills || [];
formData.location = memberData.value.location || "";
formData.offering = memberData.value.offering || "";
formData.lookingFor = memberData.value.lookingFor || "";
formData.socialLinks = memberData.value.socialLinks || {
mastodon: "",
linkedin: "",
website: "",
other: "",
};
formData.showInDirectory = memberData.value.showInDirectory ?? true;
// Load privacy settings (with defaults)
const privacy = memberData.value.privacy || {};
formData.pronounsPrivacy = privacy.pronouns || "members";
formData.timeZonePrivacy = privacy.timeZone || "members";
formData.avatarPrivacy = privacy.avatar || "public";
formData.studioPrivacy = privacy.studio || "members";
formData.bioPrivacy = privacy.bio || "members";
formData.skillsPrivacy = privacy.skills || "members";
formData.locationPrivacy = privacy.location || "members";
formData.offeringPrivacy = privacy.offering || "members";
formData.lookingForPrivacy = privacy.lookingFor || "members";
formData.socialLinksPrivacy = privacy.socialLinks || "members";
// Store initial state for change detection
initialData.value = JSON.parse(JSON.stringify(formData));
}
};
// Handle form submission
const handleSubmit = async () => {
saving.value = true;
saveSuccess.value = false;
saveError.value = null;
try {
await $fetch("/api/members/profile", {
method: "PATCH",
body: formData,
});
saveSuccess.value = true;
// Refresh member data
await checkMemberStatus();
loadProfile();
// Clear success message after 3 seconds
setTimeout(() => {
saveSuccess.value = false;
}, 3000);
} catch (error) {
console.error("Profile save error:", error);
saveError.value =
error.data?.message || "Failed to save profile. Please try again.";
} finally {
saving.value = false;
}
};
// Reset form to initial state
const resetForm = () => {
loadProfile();
saveSuccess.value = false;
saveError.value = null;
};
// Load user's updates
const loadUpdates = async () => {
if (!memberData.value?.id) return;
loadingUpdates.value = true;
try {
const response = await $fetch(`/api/updates/user/${memberData.value.id}`, {
params: { limit: 20, skip: 0 },
});
myUpdates.value = response.updates;
hasMoreUpdates.value = response.hasMore;
} catch (error) {
console.error("Failed to load updates:", error);
} finally {
loadingUpdates.value = false;
}
};
// Load more updates
const loadMoreUpdates = async () => {
if (!memberData.value?.id) return;
loadingMoreUpdates.value = true;
try {
const response = await $fetch(`/api/updates/user/${memberData.value.id}`, {
params: { limit: 20, skip: myUpdates.value.length },
});
myUpdates.value.push(...response.updates);
hasMoreUpdates.value = response.hasMore;
} catch (error) {
console.error("Failed to load more updates:", error);
} finally {
loadingMoreUpdates.value = false;
}
};
// Handle edit update
const handleEditUpdate = (update) => {
navigateTo(`/updates/${update._id}/edit`);
};
// Handle delete update
const handleDeleteUpdate = (update) => {
updateToDelete.value = update;
showDeleteUpdateModal.value = true;
};
// Confirm delete update
const confirmDeleteUpdate = async () => {
if (!updateToDelete.value) return;
deletingUpdate.value = true;
try {
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
method: "DELETE",
});
// Remove from list
myUpdates.value = myUpdates.value.filter(
(u) => u._id !== updateToDelete.value._id,
);
showDeleteUpdateModal.value = false;
updateToDelete.value = null;
} catch (error) {
console.error("Failed to delete update:", error);
alert("Failed to delete update. Please try again.");
} finally {
deletingUpdate.value = false;
}
};
// Format date helper
const formatDate = (dateString) => {
if (!dateString) return "";
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
// Load contribution options
const loadContributionOptions = async () => {
try {
const response = await $fetch("/api/contributions/options");
contributionOptions.value = response.options;
} catch (error) {
console.error("Failed to load contribution options:", error);
}
};
// Check if tier requires payment
const requiresPayment = (tier) => {
const tierInfo = contributionOptions.value.find((t) => t.value === tier);
return tierInfo?.amount > 0;
};
// Update contribution level
const updateContributionLevel = async () => {
if (selectedContributionTier.value === memberData.value.contributionTier) {
return;
}
updatingContribution.value = true;
contributionChangeError.value = null;
contributionChangeSuccess.value = false;
try {
const oldTierRequiresPayment = requiresPayment(
memberData.value.contributionTier,
);
const newTierRequiresPayment = requiresPayment(
selectedContributionTier.value,
);
// Check if upgrading from free to paid tier
if (!oldTierRequiresPayment && newTierRequiresPayment) {
// Always show payment popup when upgrading from free to paid
// Even if they have a customer ID, they might not have an active subscription
console.log("Upgrading from free to paid - showing payment popup");
await handlePaymentSetup();
console.log("Payment setup completed successfully, returning early");
return;
}
console.log("Calling update-contribution API");
// Call API to update contribution
await $fetch("/api/members/update-contribution", {
method: "POST",
body: {
contributionTier: selectedContributionTier.value,
},
});
contributionChangeSuccess.value = true;
// Refresh member data
await checkMemberStatus();
// Clear success message after 3 seconds
setTimeout(() => {
contributionChangeSuccess.value = false;
}, 3000);
} catch (error) {
console.error("Failed to update contribution level:", error);
// Check if this requires payment setup
const requiresPayment =
error.data?.requiresPaymentSetup ||
error.cause?.data?.requiresPaymentSetup ||
(error.statusMessage || error.message || "").includes(
"Payment information required",
);
if (requiresPayment) {
// Show payment modal
console.log("Showing payment modal - payment setup required");
try {
await handlePaymentSetup();
// If successful, return early without showing error
return;
} catch (paymentError) {
console.error("Payment setup failed:", paymentError);
contributionChangeError.value =
paymentError.message || "Payment setup failed. Please try again.";
}
} else {
contributionChangeError.value =
error.data?.message ||
"Failed to update contribution level. Please try again.";
}
} finally {
updatingContribution.value = false;
}
};
// Handle payment setup for upgrading to paid tier
const handlePaymentSetup = async () => {
try {
// Get or create Helcim customer
if (!memberData.value.helcimCustomerId) {
// Use server-side endpoint to avoid CORS issues
const customerResponse = await $fetch(
"/api/helcim/get-or-create-customer",
{
method: "POST",
},
);
customerId.value = customerResponse.customerId;
customerCode.value = customerResponse.customerCode;
console.log(
customerResponse.existing
? "Using existing Helcim customer:"
: "Created new Helcim customer:",
customerId.value,
);
} else {
// Get customer code from existing customer via server-side endpoint
const customerResponse = await $fetch("/api/helcim/customer-code");
customerId.value = customerResponse.customerId;
customerCode.value = customerResponse.customerCode;
}
// Initialize Helcim payment with $0 for card verification
await initializeHelcimPay(customerId.value, customerCode.value, 0);
// Show payment modal
const paymentResult = await verifyPayment();
console.log("Payment result:", paymentResult);
if (!paymentResult.success) {
throw new Error("Payment verification failed");
}
// Verify payment on backend
const verifyResult = await $fetch("/api/helcim/verify-payment", {
method: "POST",
body: {
cardToken: paymentResult.cardToken,
customerId: customerId.value,
},
});
if (!verifyResult.success) {
throw new Error("Payment verification failed");
}
// Create subscription
const subscriptionResponse = await $fetch("/api/helcim/subscription", {
method: "POST",
body: {
customerId: customerId.value,
customerCode: customerCode.value,
contributionTier: selectedContributionTier.value,
cardToken: paymentResult.cardToken,
},
});
if (!subscriptionResponse.success) {
throw new Error("Subscription creation failed");
}
contributionChangeSuccess.value = true;
// Refresh member data
await checkMemberStatus();
// Clear success message after 3 seconds
setTimeout(() => {
contributionChangeSuccess.value = false;
}, 3000);
} catch (error) {
console.error("Payment setup error:", error);
throw error;
} finally {
cleanupHelcimPay();
}
};
// Cancel membership
const cancelMembership = async () => {
cancelingMembership.value = true;
try {
await $fetch("/api/members/cancel-subscription", {
method: "POST",
});
// Redirect to cancellation confirmation or dashboard
await navigateTo("/member/dashboard?cancelled=true");
} catch (error) {
console.error("Failed to cancel membership:", error);
alert(
error.data?.message || "Failed to cancel membership. Please try again.",
);
} finally {
cancelingMembership.value = false;
showCancelModal.value = false;
}
};
// Initialize on mount
onMounted(async () => {
if (!memberData.value) {
loading.value = true;
const isAuthenticated = await checkMemberStatus();
loading.value = false;
if (!isAuthenticated) {
await navigateTo("/login");
return;
}
}
loadProfile();
loadUpdates();
loadContributionOptions();
selectedContributionTier.value = memberData.value?.contributionTier || "";
});
useHead({
title: "Your Profile - Ghost Guild",
});
</script>
<style scoped>
/* Field labels - bright and readable */
:deep(label) {
color: rgb(231 229 228) !important; /* stone-200 */
font-weight: 500;
}
/* Field descriptions - lighter gray for readability */
:deep([class*="description"]) {
color: rgb(
168 162 158
) !important; /* stone-400 - lighter than the dark background */
opacity: 0.9;
}
/* Full width inputs */
:deep(input),
:deep(textarea) {
width: 100% !important;
}
/* Input fields - ensure good contrast */
:deep(input),
:deep(textarea) {
background-color: rgb(41 37 36) !important; /* stone-800 */
color: rgb(231 229 228) !important; /* stone-200 */
border-color: rgb(87 83 78) !important; /* stone-600 */
}
:deep(input::placeholder),
:deep(textarea::placeholder) {
color: rgb(120 113 108) !important; /* stone-500 */
}
:deep(input:focus),
:deep(textarea:focus) {
border-color: rgb(147 197 253) !important; /* blue-300 */
background-color: rgb(44 40 39) !important; /* slightly lighter stone */
}
</style>