fix: use private helcimApiToken for all server-side Helcim API calls

This commit is contained in:
Jennie Robinson Faber 2026-04-04 13:37:34 +01:00
parent ccd1d0783a
commit d31b5b4dac
53 changed files with 1755 additions and 572 deletions

View file

@ -28,6 +28,7 @@
--text-dim: #5a5040;
--text-faint: #8a7e6a;
--parch: #2a2015;
--parch-hover: #3a3025;
--parch-text: #ede4d0;
--parch-text-dim: #b8ae98;
--c-community: #7a4838;
@ -52,6 +53,7 @@
--text-dim: #8a7e6a;
--text-faint: #5a5040;
--parch: #ede4d0;
--parch-hover: #d4c8a8;
--parch-text: #2a2015;
--parch-text-dim: #5a5040;
--c-community: #a06850;
@ -177,9 +179,17 @@ a:hover { text-decoration: underline; }
/* ---- SECTION DIVIDERS ---- */
.section-divider {
border: none;
display: block;
width: 100%;
max-width: none;
box-sizing: border-box;
border: 0;
border-top: 1px dashed var(--border);
margin: 20px 0 14px;
padding: 0;
flex: 0 0 auto;
align-self: stretch;
min-width: 0;
}
/* ---- MOBILE ---- */

View file

@ -45,7 +45,7 @@
<!-- Early Bird Badge -->
<span
v-if="ticketInfo.isEarlyBird"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-candlelight-900/20 text-candlelight-600 dark:bg-candlelight-900/35 dark:text-candlelight-400"
>
Early Bird
</span>
@ -64,7 +64,7 @@
<!-- Early Bird Countdown -->
<div
v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline"
class="mt-2 text-xs text-amber-400"
class="mt-2 text-xs text-candlelight-500 dark:text-candlelight-400"
>
<Icon name="heroicons:clock" class="w-4 h-4 inline mr-1" />
Early bird ends {{ formatDeadline(ticketInfo.earlyBirdDeadline) }}

View file

@ -1,17 +1,29 @@
<template>
<aside class="events-mini">
<div class="em-label">Upcoming</div>
<div v-for="event in events" :key="event._id" class="em-item">
<span class="em-date">{{ formatDate(event.date) }}</span>
<NuxtLink :to="`/events/${event._id}`" class="em-title">{{ event.title }}</NuxtLink>
<span
v-if="event.circle"
class="em-circle"
:style="{ color: `var(--c-${event.circle})` }"
>{{ event.circle }}</span>
<div class="em-inset">
<div class="em-label">Upcoming</div>
</div>
<div v-if="events?.length" class="em-rows">
<div v-for="event in events" :key="event._id" class="em-item">
<div class="em-inset em-item-body">
<span class="em-date">{{ formatDate(event.date) }}</span>
<NuxtLink :to="`/events/${event._id}`" class="em-title">{{ event.title }}</NuxtLink>
<span
v-if="event.circle"
class="em-circle"
:style="{ color: `var(--c-${event.circle})` }"
>{{ event.circle }}</span>
</div>
</div>
</div>
<div v-else class="em-rows">
<div class="em-item em-item--plain">
<div class="em-inset em-empty">No upcoming events</div>
</div>
</div>
<div class="em-inset em-link-wrap">
<NuxtLink to="/events" class="em-link">All events &rarr;</NuxtLink>
</div>
<div v-if="!events?.length" class="em-empty">No upcoming events</div>
<NuxtLink to="/events" class="em-link">All events &rarr;</NuxtLink>
</aside>
</template>
@ -29,9 +41,17 @@ const formatDate = (dateStr) => {
<style scoped>
.events-mini {
padding: 24px 20px;
border-left: 1px dashed var(--border);
box-sizing: border-box;
align-self: stretch;
height: 100%;
min-height: 100%;
padding: 24px 0;
border-left: 1px dashed var(--border);
}
.em-inset {
padding-left: 20px;
padding-right: 20px;
}
.em-label {
@ -43,14 +63,23 @@ const formatDate = (dateStr) => {
}
.em-item {
padding: 8px 0;
border-bottom: 1px dashed var(--border);
}
.em-item:last-child {
.em-rows .em-item:last-child {
border-bottom: none;
}
.em-item-body {
padding-top: 8px;
padding-bottom: 8px;
}
.em-item--plain .em-empty {
padding-top: 8px;
padding-bottom: 8px;
}
.em-date {
font-size: 11px;
color: var(--text-faint);
@ -77,9 +106,12 @@ const formatDate = (dateStr) => {
display: inline-block;
}
.em-link {
display: block;
.em-link-wrap {
margin-top: 12px;
}
.em-link {
display: inline-block;
font-size: 11px;
color: var(--candle);
}
@ -87,7 +119,6 @@ const formatDate = (dateStr) => {
.em-empty {
font-size: 11px;
color: var(--text-faint);
padding: 8px 0;
}
@media (max-width: 1024px) {

View file

@ -21,12 +21,22 @@
<!-- Content -->
<div v-else-if="passInfo">
<!-- Series Pass Card -->
<!-- Already Registered State -->
<div v-if="passInfo.alreadyRegistered" class="dashed-box p-6">
<div class="section-label mb-2">Series Pass</div>
<p class="text-[--text]">You're registered for this series.</p>
<p v-if="passInfo.registration?.eventsIncluded !== undefined" class="text-[--text-dim] text-sm mt-1">
Registered for {{ passInfo.registration.eventsIncluded }} event{{ passInfo.registration.eventsIncluded !== 1 ? 's' : '' }} in this series.
</p>
</div>
<!-- Series Pass Card (only when ticket data is available) -->
<EventSeriesTicketCard
v-else-if="passInfo.ticket"
:ticket="passInfo.ticket"
:availability="passInfo.availability"
:available="passInfo.available"
:already-registered="passInfo.alreadyRegistered"
:already-registered="false"
:is-member="passInfo.memberInfo?.isMember"
:total-events="seriesInfo.totalEvents"
:events="seriesEvents"
@ -172,7 +182,7 @@ const props = defineProps({
const emit = defineEmits(["purchase-success", "purchase-error"]);
const toast = useToast();
const { initializePayment, verifyPayment } = useHelcimPay();
const { initializeTicketPayment, verifyPayment } = useHelcimPay();
// State
const loading = ref(true);
@ -188,19 +198,29 @@ const form = ref({
const isLoggedIn = computed(() => !!props.userEmail);
// Fetch series pass info on mount
// Fetch series pass info on mount, then re-fetch if userEmail becomes available (auth loads after mount)
onMounted(async () => {
await fetchPassInfo();
});
watch(() => props.userEmail, async (newEmail, oldEmail) => {
if (newEmail && !oldEmail) {
form.value.email = newEmail;
form.value.name = props.userName || form.value.name;
await fetchPassInfo();
}
});
const fetchPassInfo = async () => {
loading.value = true;
error.value = null;
try {
const response = await $fetch(
`/api/series/${props.seriesId}/tickets/available`
);
const email = form.value.email || props.userEmail;
const url = email
? `/api/series/${props.seriesId}/tickets/available?email=${encodeURIComponent(email)}`
: `/api/series/${props.seriesId}/tickets/available`;
const response = await $fetch(url);
passInfo.value = response;
@ -244,15 +264,11 @@ const handleSubmit = async () => {
paymentProcessing.value = true;
// Initialize Helcim payment for series pass
await initializePayment(
await initializeTicketPayment(
props.seriesId,
form.value.email,
passInfo.value.ticket.price,
passInfo.value.ticket.currency || "CAD",
{
type: "series_pass",
seriesId: props.seriesId,
seriesTitle: props.seriesInfo.title,
}
props.seriesInfo.title,
);
// Show Helcim modal and complete payment
@ -267,15 +283,17 @@ const handleSubmit = async () => {
}
// Complete series pass purchase
const purchaseBody = {
name: form.value.name,
email: form.value.email,
};
if (transactionId) purchaseBody.paymentId = transactionId;
const purchaseResponse = await $fetch(
`/api/series/${props.seriesId}/tickets/purchase`,
{
method: "POST",
body: {
name: form.value.name,
email: form.value.email,
paymentId: transactionId,
},
body: purchaseBody,
}
);

View file

@ -239,12 +239,12 @@ export const useHelcimPay = () => {
// Clean up observer after a timeout
setTimeout(() => observer.disconnect(), 5000);
// Add timeout to clean up if no response
// Add timeout to clean up if no response (10 minutes for manual card entry)
setTimeout(() => {
console.log("60 seconds passed, cleaning up event listener...");
console.log("Payment timeout reached, cleaning up event listener...");
window.removeEventListener("message", handleHelcimPayEvent);
reject(new Error("Payment timeout - no response received"));
}, 60000);
}, 600000);
} else {
reject(new Error("appendHelcimPayIframe function not available"));
}

View file

@ -1,5 +1,5 @@
<template>
<div>
<div class="about-page">
<!-- ABOUT HERO (side by side) -->
<div class="about-hero">
<div class="about-hero-left">
@ -82,16 +82,26 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
</script>
<style scoped>
/* Flex chain from layout .main-body: hero + grid grow so sidebar column matches main height */
.about-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- ABOUT HERO ---- */
.about-hero {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
align-items: stretch;
border-bottom: 1px dashed var(--border);
}
.about-hero-left {
padding: 32px 32px 28px;
border-right: 1px dashed var(--border);
align-self: stretch;
}
.about-hero-left h1 {
font-family: 'Brygada 1918', serif;
@ -109,6 +119,7 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
}
.about-hero-right {
padding: 32px;
align-self: stretch;
}
.about-hero-right p {
color: var(--text-dim);
@ -119,12 +130,20 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
/* ---- CONTENT AREA ---- */
.content-area {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.content-main {
padding: 0;
min-width: 0;
align-self: stretch;
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
}
/* ---- SECTIONS ---- */

View file

@ -162,6 +162,25 @@
No events found matching your criteria
</div>
</div>
<!-- Confirm Delete Modal -->
<div v-if="confirmDelete.show" class="modal-overlay" @click.self="confirmDelete.show = false">
<div class="modal">
<div class="modal-header">
<h2>Delete Event</h2>
<button class="modal-close" @click="confirmDelete.show = false">&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>"{{ confirmDelete.title }}"</strong>?</p>
<p class="help-text" style="margin-top: 8px;">This action cannot be undone.</p>
</div>
<div class="modal-actions">
<button class="btn" @click="confirmDelete.show = false">Cancel</button>
<button class="btn btn-danger" :disabled="confirmDelete.deleting" @click="executeDelete">
{{ confirmDelete.deleting ? 'Deleting...' : 'Delete' }}
</button>
</div>
</div>
</div>
</div>
</template>
@ -255,16 +274,25 @@ const duplicateEvent = (event) => {
navigateTo('/admin/events/create?duplicate=true')
}
const deleteEvent = async (event) => {
if (confirm(`Are you sure you want to delete "${event.title}"?`)) {
try {
await $fetch(`/api/admin/events/${String(event._id)}`, {
method: 'DELETE',
})
await refresh()
} catch (error) {
console.error('Failed to delete event:', error)
}
const confirmDelete = reactive({ show: false, id: null, title: '', deleting: false })
const deleteEvent = (event) => {
confirmDelete.id = String(event._id)
confirmDelete.title = event.title
confirmDelete.deleting = false
confirmDelete.show = true
}
const executeDelete = async () => {
confirmDelete.deleting = true
try {
await $fetch(`/api/admin/events/${confirmDelete.id}`, { method: 'DELETE' })
confirmDelete.show = false
await refresh()
} catch (error) {
console.error('Failed to delete event:', error)
} finally {
confirmDelete.deleting = false
}
}
@ -588,6 +616,77 @@ tbody td {
to { transform: rotate(360deg); }
}
/* ---- MODALS ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal {
background: var(--bg);
border: 1px dashed var(--border);
max-width: 440px;
width: 100%;
margin: 16px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px 16px;
border-bottom: 1px dashed var(--border);
}
.modal-header h2 {
font-family: 'Brygada 1918', serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
}
.modal-close {
background: none;
border: none;
color: var(--text-faint);
font-size: 20px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.modal-close:hover {
color: var(--text);
}
.modal-body {
padding: 20px 24px;
}
.modal-body p {
font-size: 13px;
color: var(--text);
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 24px;
border-top: 1px dashed var(--border);
}
.help-text {
font-size: 11px;
color: var(--text-dim);
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.page-header {

View file

@ -248,6 +248,60 @@
</div>
</div>
<!-- Edit Member Modal -->
<div v-if="showEditModal" class="modal-overlay" @click.self="showEditModal = false">
<div class="modal">
<div class="modal-header">
<h2>Edit Member</h2>
<button class="modal-close" @click="showEditModal = false">&times;</button>
</div>
<form @submit.prevent="submitEditMember" class="modal-body">
<div class="field">
<label>Name</label>
<input v-model="editingMember.name" required />
</div>
<div class="field">
<label>Email</label>
<input v-model="editingMember.email" type="email" required />
</div>
<div class="field">
<label>Circle</label>
<select v-model="editingMember.circle">
<option value="community">Community</option>
<option value="founder">Founder</option>
<option value="practitioner">Practitioner</option>
</select>
</div>
<div class="field">
<label>Contribution Tier</label>
<select v-model="editingMember.contributionTier">
<option value="0">$0/month</option>
<option value="5">$5/month</option>
<option value="15">$15/month</option>
<option value="30">$30/month</option>
<option value="50">$50/month</option>
</select>
</div>
<div class="field">
<label>Status</label>
<select v-model="editingMember.status">
<option value="pending_payment">Pending Payment</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn" @click="showEditModal = false">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</form>
</div>
</div>
<!-- Send Invites Modal -->
<div v-if="showInviteModal" class="modal-overlay" @click.self="showInviteModal = false">
<div class="modal modal-wide">
@ -629,8 +683,55 @@ const sendSlackInvite = (member) => {
console.log('Send Slack invite to:', member.email)
}
// --- Edit Member ---
const showEditModal = ref(false)
const saving = ref(false)
const editingMemberId = ref(null)
const editingMember = reactive({
name: '',
email: '',
circle: 'community',
contributionTier: '0',
status: 'pending_payment',
})
const editMember = (member) => {
console.log('Edit member:', member._id)
editingMemberId.value = member._id
Object.assign(editingMember, {
name: member.name,
email: member.email,
circle: member.circle,
contributionTier: String(member.contributionTier),
status: member.status || 'pending_payment',
})
showEditModal.value = true
}
const submitEditMember = async () => {
saving.value = true
try {
await $fetch(`/api/admin/members/${editingMemberId.value}`, {
method: 'PUT',
body: {
name: editingMember.name,
email: editingMember.email,
circle: editingMember.circle,
contributionTier: editingMember.contributionTier,
status: editingMember.status,
},
})
showEditModal.value = false
await refresh()
toast.add({ title: 'Member updated', color: 'green' })
} catch (err) {
toast.add({
title: 'Failed to update member',
description: err.data?.statusMessage || err.message,
color: 'red',
})
} finally {
saving.value = false
}
}
</script>

View file

@ -361,6 +361,25 @@
</div>
</div>
</div>
<!-- Confirm Action Modal -->
<div v-if="confirmAction.show" class="modal-overlay" @click.self="confirmAction.show = false">
<div class="modal">
<div class="modal-header">
<h2>{{ confirmAction.heading }}</h2>
<button class="modal-close" @click="confirmAction.show = false">&times;</button>
</div>
<div class="modal-body">
<p>{{ confirmAction.message }}</p>
<p class="help-text" style="margin-top: 8px;">This action cannot be undone.</p>
</div>
<div class="modal-actions">
<button class="btn" @click="confirmAction.show = false">Cancel</button>
<button class="btn btn-danger" :disabled="confirmAction.running" @click="confirmAction.execute">
{{ confirmAction.running ? 'Working...' : confirmAction.label }}
</button>
</div>
</div>
</div>
</div>
</template>
@ -496,29 +515,47 @@ const editEvent = (event) => {
navigateTo(`/admin/events/create?edit=${event.id}`)
}
const removeFromSeries = async (event) => {
if (!confirm(`Remove "${event.title}" from its series?`)) return
const confirmAction = reactive({
show: false,
heading: '',
message: '',
label: '',
running: false,
execute: () => {},
})
try {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
body: {
...event,
series: {
isSeriesEvent: false,
id: '',
title: '',
description: '',
type: 'workshop_series',
position: 1,
totalEvents: null,
const removeFromSeries = (event) => {
confirmAction.heading = 'Remove from Series'
confirmAction.message = `Remove "${event.title}" from its series?`
confirmAction.label = 'Remove'
confirmAction.running = false
confirmAction.execute = async () => {
confirmAction.running = true
try {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
body: {
...event,
series: {
isSeriesEvent: false,
id: '',
title: '',
description: '',
type: 'workshop_series',
position: 1,
totalEvents: null,
},
},
},
})
await refresh()
} catch (error) {
console.error('Failed to remove event from series:', error)
})
confirmAction.show = false
await refresh()
} catch (error) {
console.error('Failed to remove event from series:', error)
} finally {
confirmAction.running = false
}
}
confirmAction.show = true
}
const addEventToSeries = (series) => {
@ -572,23 +609,32 @@ const saveSeriesEdit = async () => {
}
}
const deleteSeries = async (series) => {
if (!confirm(`Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`)) return
try {
for (const event of series.events) {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
body: {
...event,
series: { isSeriesEvent: false, id: '', title: '', description: '', type: 'workshop_series', position: 1, totalEvents: null },
},
})
const deleteSeries = (series) => {
confirmAction.heading = 'Delete Series'
confirmAction.message = `Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`
confirmAction.label = 'Delete'
confirmAction.running = false
confirmAction.execute = async () => {
confirmAction.running = true
try {
for (const event of series.events) {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
body: {
...event,
series: { isSeriesEvent: false, id: '', title: '', description: '', type: 'workshop_series', position: 1, totalEvents: null },
},
})
}
confirmAction.show = false
await refresh()
} catch (error) {
console.error('Failed to delete series:', error)
} finally {
confirmAction.running = false
}
await refresh()
} catch (error) {
console.error('Failed to delete series:', error)
}
confirmAction.show = true
}
const manageSeriesTickets = (series) => {

View file

@ -127,7 +127,7 @@
<div v-else-if="memberData && !canRSVP" class="dashed-box">
<div class="box-title">Registration</div>
<p class="reg-status" style="color: var(--ember);">{{ statusConfig.label }}</p>
<p class="reg-price">{{ getRSVPMessage }}</p>
<p class="reg-price">{{ getRSVPMessage() }}</p>
<NuxtLink v-if="isPendingPayment" to="#" @click.prevent="completePayment">
<button class="btn btn-primary" :disabled="isProcessingPayment">
{{ isProcessingPayment ? 'Processing...' : 'Complete Payment' }}

View file

@ -24,35 +24,51 @@
</div>
</div>
<!-- UPCOMING EVENTS + WIKI -->
<!-- UPCOMING EVENTS + WIKI (full-bleed row dividers: border on full-width row, padding on inset only) -->
<div class="content-row two-col">
<div class="content-block">
<div class="label">Upcoming Events</div>
<div class="block-inset">
<div class="label">Upcoming Events</div>
</div>
<div v-if="events?.length" class="event-list">
<div v-for="event in events" :key="event._id" class="event-item">
<span class="event-date">{{ formatDate(event.date) }}</span>
<span class="event-title">
<NuxtLink :to="`/events/${event._id}`">{{ event.title }}</NuxtLink>
</span>
<CircleBadge v-if="event.circle" :circle="event.circle" />
<div class="block-inset event-item-inner">
<span class="event-date">{{ formatDate(event.date) }}</span>
<span class="event-title">
<NuxtLink :to="`/events/${event._id}`">{{ event.title }}</NuxtLink>
</span>
<CircleBadge v-if="event.circle" :circle="event.circle" />
</div>
</div>
</div>
<p v-else class="empty">No upcoming events</p>
<div v-else class="block-inset">
<p class="empty">No upcoming events</p>
</div>
</div>
<div class="content-block">
<div class="label">Recently in the Wiki</div>
<div class="block-inset">
<div class="label">Recently in the Wiki</div>
</div>
<div class="wiki-list">
<div class="wiki-item">
<a href="/wiki">Revenue sharing models</a>
<div class="block-inset wiki-item-inner">
<a href="/wiki">Revenue sharing models</a>
</div>
</div>
<div class="wiki-item">
<a href="/wiki">What is a cooperative studio?</a>
<div class="block-inset wiki-item-inner">
<a href="/wiki">What is a cooperative studio?</a>
</div>
</div>
<div class="wiki-item">
<a href="/wiki">Governance structures</a>
<div class="block-inset wiki-item-inner">
<a href="/wiki">Governance structures</a>
</div>
</div>
<div class="wiki-item">
<a href="/wiki">Legal incorporation guide</a>
<div class="block-inset wiki-item-inner">
<a href="/wiki">Legal incorporation guide</a>
</div>
</div>
</div>
</div>
@ -164,6 +180,7 @@ const formatDate = (dateStr) => {
.content-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
border-bottom: 1px dashed var(--border);
}
.content-row.two-col {
@ -174,6 +191,14 @@ const formatDate = (dateStr) => {
border-right: 1px dashed var(--border);
min-width: 0;
overflow-wrap: break-word;
align-self: stretch;
}
.content-row.two-col .content-block {
padding: 24px 0;
}
.content-row.two-col .block-inset {
padding-left: 28px;
padding-right: 28px;
}
.content-block:last-child { border-right: none; }
.content-block h2 {
@ -218,16 +243,23 @@ details p {
/* ---- EVENT LIST ---- */
.event-item {
border-bottom: 1px dashed var(--border);
}
.event-list .event-item:last-child {
border-bottom: none;
}
.event-item-inner {
display: grid;
grid-template-columns: 80px 1fr auto;
gap: 16px;
align-items: baseline;
padding: 10px 0;
border-bottom: 1px dashed var(--border);
padding-top: 10px;
padding-bottom: 10px;
transition: padding-left 0.2s;
}
.event-item:last-child { border-bottom: none; }
.event-item:hover { padding-left: 4px; }
.content-row.two-col .event-item:hover .event-item-inner {
padding-left: calc(28px + 4px);
}
.event-date { color: var(--text-faint); font-size: 12px; }
.event-title { color: var(--text); font-size: 13px; }
.event-title a { color: var(--text); text-decoration: none; }
@ -235,11 +267,16 @@ details p {
/* ---- WIKI LIST ---- */
.wiki-item {
padding: 8px 0;
border-bottom: 1px dashed var(--border);
font-size: 13px;
}
.wiki-item:last-child { border-bottom: none; }
.wiki-list .wiki-item:last-child {
border-bottom: none;
}
.wiki-item-inner {
padding-top: 8px;
padding-bottom: 8px;
}
.wiki-item a { color: var(--text); text-decoration: none; }
.wiki-item a:hover { color: var(--candle); }

View file

@ -583,7 +583,7 @@ onUnmounted(() => {
}
:deep(.parchment-inset ul li) {
font-size: 13px;
color: #c8bea8;
color: var(--parch-text-dim);
line-height: 1.75;
padding: 4px 0;
padding-left: 16px;
@ -609,6 +609,7 @@ onUnmounted(() => {
.content-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
border-bottom: 1px dashed var(--border);
}
.content-block {
@ -616,6 +617,7 @@ onUnmounted(() => {
border-right: 1px dashed var(--border);
min-width: 0;
overflow-wrap: break-word;
align-self: stretch;
}
.content-block:last-child {
border-right: none;
@ -871,8 +873,8 @@ onUnmounted(() => {
text-align: center;
}
.form-submit:hover {
background: #3a3025;
border-color: #3a3025;
background: var(--parch-hover);
border-color: var(--parch-hover);
color: var(--candle-dim);
text-decoration: none;
}

View file

@ -1,12 +1,12 @@
<template>
<div>
<div class="member-account-page">
<!-- Unauthenticated -->
<div v-if="!memberData" class="loading">
<p>Please sign in to access your account settings.</p>
<button class="btn btn-primary" @click="openLoginModal({ title: 'Sign in to manage your account' })">Sign In</button>
</div>
<div v-else>
<div v-else class="account-authenticated">
<!-- PAGE HEADER -->
<PageHeader title="Account Settings" subtitle="Manage your membership and billing" />
@ -17,81 +17,100 @@
<!-- LEFT COLUMN: Membership Status & Email -->
<div class="account-col-left">
<div class="section-label">Current Membership</div>
<section class="account-section">
<div class="account-col-inset">
<div class="section-label">Current Membership</div>
<div class="membership-card">
<table>
<tbody>
<tr>
<td>Status</td>
<td>
<div class="membership-card">
<div class="membership-row">
<span class="membership-k">Status</span>
<span class="membership-v">
<span class="status-dot" :class="memberData.status || 'active'"></span>
{{ memberData.status || 'Active' }}
</td>
</tr>
<tr>
<td>Circle</td>
<td :style="{ color: `var(--c-${memberData.circle || 'community'})` }">
</span>
</div>
<div class="membership-row">
<span class="membership-k">Circle</span>
<span class="membership-v" :style="{ color: `var(--c-${memberData.circle || 'community'})` }">
{{ memberData.circle || 'Community' }}
</td>
</tr>
<tr>
<td>Contribution</td>
<td>${{ memberData.contributionAmount || 0 }} / month</td>
</tr>
<tr>
<td>Member since</td>
<td>{{ formatMemberSince(memberData.createdAt) }}</td>
</tr>
</tbody>
</table>
</div>
</span>
</div>
<div class="membership-row">
<span class="membership-k">Contribution</span>
<span class="membership-v">${{ memberData.contributionTier || 0 }} / month</span>
</div>
<div class="membership-row">
<span class="membership-k">Member since</span>
<span class="membership-v">{{ formatMemberSince(memberData.createdAt) }}</span>
</div>
</div>
</div>
</section>
<!-- Email -->
<hr class="section-divider">
<div class="section-label">Email</div>
<div class="email-display">
<span class="email-value">{{ memberData.email }}</span>
</div>
<div class="email-hint">Used for login magic links and notifications</div>
<section class="account-section">
<div class="account-col-inset">
<div class="section-label">Email</div>
<div class="email-display">
<span class="email-value">{{ memberData.email }}</span>
</div>
<div class="email-hint">Used for login magic links and notifications</div>
</div>
</section>
<!-- Danger Zone -->
<hr class="section-divider danger">
<div class="section-label danger">Danger Zone</div>
<div class="danger-zone">
<p>Cancelling your membership will immediately revoke access to member-only resources, events, and the Slack workspace. <strong>This action cannot be easily undone.</strong></p>
<button class="btn btn-danger" @click="handleCancelMembership" :disabled="isCancelling">
{{ isCancelling ? 'Cancelling...' : 'Cancel Membership' }}
</button>
</div>
<section class="account-section account-section--danger">
<div class="account-col-inset">
<div class="section-label danger">Danger Zone</div>
<div class="danger-zone">
<p>Cancelling your membership will immediately revoke access to member-only resources, events, and the Slack workspace. <strong>This action cannot be easily undone.</strong></p>
<div v-if="showCancelConfirm" class="cancel-confirm">
<p class="cancel-confirm-prompt">Are you sure? This cannot be easily undone.</p>
<div class="cancel-confirm-actions">
<button class="btn btn-danger" @click="confirmCancelMembership" :disabled="isCancelling">
{{ isCancelling ? 'Cancelling...' : 'Yes, Cancel' }}
</button>
<button class="btn" @click="showCancelConfirm = false">Nevermind</button>
</div>
</div>
<button v-else class="btn btn-danger" @click="handleCancelMembership" :disabled="isCancelling">
Cancel Membership
</button>
</div>
</div>
</section>
</div>
<!-- RIGHT COLUMN: Change Contribution & Circle -->
<div class="account-col-right">
<div class="section-label">Change Contribution</div>
<section class="account-section">
<div class="account-col-inset">
<div class="section-label">Change Contribution</div>
<TierPicker v-model="selectedTier" :tiers="tiers" />
<div class="tier-hint">Changes take effect on your next billing cycle</div>
<button
class="btn btn-primary btn-section"
@click="handleUpdateTier"
:disabled="selectedTier === memberData.contributionAmount || isUpdating"
>
{{ isUpdating ? 'Updating...' : 'Update Contribution' }}
</button>
<TierPicker v-model="selectedTier" :tiers="tiers" />
<div class="tier-hint">Changes take effect on your next billing cycle</div>
<button
class="btn btn-primary btn-section"
@click="handleUpdateTier"
:disabled="selectedTier === Number(memberData.contributionTier || 0) || isUpdating"
>
{{ isUpdating ? 'Updating...' : 'Update Contribution' }}
</button>
</div>
</section>
<!-- Change Circle -->
<hr class="section-divider">
<div class="section-label">Change Circle</div>
<section class="account-section">
<div class="account-col-inset">
<div class="section-label">Change Circle</div>
<CirclePicker v-model="selectedCircle" :circles="circleOptions" />
<button
class="btn btn-primary btn-section"
@click="handleUpdateCircle"
:disabled="selectedCircle === memberData.circle || isUpdating"
>
{{ isUpdating ? 'Updating...' : 'Update Circle' }}
</button>
<CirclePicker v-model="selectedCircle" :circles="circleOptions" />
<button
class="btn btn-primary btn-section"
@click="handleUpdateCircle"
:disabled="selectedCircle === memberData.circle || isUpdating"
>
{{ isUpdating ? 'Updating...' : 'Update Circle' }}
</button>
</div>
</section>
</div>
</div>
</div>
@ -134,7 +153,7 @@ const circleOptions = [
// Initialize from member data
watchEffect(() => {
if (memberData.value) {
selectedTier.value = memberData.value.contributionAmount || 0
selectedTier.value = Number(memberData.value.contributionTier || 0)
selectedCircle.value = memberData.value.circle || 'community'
}
})
@ -154,11 +173,12 @@ const handleUpdateTier = async () => {
try {
await $fetch('/api/members/update-contribution', {
method: 'POST',
body: { amount: selectedTier.value },
body: { contributionTier: String(selectedTier.value) },
})
await checkMemberStatus()
toast.add({ title: 'Contribution updated', color: 'green' })
} catch (err) {
selectedTier.value = Number(memberData.value?.contributionTier || 0)
toast.add({ title: 'Update failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
} finally {
isUpdating.value = false
@ -175,18 +195,30 @@ const handleUpdateCircle = async () => {
await checkMemberStatus()
toast.add({ title: 'Circle updated', color: 'green' })
} catch (err) {
selectedCircle.value = memberData.value?.circle || 'community'
toast.add({ title: 'Update failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
} finally {
isUpdating.value = false
}
}
const handleCancelMembership = async () => {
const showCancelConfirm = ref(false)
const handleCancelMembership = () => {
showCancelConfirm.value = true
}
const confirmCancelMembership = async () => {
showCancelConfirm.value = false
isCancelling.value = true
try {
await $fetch('/api/members/cancel', { method: 'POST' })
const result = await $fetch('/api/members/cancel-subscription', { method: 'POST' })
await checkMemberStatus()
toast.add({ title: 'Membership cancelled', color: 'orange' })
if (result.message === 'No active subscription to cancel') {
toast.add({ title: 'No active subscription', description: 'You are on the free tier — nothing to cancel.', color: 'neutral' })
} else {
toast.add({ title: 'Membership cancelled', color: 'orange' })
}
} catch (err) {
toast.add({ title: 'Cancellation failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
} finally {
@ -196,56 +228,120 @@ const handleCancelMembership = async () => {
</script>
<style scoped>
.member-account-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.loading {
flex: 1;
padding: 48px 32px;
color: var(--text-dim);
}
.account-authenticated {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- CONTENT AREA ---- */
.content-area {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.page-content {
min-width: 0;
align-self: stretch;
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
}
/* ---- TWO-COLUMN LAYOUT ---- */
.account-columns {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
align-items: stretch;
min-height: 0;
}
.account-col-left,
.account-col-right {
display: flex;
flex-direction: column;
min-height: 0;
align-self: stretch;
width: 100%;
min-width: 0;
}
.account-col-left {
padding: 24px 28px;
border-right: 1px dashed var(--border);
}
.account-col-right {
padding: 24px 28px;
/* Full-column rules: border on block-level section (no hr / flex quirks) */
.account-section {
width: 100%;
min-width: 0;
}
.account-section + .account-section {
margin-top: 20px;
border-top: 1px dashed var(--border);
padding-top: 14px;
}
.account-section + .account-section.account-section--danger {
border-top-color: var(--ember);
}
.account-col-left > .account-section:first-child .account-col-inset,
.account-col-right > .account-section:first-child .account-col-inset {
padding-top: 24px;
}
.account-col-left > .account-section:last-child .account-col-inset,
.account-col-right > .account-section:last-child .account-col-inset {
padding-bottom: 24px;
}
.account-col-left .account-col-inset {
padding-left: 28px;
padding-right: 24px;
}
.account-col-right .account-col-inset {
padding-left: 24px;
padding-right: 28px;
}
/* ---- MEMBERSHIP CARD ---- */
.membership-card {
border: 1px dashed var(--border);
padding: 16px 20px;
padding: 0;
margin-bottom: 12px;
}
.membership-card table {
width: 100%;
border-collapse: collapse;
}
.membership-card td {
padding: 4px 0;
.membership-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 0 12px;
align-items: baseline;
padding: 10px 20px;
font-size: 12px;
border-bottom: 1px dashed var(--border);
}
.membership-card tr:last-child td {
.membership-row:last-child {
border-bottom: none;
}
.membership-card td:first-child {
.membership-k {
color: var(--text-faint);
width: 120px;
}
.membership-card td:last-child {
.membership-v {
color: var(--text);
}
@ -279,9 +375,6 @@ const handleCancelMembership = async () => {
}
/* ---- DANGER ZONE ---- */
.section-divider.danger {
border-color: var(--ember);
}
.section-label.danger {
color: var(--ember);
}
@ -293,6 +386,21 @@ const handleCancelMembership = async () => {
max-width: 400px;
}
/* ---- CANCEL CONFIRM ---- */
.cancel-confirm {
border: 1px dashed var(--ember);
padding: 14px 16px;
}
.cancel-confirm-prompt {
font-size: 12px;
color: var(--ember);
margin-bottom: 10px;
}
.cancel-confirm-actions {
display: flex;
gap: 8px;
}
/* ---- TIER HINT ---- */
.tier-hint {
font-size: 11px;
@ -313,5 +421,10 @@ const handleCancelMembership = async () => {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.account-col-left .account-col-inset,
.account-col-right .account-col-inset {
padding-left: 28px;
padding-right: 28px;
}
}
</style>

View file

@ -21,6 +21,7 @@
<!-- Dashboard Content -->
<template v-else>
<div class="dashboard-body">
<!-- Member Status Banner -->
<MemberStatusBanner :dismissible="true" />
@ -149,6 +150,7 @@
</DashedBox>
</div>
</div>
</div>
</template>
<template #fallback>
@ -321,14 +323,22 @@ useHead({
<style scoped>
/* ---- DASHBOARD LAYOUT ---- */
.dashboard {
max-width: 960px;
margin: 0 auto;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
}
/* ---- LOADING / UNAUTH STATES ---- */
.loading-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 80px 24px;
padding: 48px 24px;
color: var(--text-faint);
}
@ -357,10 +367,17 @@ useHead({
}
.unauth-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 80px 24px;
padding: 48px 24px;
max-width: 400px;
width: 100%;
margin: 0 auto;
box-sizing: border-box;
}
.unauth-state h2 {
@ -404,10 +421,19 @@ useHead({
}
/* ---- CONTENT GRID ---- */
.dashboard-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
}
.content-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-bottom: 1px dashed var(--border);
align-items: stretch;
}
.content-block {
@ -415,6 +441,7 @@ useHead({
border-right: 1px dashed var(--border);
min-width: 0;
overflow-wrap: break-word;
align-self: stretch;
}
.content-block:last-child {

View file

@ -1,5 +1,5 @@
<template>
<div>
<div class="my-updates-page">
<PageHeader
title="My Updates"
subtitle="Your activity and milestones in the Guild"
@ -312,10 +312,20 @@ useHead({
</script>
<style scoped>
.my-updates-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- TWO-COLUMN LAYOUT ---- */
.content-area {
flex: 1;
display: grid;
grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
}
.content-main {

View file

@ -1,5 +1,5 @@
<template>
<div>
<div class="profile-page">
<!-- Loading State -->
<div v-if="loading" class="loading-state">
<p style="color: var(--text-faint)">Loading your profile...</p>
@ -16,7 +16,7 @@
</button>
</div>
<div v-else>
<div v-else class="profile-authenticated">
<!-- PAGE HEADER -->
<PageHeader
title="Edit Profile"
@ -25,11 +25,13 @@
<!-- TWO-COLUMN FORM -->
<form class="page-content" @submit.prevent="handleSubmit">
<div class="profile-main">
<div class="profile-columns">
<!-- ======== LEFT COLUMN ======== -->
<div class="profile-col-left">
<div class="profile-col-inset">
<div class="section-label">Basics</div>
<div class="field">
@ -73,9 +75,11 @@
</button>
</div>
</div>
</div>
<!-- About You -->
<hr class="section-divider section-divider-left" />
<hr class="section-divider" />
<div class="profile-col-inset">
<div class="section-label">About You</div>
<div class="row-2">
@ -103,9 +107,11 @@
<textarea v-model="formData.bio" rows="2" placeholder="Share your background, interests, and experience..." maxlength="300"></textarea>
<div class="char-count">{{ formData.bio?.length || 0 }} / 300</div>
</div>
</div>
<!-- Skills Exchange -->
<hr class="section-divider section-divider-left" />
<hr class="section-divider" />
<div class="profile-col-inset">
<div class="section-label">Skills Exchange</div>
<div class="field">
@ -131,9 +137,11 @@
<label>Details</label>
<textarea v-model="formData.lookingFor.text" rows="2" placeholder="e.g., Seeking a business-minded co-founder for a worker co-op studio."></textarea>
</div>
</div>
<!-- Visibility -->
<hr class="section-divider section-divider-left" />
<hr class="section-divider" />
<div class="profile-col-inset">
<div class="section-label">Visibility</div>
<div class="toggle-field">
@ -143,12 +151,14 @@
<span class="toggle-sub">Your profile will appear in the public member listing</span>
</div>
</div>
</div>
</div>
<!-- ======== RIGHT COLUMN ======== -->
<div class="profile-col-right">
<div class="profile-col-inset">
<div class="section-label">Peer Support</div>
<div class="toggle-field">
@ -205,9 +215,11 @@
<div class="char-count">{{ formData.peerSupportMessage?.length || 0 }} / 200</div>
</div>
</div>
</div>
<!-- Notifications -->
<hr class="section-divider section-divider-right" />
<hr class="section-divider" />
<div class="profile-col-inset">
<div class="section-label">Notifications</div>
<div class="toggle-field">
@ -233,6 +245,7 @@
<span class="toggle-sub">When someone wants to connect</span>
</div>
</div>
</div>
</div>
@ -247,6 +260,7 @@
<span v-if="saveSuccess" class="save-msg save-msg-ok">Profile updated.</span>
<span v-if="saveError" class="save-msg save-msg-err">{{ saveError }}</span>
</div>
</div>
</form>
</div>
</div>
@ -441,7 +455,14 @@ const handleSubmit = async () => {
// Save profile data
await $fetch('/api/members/profile', {
method: 'PATCH',
body: formData,
body: {
...formData,
notifications: {
events: formData.notifyEvents,
updates: formData.notifyUpdates,
peerRequests: formData.notifyPeerRequests,
},
},
})
// Save peer support data separately
@ -504,6 +525,20 @@ useHead({
</script>
<style scoped>
.profile-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.profile-authenticated {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- LOADING / EMPTY STATE ---- */
.loading-state {
display: flex;
@ -514,41 +549,83 @@ useHead({
text-align: center;
}
.profile-page > .loading-state {
flex: 1;
}
/* ---- CONTENT AREA ---- */
.page-content {
padding: 0 28px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0;
}
/* Grid + save bar: one flex child so the center rule can span both */
.profile-main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
}
/* Full-height vertical rule between columns (through save bar); 1fr | 1fr grid */
.profile-main::before {
display: none;
}
@media (min-width: 1025px) {
.profile-main::before {
display: block;
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 0;
border-left: 1px dashed var(--border);
pointer-events: none;
}
}
/* ---- TWO-COLUMN LAYOUT ---- */
.profile-columns {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
gap: 0;
flex: 1;
align-items: stretch;
min-height: 0;
}
.profile-col-left,
.profile-col-right {
display: flex;
flex-direction: column;
min-height: 0;
align-self: stretch;
}
.profile-col-left {
border-right: none;
}
.profile-col-left > .profile-col-inset:first-of-type,
.profile-col-right > .profile-col-inset:first-of-type {
padding-top: 14px;
}
.profile-col-left .profile-col-inset {
padding-left: 28px;
padding-right: 24px;
padding-top: 14px;
border-right: 1px dashed var(--border);
}
.profile-col-right {
.profile-col-right .profile-col-inset {
padding-left: 24px;
padding-top: 14px;
}
/* ---- SECTION DIVIDERS (full bleed) ---- */
.section-divider-left {
margin-left: -28px;
margin-right: -24px;
}
.section-divider-right {
margin-left: -24px;
margin-right: -28px;
padding-right: 28px;
}
/* ---- MULTI-COLUMN ROWS ---- */
@ -710,10 +787,9 @@ useHead({
/* ---- SAVE BAR ---- */
.save-bar {
margin-left: -28px;
margin-right: -28px;
padding: 16px 28px 24px;
margin-top: 20px;
flex-shrink: 0;
padding: 24px 28px 24px;
margin-top: 0;
border-top: 1px dashed var(--border);
display: flex;
align-items: center;
@ -737,36 +813,24 @@ useHead({
@media (max-width: 1024px) {
.profile-columns {
grid-template-columns: 1fr;
grid-template-rows: auto;
}
.profile-col-left {
padding-right: 0;
border-right: none;
border-bottom: 1px dashed var(--border);
padding-bottom: 20px;
margin-bottom: 20px;
margin-left: -28px;
margin-right: -28px;
}
.profile-col-left .profile-col-inset,
.profile-col-right .profile-col-inset {
padding-left: 28px;
padding-right: 28px;
}
.profile-col-right {
padding-left: 0;
}
.section-divider-left,
.section-divider-right {
margin-left: -28px;
margin-right: -28px;
}
}
@media (max-width: 768px) {
.page-content {
padding: 0 16px;
}
.row-2 {
grid-template-columns: 1fr;
}
@ -775,22 +839,13 @@ useHead({
grid-template-columns: 1fr;
}
.profile-col-left {
margin-left: -16px;
margin-right: -16px;
.profile-col-left .profile-col-inset,
.profile-col-right .profile-col-inset {
padding-left: 16px;
padding-right: 16px;
}
.section-divider-left,
.section-divider-right {
margin-left: -16px;
margin-right: -16px;
}
.save-bar {
margin-left: -16px;
margin-right: -16px;
padding-left: 16px;
padding-right: 16px;
flex-wrap: wrap;

View file

@ -50,12 +50,15 @@
</div>
<!-- PASS PURCHASE -->
<div v-if="series.passPrice" class="section">
<DashedBox>
<div class="section-label">Series Pass</div>
<p>Get access to all sessions in this series with a single pass.</p>
<div class="pass-price">${{ series.passPrice }}</div>
</DashedBox>
<div v-if="series.tickets?.enabled" class="section">
<SeriesPassPurchase
:series-id="series.id"
:series-info="{ id: series.id, title: series.title, totalEvents: series.totalEvents || series.events?.length || 0, type: series.type }"
:series-events="series.events || []"
:user-email="memberData?.email"
:user-name="memberData?.name"
@purchase-success="handlePurchaseSuccess"
/>
</div>
<!-- QUESTIONS -->
@ -70,6 +73,7 @@
<script setup>
const route = useRoute()
const { memberData } = useAuth()
const { data: series, pending, error } = await useFetch(`/api/series/${route.params.id}`)
@ -96,6 +100,10 @@ const getEventStatus = (event) => {
return 'Completed'
}
const handlePurchaseSuccess = () => {
refreshNuxtData()
}
useHead(() => ({
title: series.value ? `${series.value.title} - Event Series - Ghost Guild` : 'Event Series - Ghost Guild',
meta: [{ name: 'description', content: series.value?.description || 'Multi-event series' }],
@ -152,13 +160,5 @@ useHead(() => ({
.event-title-link:hover { color: var(--candle); }
.event-status { font-size: 10px; color: var(--text-faint); margin-left: 8px; }
.pass-price {
font-family: 'Brygada 1918', serif;
font-size: 22px;
font-weight: 600;
color: var(--candle);
margin-top: 8px;
}
.empty { font-size: 12px; color: var(--text-faint); }
</style>

View file

@ -0,0 +1,157 @@
<template>
<div>
<div class="back-link">
<NuxtLink to="/member/my-updates">&larr; Back to My Updates</NuxtLink>
</div>
<div v-if="pending" class="loading">Loading update...</div>
<template v-else-if="update">
<div class="form-header">
<h1>Edit Update</h1>
</div>
<form @submit.prevent="handleSubmit" class="update-form">
<div class="field">
<label class="section-label">Content</label>
<textarea
v-model="form.content"
rows="8"
required
:disabled="saving"
></textarea>
<div class="char-count">{{ form.content.length }} / 50000</div>
</div>
<div class="field">
<label class="section-label">Visibility</label>
<select v-model="form.privacy" :disabled="saving">
<option value="members">Members only</option>
<option value="public">Public</option>
<option value="private">Private (only you)</option>
</select>
</div>
<div class="form-actions">
<NuxtLink to="/member/my-updates" class="btn">Cancel</NuxtLink>
<button
type="submit"
class="btn btn-primary"
:disabled="saving || !form.content.trim()"
>
{{ saving ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</form>
</template>
<div v-else class="loading">Update not found.</div>
</div>
</template>
<script setup>
definePageMeta({ middleware: 'auth' })
const route = useRoute()
const toast = useToast()
const { data: update, pending } = await useFetch(`/api/updates/${route.params.id}`)
const form = ref({
content: update.value?.content || '',
privacy: update.value?.privacy || 'members',
})
watch(update, (val) => {
if (val) {
form.value.content = val.content || ''
form.value.privacy = val.privacy || 'members'
}
})
const saving = ref(false)
const handleSubmit = async () => {
saving.value = true
try {
await $fetch(`/api/updates/${route.params.id}`, {
method: 'PATCH',
body: { content: form.value.content, privacy: form.value.privacy },
})
toast.add({ title: 'Update saved!', color: 'green' })
navigateTo('/member/my-updates')
} catch (err) {
toast.add({
title: 'Failed to save update',
description: err.data?.statusMessage || 'Please try again.',
color: 'red',
})
} finally {
saving.value = false
}
}
useHead({ title: 'Edit Update - Ghost Guild' })
</script>
<style scoped>
.back-link {
padding: 12px 32px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.back-link a { color: var(--candle); text-decoration: none; }
.loading {
padding: 48px 32px;
font-size: 12px;
color: var(--text-dim);
}
.form-header {
padding: 28px 32px 20px;
border-bottom: 1px dashed var(--border);
}
.form-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 24px;
font-weight: 600;
color: var(--text-bright);
}
.update-form {
padding: 24px 32px;
max-width: 640px;
display: flex;
flex-direction: column;
gap: 20px;
}
.field { display: flex; flex-direction: column; gap: 6px; }
textarea, select {
font-family: 'Commit Mono', monospace;
font-size: 13px;
background: var(--surface);
border: 1px dashed var(--border);
color: var(--text);
padding: 10px 12px;
resize: vertical;
width: 100%;
}
textarea:focus, select:focus { outline: none; border-color: var(--candle); }
textarea:disabled, select:disabled { opacity: 0.6; }
.char-count {
font-size: 11px;
color: var(--text-faint);
text-align: right;
}
.form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 4px;
}
</style>

139
app/pages/updates/new.vue Normal file
View file

@ -0,0 +1,139 @@
<template>
<div>
<div class="back-link">
<NuxtLink to="/member/my-updates">&larr; Back to My Updates</NuxtLink>
</div>
<div class="form-header">
<h1>New Update</h1>
<p>Share what you're working on with the community</p>
</div>
<form @submit.prevent="handleSubmit" class="update-form">
<div class="field">
<label class="section-label">Content</label>
<textarea
v-model="form.content"
rows="8"
required
placeholder="What's on your mind? Share a project update, milestone, or thought..."
:disabled="saving"
></textarea>
<div class="char-count">{{ form.content.length }} / 50000</div>
</div>
<div class="field">
<label class="section-label">Visibility</label>
<select v-model="form.privacy" :disabled="saving">
<option value="members">Members only</option>
<option value="public">Public</option>
<option value="private">Private (only you)</option>
</select>
</div>
<div class="form-actions">
<NuxtLink to="/member/my-updates" class="btn">Cancel</NuxtLink>
<button
type="submit"
class="btn btn-primary"
:disabled="saving || !form.content.trim()"
>
{{ saving ? 'Posting...' : 'Post Update' }}
</button>
</div>
</form>
</div>
</template>
<script setup>
definePageMeta({ middleware: 'auth' })
const toast = useToast()
const form = ref({
content: '',
privacy: 'members',
})
const saving = ref(false)
const handleSubmit = async () => {
saving.value = true
try {
await $fetch('/api/updates', {
method: 'POST',
body: { content: form.value.content, privacy: form.value.privacy },
})
toast.add({ title: 'Update posted!', color: 'green' })
navigateTo('/member/my-updates')
} catch (err) {
toast.add({
title: 'Failed to post update',
description: err.data?.statusMessage || 'Please try again.',
color: 'red',
})
} finally {
saving.value = false
}
}
useHead({ title: 'New Update - Ghost Guild' })
</script>
<style scoped>
.back-link {
padding: 12px 32px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.back-link a { color: var(--candle); text-decoration: none; }
.form-header {
padding: 28px 32px 0;
border-bottom: 1px dashed var(--border);
padding-bottom: 20px;
}
.form-header h1 {
font-family: 'Brygada 1918', serif;
font-size: 24px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 4px;
}
.form-header p { font-size: 12px; color: var(--text-dim); }
.update-form {
padding: 24px 32px;
max-width: 640px;
display: flex;
flex-direction: column;
gap: 20px;
}
.field { display: flex; flex-direction: column; gap: 6px; }
textarea, select {
font-family: 'Commit Mono', monospace;
font-size: 13px;
background: var(--surface);
border: 1px dashed var(--border);
color: var(--text);
padding: 10px 12px;
resize: vertical;
width: 100%;
}
textarea:focus, select:focus { outline: none; border-color: var(--candle); }
textarea:disabled, select:disabled { opacity: 0.6; }
.char-count {
font-size: 11px;
color: var(--text-faint);
text-align: right;
}
.form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 4px;
}
</style>

80
app/pages/verify.vue Normal file
View file

@ -0,0 +1,80 @@
<template>
<div class="verify-page">
<div class="verify-box">
<div v-if="state === 'verifying'" class="verify-status">
<div class="section-label">Ghost Guild</div>
<p>Verifying your login link&hellip;</p>
</div>
<div v-else-if="state === 'error'" class="verify-status">
<div class="section-label">Login Failed</div>
<p class="error-msg">{{ errorMessage }}</p>
<NuxtLink to="/" class="btn btn-primary">Back to home</NuxtLink>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({ layout: false })
const state = ref('verifying')
const errorMessage = ref('')
onMounted(async () => {
const hash = window.location.hash.slice(1)
if (!hash) {
state.value = 'error'
errorMessage.value = 'No login token found. Please request a new login link.'
return
}
try {
const data = await $fetch('/api/auth/verify', {
method: 'POST',
body: { token: hash },
})
await navigateTo(data.redirectUrl, { replace: true })
} catch (err) {
state.value = 'error'
const status = err?.response?.status
if (status === 401) {
errorMessage.value = 'This login link is invalid or has expired. Please request a new one.'
} else if (status === 403) {
errorMessage.value = err?.data?.statusMessage || 'Your account is not active. Please contact support.'
} else {
errorMessage.value = 'Something went wrong. Please try again or request a new login link.'
}
}
})
</script>
<style scoped>
.verify-page {
min-height: 100vh;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.verify-box {
border: 1px dashed var(--border);
padding: 2rem 2.5rem;
max-width: 420px;
width: 100%;
}
.verify-status {
display: flex;
flex-direction: column;
gap: 1rem;
}
.error-msg {
color: var(--ember);
}
</style>