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

1365 lines
47 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-ghost-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-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"
disabled
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-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-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-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-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="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-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-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-500/20 text-purple-300 rounded-full text-sm border border-purple-500/30 flex items-center gap-2 group 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-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-ghost-100 ethereal-text"
>
Peer Support
</h2>
<div
class="mb-6 backdrop-blur-sm bg-purple-500/10 border border-purple-500/30 rounded-lg p-4"
>
<p class="text-ghost-300 text-sm leading-relaxed">
Offer guidance to fellow members through the
<NuxtLink
to="/peer-support"
class="text-purple-400 hover:text-purple-300 underline"
>
Peer Support directory
</NuxtLink>
</p>
</div>
<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-ghost-200">
Offer Peer Support
</p>
<p class="text-sm 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-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="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-blue-400 hover:text-blue-300 cursor-pointer underline"
@click="addSuggestedSkillTopic(tag)"
>
{{ tag }}
</span>
</p>
</div>
</UFormField>
<!-- Conversational Support Topics -->
<UFormField
label="Conversational Support Topics"
name="peerSupportSupportTopics"
description="Select emotional and conversational support areas"
>
<div class="space-y-2 mt-2">
<label
v-for="topic in availableSupportTopics"
:key="topic"
class="flex items-center gap-3 p-3 rounded-lg border border-ghost-700 hover:border-purple-500/50 transition-colors cursor-pointer"
:class="
formData.peerSupportSupportTopics.includes(topic)
? 'bg-purple-500/10 border-purple-500/50'
: 'bg-ghost-900/50'
"
>
<input
type="checkbox"
:value="topic"
v-model="formData.peerSupportSupportTopics"
class="rounded border-ghost-600 text-purple-500 focus:ring-purple-500 focus:ring-offset-0 bg-ghost-800"
/>
<span class="text-ghost-200">{{ topic }}</span>
</label>
</div>
</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-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-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-ghost-200">
Show in Member Directory
</p>
<p class="text-sm 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">
<!-- Current Membership -->
<div>
<h2
class="text-2xl font-semibold mb-6 text-ghost-100 ethereal-text"
>
Current Membership
</h2>
<div
class="backdrop-blur-sm bg-ghost-800/50 border border-ghost-700 rounded-lg p-6 space-y-4"
>
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-ghost-400">Circle</p>
<p
class="text-lg font-medium text-ghost-100 capitalize"
>
{{ memberData.circle }}
</p>
</div>
<div>
<p class="text-sm text-ghost-400">Contribution Level</p>
<p class="text-lg font-medium text-ghost-100">
${{ contributionTierDetails?.amount }}/month
</p>
</div>
</div>
<div v-if="memberData.subscriptionStartDate">
<p class="text-sm text-ghost-400">Member Since</p>
<p class="text-ghost-100">
{{ formatDate(memberData.subscriptionStartDate) }}
</p>
</div>
<div
v-if="
memberData.nextBillingDate &&
memberData.contributionTier !== '0'
"
>
<p class="text-sm text-ghost-400">Next Billing Date</p>
<p class="text-ghost-100">
{{ formatDate(memberData.nextBillingDate) }}
</p>
</div>
</div>
</div>
<!-- Change Contribution Level -->
<div>
<h2
class="text-2xl font-semibold mb-6 text-ghost-100 ethereal-text"
>
Change Contribution Level
</h2>
<div
class="backdrop-blur-sm bg-ghost-800/50 border border-ghost-700 rounded-lg p-6"
>
<p class="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-ghost-600 bg-ghost-900/30 hover:border-ghost-500',
]"
@click="selectedContributionTier = tier.value"
>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-ghost-100">
{{ tier.label }}
</p>
<p class="text-sm text-ghost-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-ghost-100 ethereal-text"
>
Cancel Membership
</h2>
<div
class="backdrop-blur-sm bg-ghost-800/50 border border-ghost-700 rounded-lg p-6"
>
<p class="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-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 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 (tag && !formData.offering.tags.includes(tag)) {
formData.offering.tags.push(tag);
currentOfferingTagInput.value = "";
}
};
const removeOfferingTag = (index) => {
formData.offering.tags.splice(index, 1);
};
// Looking For tag management
const addLookingForTag = () => {
const tag = currentLookingForTagInput.value.trim().replace(/,$/, "");
if (tag && !formData.lookingFor.tags.includes(tag)) {
formData.lookingFor.tags.push(tag);
currentLookingForTagInput.value = "";
}
};
const removeLookingForTag = (index) => {
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 (!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, tags: [] };
} else {
formData.offering = {
text: memberData.value.offering?.text || "",
tags: memberData.value.offering?.tags || [],
};
}
// Load lookingFor (handle both old string and new object format)
if (typeof memberData.value.lookingFor === "string") {
formData.lookingFor = { text: memberData.value.lookingFor, tags: [] };
} else {
formData.lookingFor = {
text: memberData.value.lookingFor?.text || "",
tags: memberData.value.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 =
memberData.value.peerSupport.skillTopics || [];
formData.peerSupportSupportTopics =
memberData.value.peerSupport.supportTopics || [];
formData.peerSupportAvailability =
memberData.value.peerSupport.availability || "";
formData.peerSupportMessage =
memberData.value.peerSupport.personalMessage || "";
formData.peerSupportSlackUsername =
memberData.value.peerSupport.slackUsername || "";
}
// 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
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();
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; /* ghost-200 equivalent */
font-weight: 500;
text-align: left !important;
}
/* Field descriptions - lighter gray for readability */
:deep([class*="description"]) {
color: rgb(
168 162 158
) !important; /* ghost-400 equivalent - lighter than the dark background */
opacity: 0.9;
}
/* Full width inputs */
:deep(input),
:deep(textarea) {
width: 100% !important;
}
/* Input fields - respect light/dark mode */
:deep(input),
:deep(textarea) {
background-color: transparent !important;
color: var(--color-ghost-100) !important;
border-color: var(--color-ghost-600) !important;
}
:deep(input::placeholder),
:deep(textarea::placeholder) {
color: var(--color-ghost-500) !important;
}
:deep(input:focus),
:deep(textarea:focus) {
border-color: rgb(147 197 253) !important;
background-color: transparent !important;
}
</style>