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

1394 lines
50 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>
<!-- Loading State -->
<div
v-if="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-gray-600 dark:text-ghost-400">
Loading your profile...
</p>
</div>
</div>
<!-- Unauthenticated State -->
<div
v-else-if="!memberData"
class="flex justify-center items-center py-20"
>
<div class="text-center max-w-md">
<div class="w-16 h-16 bg-ghost-800 border border-ghost-600 rounded-full flex items-center justify-center mx-auto mb-4">
<Icon name="heroicons:lock-closed" class="w-8 h-8 text-ghost-400" />
</div>
<h2 class="text-xl font-semibold text-ghost-100 mb-2">Sign in required</h2>
<p class="text-ghost-400 mb-6">Please sign in to access your profile settings.</p>
<UButton @click="openLoginModal({ title: 'Sign in to your profile', description: 'Enter your email to manage your profile settings' })">
Sign In
</UButton>
</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-gray-900 dark:text-ghost-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"
class="w-full"
/>
</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"
class="w-full"
/>
</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"
class="w-full"
/>
</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-ghost-700 bg-ghost-800/50 hover:border-ghost-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-gray-900 dark:text-ghost-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"
class="w-full"
/>
</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"
class="w-full"
/>
</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
class="w-full"
/>
</UFormField>
<PrivacyToggle
v-model="formData.bioPrivacy"
class="mt-2"
/>
</div>
</div>
</div>
<!-- Community Connections -->
<div>
<h2
class="text-2xl font-semibold mb-8 text-gray-900 dark:text-ghost-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"
>
<div class="space-y-3">
<!-- Tags input -->
<div>
<label
class="block text-sm font-medium text-gray-800 dark:text-ghost-200 mb-2"
>
Skills & Topics
</label>
<UInput
v-model="currentOfferingTagInput"
placeholder="Type a skill and press Enter (e.g., Unity, Playtesting)"
@keydown.enter.prevent="addOfferingTag"
@keydown.comma.prevent="addOfferingTag"
class="w-full"
/>
<div
v-if="formData.offering.tags?.length"
class="flex flex-wrap gap-2 mt-2"
>
<span
v-for="(tag, index) in formData.offering.tags"
:key="tag"
class="px-3 py-1 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300 rounded-full text-sm border border-blue-300 dark:border-blue-500/30 flex items-center gap-2 group hover:bg-blue-200 dark:hover:bg-blue-500/30 transition-colors cursor-pointer"
@click="removeOfferingTag(index)"
>
{{ tag }}
<span class="opacity-50 group-hover:opacity-100"
>×</span
>
</span>
</div>
</div>
<!-- Description textarea -->
<div>
<label
class="block text-sm font-medium text-gray-800 dark:text-ghost-200 mb-2"
>
Details
</label>
<UTextarea
v-model="formData.offering.text"
placeholder="e.g., I have 10+ years in Unity and love helping new devs. Can also playtest narrative games."
:rows="3"
autoresize
class="w-full"
/>
</div>
</div>
</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"
>
<div class="space-y-3">
<!-- Tags input -->
<div>
<label
class="block text-sm font-medium text-gray-800 dark:text-ghost-200 mb-2"
>
Skills & Topics
</label>
<UInput
v-model="currentLookingForTagInput"
placeholder="Type what you need and press Enter (e.g., Co-founder, Marketing)"
@keydown.enter.prevent="addLookingForTag"
@keydown.comma.prevent="addLookingForTag"
class="w-full"
/>
<div
v-if="formData.lookingFor.tags?.length"
class="flex flex-wrap gap-2 mt-2"
>
<span
v-for="(tag, index) in formData.lookingFor.tags"
:key="tag"
class="px-3 py-1 bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300 rounded-full text-sm border border-purple-300 dark:border-purple-500/30 flex items-center gap-2 group hover:bg-purple-200 dark:hover:bg-purple-500/30 transition-colors cursor-pointer"
@click="removeLookingForTag(index)"
>
{{ tag }}
<span class="opacity-50 group-hover:opacity-100"
>×</span
>
</span>
</div>
</div>
<!-- Description textarea -->
<div>
<label
class="block text-sm font-medium text-gray-800 dark:text-ghost-200 mb-2"
>
Details
</label>
<UTextarea
v-model="formData.lookingFor.text"
placeholder="e.g., Seeking a business-minded co-founder for a worker co-op studio. Also need help understanding the publishing landscape."
:rows="3"
autoresize
class="w-full"
/>
</div>
</div>
</UFormField>
<PrivacyToggle
v-model="formData.lookingForPrivacy"
class="mt-2"
/>
</div>
</div>
</div>
<!-- Peer Support -->
<div>
<h2
class="text-2xl font-semibold mb-8 text-gray-900 dark:text-ghost-100 ethereal-text"
>
Peer Support
</h2>
<div class="space-y-6">
<!-- Enable Toggle -->
<div class="flex items-start gap-4">
<USwitch v-model="formData.peerSupportEnabled" />
<div>
<p
class="font-medium text-gray-800 dark:text-ghost-200"
>
Offer Peer Support
</p>
<p
class="text-sm text-gray-600 dark:text-ghost-400 mt-1"
>
Make yourself available to support other members
</p>
</div>
</div>
<!-- Conditional Fields -->
<div
v-if="formData.peerSupportEnabled"
class="space-y-6 pl-4 border-l-2 border-purple-500/30"
>
<!-- Skill-Based Topics -->
<UFormField
label="Skill-Based Topics"
name="peerSupportSkillTopics"
description="Skills from your offerings where you can provide guidance"
>
<div class="space-y-3">
<UInput
v-model="currentPeerSkillTopicInput"
placeholder="Type a skill and press Enter"
@keydown.enter.prevent="addPeerSkillTopic"
@keydown.comma.prevent="addPeerSkillTopic"
class="w-full"
/>
<div
v-if="formData.peerSupportSkillTopics?.length"
class="flex flex-wrap gap-2"
>
<span
v-for="(
topic, index
) in formData.peerSupportSkillTopics"
:key="topic"
class="px-3 py-1 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300 rounded-full text-sm border border-blue-300 dark:border-blue-500/30 flex items-center gap-2 group hover:bg-blue-200 dark:hover:bg-blue-500/30 transition-colors cursor-pointer"
@click="removePeerSkillTopic(index)"
>
{{ topic }}
<span class="opacity-50 group-hover:opacity-100"
>×</span
>
</span>
</div>
<p class="text-xs text-stone-500 mt-2">
Suggested from your offerings:
<span
v-for="tag in formData.offering.tags?.filter(
(t) =>
!formData.peerSupportSkillTopics?.includes(t),
)"
:key="tag"
class="inline-block ml-2 text-primary-400 hover:text-primary-300 cursor-pointer underline"
@click="addSuggestedSkillTopic(tag)"
>
{{ tag }}
</span>
</p>
</div>
</UFormField>
<!-- Conversational Support Topics -->
<UFormField
label="Conversational Support Topics"
name="peerSupportSupportTopics"
>
<UCheckboxGroup
v-model="formData.peerSupportSupportTopics"
:items="availableSupportTopics"
color="primary"
/>
</UFormField>
<!-- Availability -->
<UFormField
label="Your Availability"
name="peerSupportAvailability"
description="When are you generally available for peer support sessions?"
required
>
<UTextarea
v-model="formData.peerSupportAvailability"
placeholder="e.g., Weekday evenings (6-9pm EST), weekends flexible, or by appointment"
rows="3"
autoresize
class="w-full"
/>
</UFormField>
<!-- Personal Message -->
<UFormField
label="Personal Message to Potential Chatters"
name="peerSupportMessage"
description="A friendly message to encourage people to reach out"
>
<UTextarea
v-model="formData.peerSupportMessage"
placeholder="e.g., I love talking about this stuff! No question is too basic."
rows="2"
maxlength="200"
autoresize
class="w-full"
/>
<template #hint>
<span
class="text-xs text-gray-500 dark:text-ghost-500"
>
{{ formData.peerSupportMessage?.length || 0 }}/200
characters
</span>
</template>
</UFormField>
<!-- Slack Username -->
<UFormField
label="Slack Username"
name="peerSupportSlackUsername"
description="Your Slack username so members can message you"
required
>
<UInput
v-model="formData.peerSupportSlackUsername"
placeholder="@yourslackname"
class="w-full"
/>
</UFormField>
</div>
</div>
</div>
<!-- Directory Settings -->
<div>
<h2
class="text-2xl font-semibold mb-8 text-gray-900 dark:text-ghost-100 ethereal-text"
>
Directory Visibility
</h2>
<div class="flex items-start gap-4">
<USwitch v-model="formData.showInDirectory" />
<div>
<p class="font-medium text-gray-800 dark:text-ghost-200">
Show in Member Directory
</p>
<p class="text-sm text-gray-600 dark:text-ghost-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 #account>
<div class="space-y-8 mt-8">
<!-- Member Status Banner -->
<MemberStatusBanner :dismissible="false" />
<!-- Current Membership -->
<div>
<h2
class="text-2xl font-semibold mb-6 text-gray-900 dark:text-ghost-100 ethereal-text"
>
Current Membership
</h2>
<div
class="backdrop-blur-sm bg-white/80 dark:bg-ghost-800/50 border border-gray-200 dark:border-ghost-700 rounded-lg p-6 space-y-4"
>
<!-- Status Badge -->
<div
class="flex items-center justify-between pb-4 border-b border-gray-200 dark:border-ghost-700"
>
<div>
<p class="text-sm text-gray-600 dark:text-ghost-400">
Membership Status
</p>
<div class="flex items-center gap-2 mt-1">
<Icon
:name="statusConfig.icon"
:class="['w-5 h-5', statusConfig.textColor]"
/>
<p
:class="[
'text-lg font-medium',
statusConfig.textColor,
]"
>
{{ statusConfig.label }}
</p>
</div>
</div>
<span
:class="[
'px-4 py-2 rounded-full text-xs font-medium',
statusConfig.bgColor,
statusConfig.borderColor,
'border',
statusConfig.textColor,
]"
>
{{ statusConfig.label }}
</span>
</div>
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-ghost-400">
Circle
</p>
<p
class="text-lg font-medium text-gray-900 dark:text-ghost-100 capitalize"
>
{{ memberData.circle }}
</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-ghost-400">
Contribution Level
</p>
<p
class="text-lg font-medium text-gray-900 dark:text-ghost-100"
>
${{ contributionTierDetails?.amount }}/month
</p>
</div>
</div>
<div v-if="memberData.subscriptionStartDate">
<p class="text-sm text-gray-600 dark:text-ghost-400">
Member Since
</p>
<p class="text-gray-900 dark:text-ghost-100">
{{ formatDate(memberData.subscriptionStartDate) }}
</p>
</div>
<div
v-if="
memberData.nextBillingDate &&
memberData.contributionTier !== '0'
"
>
<p class="text-sm text-gray-600 dark:text-ghost-400">
Next Billing Date
</p>
<p class="text-gray-900 dark:text-ghost-100">
{{ formatDate(memberData.nextBillingDate) }}
</p>
</div>
</div>
</div>
<!-- Change Contribution Level -->
<div>
<h2
class="text-2xl font-semibold mb-6 text-gray-900 dark:text-ghost-100 ethereal-text"
>
Change Contribution Level
</h2>
<div
class="backdrop-blur-sm bg-white/80 dark:bg-ghost-800/50 border border-gray-200 dark:border-ghost-700 rounded-lg p-6"
>
<p class="text-gray-700 dark:text-ghost-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-gray-300 dark:border-ghost-600 bg-gray-50 dark:bg-ghost-900/30 hover:border-blue-300 dark:hover:border-ghost-500',
]"
@click="selectedContributionTier = tier.value"
>
<div class="flex items-center justify-between">
<div>
<p
class="font-medium text-gray-900 dark:text-ghost-100"
>
{{ tier.label }}
</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-gray-900 dark:text-ghost-100 ethereal-text"
>
Cancel Membership
</h2>
<div
class="backdrop-blur-sm bg-white/80 dark:bg-ghost-800/50 border border-gray-200 dark:border-ghost-700 rounded-lg p-6"
>
<p class="text-gray-700 dark:text-ghost-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-gray-600 dark:text-ghost-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>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
const { memberData, checkMemberStatus } = useAuth();
const { openLoginModal } = useLoginModal();
const { statusConfig } = useMemberStatus();
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: "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: "",
location: "",
offering: {
text: "",
tags: [],
},
lookingFor: {
text: "",
tags: [],
},
showInDirectory: true,
// Peer support fields
peerSupportEnabled: false,
peerSupportSkillTopics: [],
peerSupportSupportTopics: [],
peerSupportAvailability: "",
peerSupportMessage: "",
peerSupportSlackUsername: "",
// Privacy settings
pronounsPrivacy: "members",
timeZonePrivacy: "members",
avatarPrivacy: "members",
studioPrivacy: "members",
bioPrivacy: "members",
locationPrivacy: "members",
offeringPrivacy: "members",
lookingForPrivacy: "members",
});
// Tag input refs
const currentOfferingTagInput = ref("");
const currentLookingForTagInput = ref("");
const currentPeerSkillTopicInput = ref("");
const loading = ref(false);
const saving = ref(false);
const saveSuccess = ref(false);
const saveError = ref(null);
const initialData = ref(null);
// Available conversational support topics for peer support
const availableSupportTopics = [
"Co-founder relationships",
"Burnout prevention",
"Impostor syndrome",
"Work-life boundaries",
"Conflict resolution",
"General chat & support",
];
// 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);
});
// Offering tag management
const addOfferingTag = () => {
const tag = currentOfferingTagInput.value.trim().replace(/,$/, "");
if (!Array.isArray(formData.offering.tags)) {
formData.offering.tags = [];
}
if (tag && !formData.offering.tags.includes(tag)) {
formData.offering.tags.push(tag);
currentOfferingTagInput.value = "";
}
};
const removeOfferingTag = (index) => {
if (!formData.offering.tags) return;
formData.offering.tags.splice(index, 1);
};
// Looking For tag management
const addLookingForTag = () => {
const tag = currentLookingForTagInput.value.trim().replace(/,$/, "");
if (!Array.isArray(formData.lookingFor.tags)) {
formData.lookingFor.tags = [];
}
if (tag && !formData.lookingFor.tags.includes(tag)) {
formData.lookingFor.tags.push(tag);
currentLookingForTagInput.value = "";
}
};
const removeLookingForTag = (index) => {
if (!formData.lookingFor.tags) return;
formData.lookingFor.tags.splice(index, 1);
};
// Peer support skill topic management
const addPeerSkillTopic = () => {
const topic = currentPeerSkillTopicInput.value.trim().replace(/,$/, "");
if (topic && !formData.peerSupportSkillTopics.includes(topic)) {
formData.peerSupportSkillTopics.push(topic);
currentPeerSkillTopicInput.value = "";
}
};
const removePeerSkillTopic = (index) => {
formData.peerSupportSkillTopics.splice(index, 1);
};
const addSuggestedSkillTopic = (tag) => {
if (!Array.isArray(formData.peerSupportSkillTopics)) {
formData.peerSupportSkillTopics = [];
}
if (!formData.peerSupportSkillTopics.includes(tag)) {
formData.peerSupportSkillTopics.push(tag);
}
};
// 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.location = memberData.value.location || "";
// Load offering (handle both old string and new object format)
if (typeof memberData.value.offering === "string") {
formData.offering.text = memberData.value.offering;
formData.offering.tags = [];
} else if (memberData.value.offering) {
formData.offering.text = memberData.value.offering?.text || "";
formData.offering.tags = Array.isArray(memberData.value.offering?.tags)
? [...memberData.value.offering.tags]
: [];
} else {
formData.offering.text = "";
formData.offering.tags = [];
}
// Load lookingFor (handle both old string and new object format)
if (typeof memberData.value.lookingFor === "string") {
formData.lookingFor.text = memberData.value.lookingFor;
formData.lookingFor.tags = [];
} else if (memberData.value.lookingFor) {
formData.lookingFor.text = memberData.value.lookingFor?.text || "";
formData.lookingFor.tags = Array.isArray(
memberData.value.lookingFor?.tags,
)
? [...memberData.value.lookingFor.tags]
: [];
} else {
formData.lookingFor.text = "";
formData.lookingFor.tags = [];
}
formData.showInDirectory = memberData.value.showInDirectory ?? true;
// Load peer support data
if (memberData.value.peerSupport) {
formData.peerSupportEnabled =
memberData.value.peerSupport.enabled || false;
formData.peerSupportSkillTopics = Array.isArray(
memberData.value.peerSupport.skillTopics,
)
? [...memberData.value.peerSupport.skillTopics]
: [];
formData.peerSupportSupportTopics = Array.isArray(
memberData.value.peerSupport.supportTopics,
)
? [...memberData.value.peerSupport.supportTopics]
: [];
formData.peerSupportAvailability =
memberData.value.peerSupport.availability || "";
formData.peerSupportMessage =
memberData.value.peerSupport.personalMessage || "";
formData.peerSupportSlackUsername =
memberData.value.peerSupport.slackUsername || "";
} else {
formData.peerSupportEnabled = false;
formData.peerSupportSkillTopics = [];
formData.peerSupportSupportTopics = [];
formData.peerSupportAvailability = "";
formData.peerSupportMessage = "";
formData.peerSupportSlackUsername = "";
}
// Load privacy settings (with defaults)
const privacy = memberData.value.privacy || {};
formData.pronounsPrivacy = privacy.pronouns || "members";
formData.timeZonePrivacy = privacy.timeZone || "members";
formData.avatarPrivacy = privacy.avatar || "members";
formData.studioPrivacy = privacy.studio || "members";
formData.bioPrivacy = privacy.bio || "members";
formData.locationPrivacy = privacy.location || "members";
formData.offeringPrivacy = privacy.offering || "members";
formData.lookingForPrivacy = privacy.lookingFor || "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 {
// Save profile data
await $fetch("/api/members/profile", {
method: "PATCH",
body: formData,
});
// Save peer support data separately
await $fetch("/api/members/me/peer-support", {
method: "PATCH",
body: {
enabled: formData.peerSupportEnabled,
skillTopics: formData.peerSupportSkillTopics,
supportTopics: formData.peerSupportSupportTopics,
availability: formData.peerSupportAvailability,
personalMessage: formData.peerSupportMessage,
slackUsername: formData.peerSupportSlackUsername,
},
});
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;
};
// 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
await handlePaymentSetup();
return;
}
// 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
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;
} 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();
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) {
// Show login modal instead of redirecting
openLoginModal({
title: "Sign in to your profile",
description: "Enter your email to manage your profile settings",
});
return;
}
}
loadProfile();
loadContributionOptions();
selectedContributionTier.value = memberData.value?.contributionTier || "";
});
useHead({
title: "Your Profile - Ghost Guild",
});
</script>