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

499 lines
16 KiB
Vue

<template>
<div>
<!-- Page Header -->
<PageHeader
title="Member Dashboard"
:subtitle="`Welcome back, ${memberData?.name || 'Member'}!`"
theme="blue"
size="medium"
/>
<UContainer class="">
<!-- Loading State -->
<div
v-if="authPending"
class="flex justify-center items-center py-20"
>
<div class="text-center">
<div
class="w-8 h-8 border-4 border-whisper-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
/>
<p class="text-ghost-300">Loading your dashboard...</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 member dashboard.</p>
<UButton @click="openLoginModal({ title: 'Sign in to your dashboard', description: 'Enter your email to access your member dashboard' })">
Sign In
</UButton>
</div>
</div>
<!-- Dashboard Content -->
<div v-else class="space-y-8">
<!-- Member Status Banner -->
<MemberStatusBanner :dismissible="true" />
<!-- Welcome Card -->
<UCard
class="sparkle-field"
:ui="{
root: 'bg-ghost-900 border border-ghost-700',
header: 'border-b border-ghost-700',
body: 'bg-ghost-900',
}"
>
<template #header>
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<h1 class="text-2xl font-bold text-ghost-100 ethereal-text">
Welcome to Ghost Guild, {{ memberData?.name }}!
</h1>
<p
:class="[
'mt-2',
isActive ? 'text-ghost-300' : statusConfig.textColor,
]"
>
{{
isActive ? "Your membership is active!" : statusConfig.label
}}
</p>
</div>
<div class="flex-shrink-0" v-if="memberData?.avatar">
<img
:src="`/ghosties/Ghost-${capitalize(memberData.avatar)}.png`"
:alt="memberData.name"
class="w-16 h-16"
/>
</div>
<div v-else class="flex-shrink-0">
<div
class="w-16 h-16 bg-ghost-700 border border-ghost-600 flex items-center justify-center text-ghost-200 font-bold text-xl"
>
{{ memberData?.name?.charAt(0)?.toUpperCase() }}
</div>
</div>
</div>
</template>
<div class="flex flex-wrap gap-4 text-sm">
<div class="bg-ghost-800 border border-ghost-600 px-4 py-2">
<span class="text-ghost-200">Circle:</span>
<span class="font-medium text-stone-50 ml-1 capitalize">{{
memberData?.circle
}}</span>
</div>
<div class="bg-ghost-800 border border-ghost-600 px-4 py-2">
<span class="text-ghost-200">Contribution:</span>
<span class="font-medium text-stone-50 ml-1"
>${{ memberData?.contributionTier }} CAD/month</span
>
</div>
</div>
</UCard>
<!-- Quick Links -->
<UCard
:ui="{
root: 'bg-ghost-900 border border-ghost-700',
header: 'border-b border-ghost-700 bg-ghost-900',
body: 'bg-ghost-900',
}"
>
<template #header>
<h2 class="text-xl font-bold text-ghost-100 ethereal-text">
Quick Links
</h2>
</template>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<UButton
to="/members?peerSupport=true"
variant="outline"
:disabled="!canPeerSupport"
:class="[
'border-ghost-600 text-ghost-200 justify-start',
canPeerSupport
? 'hover:bg-ghost-800 hover:border-whisper-500'
: 'opacity-50 cursor-not-allowed',
]"
block
:title="
!canPeerSupport
? 'Complete your membership to book peer sessions'
: ''
"
>
Book a Peer Session
</UButton>
<UButton
to="https://wiki.ghostguild.org"
target="_blank"
variant="outline"
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
block
>
Browse Resources
</UButton>
<UButton
to="/member/profile"
variant="outline"
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
block
>
Update Profile
</UButton>
<UButton
to="/events"
variant="outline"
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
block
>
View Events
</UButton>
<UButton
to="/members"
variant="outline"
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
block
>
Browse Members
</UButton>
<UButton
to="/member/profile#account"
variant="outline"
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
block
>
Manage Account
</UButton>
</div>
</UCard>
<!-- Your Registered Events -->
<UCard
:ui="{
root: 'bg-ghost-900 border border-ghost-700',
header: 'border-b border-ghost-700 bg-ghost-900',
body: 'bg-ghost-900',
}"
>
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-ghost-100 ethereal-text">
Your Upcoming Events
</h2>
<div class="flex items-center gap-2">
<UButton
v-if="registeredEvents.length > 0"
@click="copyCalendarLink"
variant="ghost"
size="sm"
class="text-ghost-300 hover:text-ghost-100"
icon="heroicons:calendar"
>
{{
calendarLinkCopied ? "Link Copied!" : "Get Calendar Link"
}}
</UButton>
<UButton
to="/events"
variant="ghost"
size="sm"
class="text-ghost-300 hover:text-ghost-100"
>
Browse All Events
</UButton>
</div>
</div>
</template>
<div v-if="loadingEvents" class="text-center py-8">
<div
class="w-6 h-6 border-2 border-whisper-500 border-t-transparent rounded-full animate-spin mx-auto"
></div>
</div>
<div v-else-if="registeredEvents.length" class="space-y-4">
<NuxtLink
v-for="evt in registeredEvents"
:key="evt._id"
:to="`/events/${evt.slug || evt._id}`"
class="block p-4 border border-ghost-700 hover:border-whisper-500 transition-colors"
>
<div class="flex items-start gap-4">
<div
v-if="
evt.featureImage &&
(evt.featureImage.publicId || evt.featureImage.url)
"
class="flex-shrink-0 w-20 h-20 overflow-hidden"
>
<img
:src="getEventImageUrl(evt.featureImage)"
:alt="evt.title"
class="w-full h-full object-cover"
/>
</div>
<div
v-else
class="flex-shrink-0 w-20 h-20 bg-ghost-800 border border-ghost-600 flex items-center justify-center"
>
<Icon
name="heroicons:calendar-days"
class="w-8 h-8 text-whisper-400"
/>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-ghost-100 mb-1">
{{ evt.title }}
</h3>
<div class="flex items-center gap-4 text-sm text-ghost-400">
<span class="flex items-center gap-1">
<Icon name="heroicons:calendar" class="w-4 h-4" />
{{ formatEventDate(evt.startDate) }}
</span>
<span class="flex items-center gap-1">
<Icon name="heroicons:clock" class="w-4 h-4" />
{{ formatEventTime(evt.startDate) }}
</span>
</div>
</div>
<div class="flex-shrink-0">
<Icon
name="heroicons:chevron-right"
class="w-5 h-5 text-ghost-500"
/>
</div>
</div>
</NuxtLink>
</div>
<div v-else class="text-center py-8">
<Icon
name="heroicons:calendar-days"
class="w-12 h-12 text-ghost-600 mx-auto mb-3"
/>
<p class="text-ghost-400 mb-4">
You haven't registered for any upcoming events
</p>
<UButton
to="/events"
size="sm"
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500"
>
Browse Events
</UButton>
</div>
<!-- Calendar subscription instructions -->
<div
v-if="registeredEvents.length > 0 && showCalendarInstructions"
class="mt-4 p-4 bg-ghost-800 border border-ghost-600"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<h4 class="text-sm font-semibold text-ghost-100 mb-2">
How to Subscribe to Your Calendar
</h4>
<ul
class="text-xs text-ghost-300 space-y-1 list-disc list-inside"
>
<li>
<strong>Google Calendar:</strong> Click "+" → "From URL" →
Paste the link
</li>
<li>
<strong>Apple Calendar:</strong> File → New Calendar
Subscription → Paste the link
</li>
<li>
<strong>Outlook:</strong> Add Calendar → Subscribe from web
→ Paste the link
</li>
</ul>
<p class="text-xs text-ghost-400 mt-2">
Your calendar will automatically update when you register or
unregister from events.
</p>
</div>
<button
@click="showCalendarInstructions = false"
class="text-ghost-400 hover:text-ghost-200"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
</UCard>
</div>
</UContainer>
</div>
</template>
<script setup>
const { memberData, checkMemberStatus } = useAuth();
const { isActive, statusConfig, isPendingPayment, canPeerSupport } = useMemberStatus();
const { completePayment, isProcessingPayment } = useMemberPayment();
const registeredEvents = ref([]);
const loadingEvents = ref(false);
const calendarLinkCopied = ref(false);
const showCalendarInstructions = ref(false);
// Calendar subscription URL
const calendarUrl = computed(() => {
const memberId = memberData.value?._id || memberData.value?.id;
if (!memberId) return "";
const config = useRuntimeConfig();
const baseUrl = config.public.appUrl || "http://localhost:3000";
// Use webcal protocol for calendar subscription
const webcalUrl = baseUrl.replace(/^https?:/, "webcal:");
return `${webcalUrl}/api/members/my-calendar?memberId=${memberId}`;
});
// Copy calendar subscription link to clipboard
const copyCalendarLink = async () => {
try {
await navigator.clipboard.writeText(calendarUrl.value);
calendarLinkCopied.value = true;
showCalendarInstructions.value = true;
setTimeout(() => {
calendarLinkCopied.value = false;
}, 2000);
} catch (err) {
console.error("Failed to copy calendar link:", err);
}
};
const { openLoginModal } = useLoginModal();
// Handle authentication check on page load
const { pending: authPending } = await useLazyAsyncData(
"dashboard-auth",
async () => {
// Only check authentication on client side
if (process.server) return null;
// If no member data, try to authenticate
if (!memberData.value) {
const isAuthenticated = await checkMemberStatus();
if (!isAuthenticated) {
// Show login modal instead of redirecting
openLoginModal({
title: "Sign in to your dashboard",
description: "Enter your email to access your member dashboard",
dismissible: true,
});
return null;
}
}
return memberData.value;
},
);
// Load registered events
const loadRegisteredEvents = async () => {
const memberId = memberData.value?._id || memberData.value?.id;
if (!memberId) {
return;
}
loadingEvents.value = true;
try {
const response = await $fetch("/api/members/my-events", {
params: { memberId },
});
registeredEvents.value = response.events;
} catch (error) {
console.error("Failed to load registered events:", error);
} finally {
loadingEvents.value = false;
}
};
// Valid ghost avatar options
const validAvatars = [
"disbelieving",
"double-take",
"exasperated",
"mild",
"sweet",
];
const isValidAvatar = (avatar) => {
if (!avatar) return false;
return validAvatars.includes(avatar.toLowerCase());
};
const capitalize = (str) => {
if (!str) return "";
// Handle kebab-case or multi-word avatars (e.g., "double-take" -> "Double-Take")
return str
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("-");
};
// Helper functions for event display
const getEventImageUrl = (featureImage) => {
if (!featureImage) return "";
if (featureImage.url) {
return featureImage.url;
}
if (featureImage.publicId) {
const config = useRuntimeConfig();
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/w_200,h_200,c_fill,f_auto,q_auto/${featureImage.publicId}`;
}
return "";
};
const formatEventDate = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
};
const formatEventTime = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
}).format(date);
};
onMounted(() => {
loadRegisteredEvents();
});
// Set page meta
useHead({
title: "Member Dashboard - Ghost Guild",
});
// Removed middleware - handling auth directly in the page component
</script>