fix: use private helcimApiToken for all server-side Helcim API calls
This commit is contained in:
parent
ccd1d0783a
commit
d31b5b4dac
53 changed files with 1755 additions and 572 deletions
|
|
@ -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 ---- */
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
|
|
|
|||
|
|
@ -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 →</NuxtLink>
|
||||
</div>
|
||||
<div v-if="!events?.length" class="em-empty">No upcoming events</div>
|
||||
<NuxtLink to="/events" class="em-link">All events →</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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ---- */
|
||||
|
|
|
|||
|
|
@ -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">×</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 {
|
||||
|
|
|
|||
|
|
@ -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">×</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">×</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) => {
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
157
app/pages/updates/[id]/edit.vue
Normal file
157
app/pages/updates/[id]/edit.vue
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="back-link">
|
||||
<NuxtLink to="/member/my-updates">← 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
139
app/pages/updates/new.vue
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="back-link">
|
||||
<NuxtLink to="/member/my-updates">← 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
80
app/pages/verify.vue
Normal 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…</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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue