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

2
.serena/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/cache
/project.local.yml

152
.serena/project.yml Normal file
View file

@ -0,0 +1,152 @@
# the name by which the project can be referenced within Serena
project_name: "ghostguild-org"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- vue
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []

View file

@ -71,7 +71,10 @@ Copy `.env.example` to `.env`. Required: `MONGODB_URI`, `JWT_SECRET`, `RESEND_AP
- Use `USwitch` (not `UToggle`) — this is the correct Nuxt UI 3+ component name - Use `USwitch` (not `UToggle`) — this is the correct Nuxt UI 3+ component name
- No fallback/placeholder data — always use real data - No fallback/placeholder data — always use real data
- Follow Nuxt 4 file-based routing conventions for route naming - Follow Nuxt 4 file-based routing conventions for route naming
- Always check Nuxt UI 3 latest documentation on the web when implementing UI components - Always check Nuxt UI 4 latest documentation on the web when implementing UI components
- Auth API responses (`/api/auth/status`, `/api/auth/member`) must include `status` in the returned member object — `useMemberStatus` defaults to `PENDING_PAYMENT` if missing
- Helcim payment testing requires ngrok: `npx nuxi dev --https` then `ngrok http https://localhost:3000` — Helcim blocks localhost origins
- The `/api/helcim/initialize-payment` endpoint skips auth for `event_ticket` type payments (public users can buy tickets)
## Product Spec ## Product Spec
@ -90,8 +93,3 @@ The sections below describe planned and in-progress features for reference.
### Resources (Planned) ### Resources (Planned)
- Learning paths by circle, templates and tools, case studies - Learning paths by circle, templates and tools, case studies
- Tag by circle relevance, download tracking, version control - Tag by circle relevance, download tracking, version control
### Implementation Priority
**Must have:** Payment processing, Slack automation, member dashboard, resource library, event listing/RSVP
**Nice to have:** Member profiles, peer matching, Cal.com, member updates
**Post-launch:** Etherpad integration, member-proposed events, advanced search, analytics dashboard

View file

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

View file

@ -45,7 +45,7 @@
<!-- Early Bird Badge --> <!-- Early Bird Badge -->
<span <span
v-if="ticketInfo.isEarlyBird" 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 Early Bird
</span> </span>
@ -64,7 +64,7 @@
<!-- Early Bird Countdown --> <!-- Early Bird Countdown -->
<div <div
v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline" 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" /> <Icon name="heroicons:clock" class="w-4 h-4 inline mr-1" />
Early bird ends {{ formatDeadline(ticketInfo.earlyBirdDeadline) }} Early bird ends {{ formatDeadline(ticketInfo.earlyBirdDeadline) }}

View file

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

View file

@ -21,12 +21,22 @@
<!-- Content --> <!-- Content -->
<div v-else-if="passInfo"> <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 <EventSeriesTicketCard
v-else-if="passInfo.ticket"
:ticket="passInfo.ticket" :ticket="passInfo.ticket"
:availability="passInfo.availability" :availability="passInfo.availability"
:available="passInfo.available" :available="passInfo.available"
:already-registered="passInfo.alreadyRegistered" :already-registered="false"
:is-member="passInfo.memberInfo?.isMember" :is-member="passInfo.memberInfo?.isMember"
:total-events="seriesInfo.totalEvents" :total-events="seriesInfo.totalEvents"
:events="seriesEvents" :events="seriesEvents"
@ -172,7 +182,7 @@ const props = defineProps({
const emit = defineEmits(["purchase-success", "purchase-error"]); const emit = defineEmits(["purchase-success", "purchase-error"]);
const toast = useToast(); const toast = useToast();
const { initializePayment, verifyPayment } = useHelcimPay(); const { initializeTicketPayment, verifyPayment } = useHelcimPay();
// State // State
const loading = ref(true); const loading = ref(true);
@ -188,19 +198,29 @@ const form = ref({
const isLoggedIn = computed(() => !!props.userEmail); 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 () => { onMounted(async () => {
await fetchPassInfo(); 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 () => { const fetchPassInfo = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await $fetch( const email = form.value.email || props.userEmail;
`/api/series/${props.seriesId}/tickets/available` 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; passInfo.value = response;
@ -244,15 +264,11 @@ const handleSubmit = async () => {
paymentProcessing.value = true; paymentProcessing.value = true;
// Initialize Helcim payment for series pass // Initialize Helcim payment for series pass
await initializePayment( await initializeTicketPayment(
props.seriesId,
form.value.email, form.value.email,
passInfo.value.ticket.price, passInfo.value.ticket.price,
passInfo.value.ticket.currency || "CAD", props.seriesInfo.title,
{
type: "series_pass",
seriesId: props.seriesId,
seriesTitle: props.seriesInfo.title,
}
); );
// Show Helcim modal and complete payment // Show Helcim modal and complete payment
@ -267,15 +283,17 @@ const handleSubmit = async () => {
} }
// Complete series pass purchase // Complete series pass purchase
const purchaseBody = {
name: form.value.name,
email: form.value.email,
};
if (transactionId) purchaseBody.paymentId = transactionId;
const purchaseResponse = await $fetch( const purchaseResponse = await $fetch(
`/api/series/${props.seriesId}/tickets/purchase`, `/api/series/${props.seriesId}/tickets/purchase`,
{ {
method: "POST", method: "POST",
body: { body: purchaseBody,
name: form.value.name,
email: form.value.email,
paymentId: transactionId,
},
} }
); );

View file

@ -239,12 +239,12 @@ export const useHelcimPay = () => {
// Clean up observer after a timeout // Clean up observer after a timeout
setTimeout(() => observer.disconnect(), 5000); 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(() => { setTimeout(() => {
console.log("60 seconds passed, cleaning up event listener..."); console.log("Payment timeout reached, cleaning up event listener...");
window.removeEventListener("message", handleHelcimPayEvent); window.removeEventListener("message", handleHelcimPayEvent);
reject(new Error("Payment timeout - no response received")); reject(new Error("Payment timeout - no response received"));
}, 60000); }, 600000);
} else { } else {
reject(new Error("appendHelcimPayIframe function not available")); reject(new Error("appendHelcimPayIframe function not available"));
} }

View file

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

View file

@ -162,6 +162,25 @@
No events found matching your criteria No events found matching your criteria
</div> </div>
</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> </div>
</template> </template>
@ -255,16 +274,25 @@ const duplicateEvent = (event) => {
navigateTo('/admin/events/create?duplicate=true') navigateTo('/admin/events/create?duplicate=true')
} }
const deleteEvent = async (event) => { const confirmDelete = reactive({ show: false, id: null, title: '', deleting: false })
if (confirm(`Are you sure you want to delete "${event.title}"?`)) {
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 { try {
await $fetch(`/api/admin/events/${String(event._id)}`, { await $fetch(`/api/admin/events/${confirmDelete.id}`, { method: 'DELETE' })
method: 'DELETE', confirmDelete.show = false
})
await refresh() await refresh()
} catch (error) { } catch (error) {
console.error('Failed to delete event:', error) console.error('Failed to delete event:', error)
} } finally {
confirmDelete.deleting = false
} }
} }
@ -588,6 +616,77 @@ tbody td {
to { transform: rotate(360deg); } 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 ---- */ /* ---- RESPONSIVE ---- */
@media (max-width: 768px) { @media (max-width: 768px) {
.page-header { .page-header {

View file

@ -248,6 +248,60 @@
</div> </div>
</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 --> <!-- Send Invites Modal -->
<div v-if="showInviteModal" class="modal-overlay" @click.self="showInviteModal = false"> <div v-if="showInviteModal" class="modal-overlay" @click.self="showInviteModal = false">
<div class="modal modal-wide"> <div class="modal modal-wide">
@ -629,8 +683,55 @@ const sendSlackInvite = (member) => {
console.log('Send Slack invite to:', member.email) 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) => { 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> </script>

View file

@ -361,6 +361,25 @@
</div> </div>
</div> </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> </div>
</template> </template>
@ -496,9 +515,22 @@ const editEvent = (event) => {
navigateTo(`/admin/events/create?edit=${event.id}`) navigateTo(`/admin/events/create?edit=${event.id}`)
} }
const removeFromSeries = async (event) => { const confirmAction = reactive({
if (!confirm(`Remove "${event.title}" from its series?`)) return show: false,
heading: '',
message: '',
label: '',
running: false,
execute: () => {},
})
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 { try {
await $fetch(`/api/admin/events/${event.id}`, { await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT', method: 'PUT',
@ -515,10 +547,15 @@ const removeFromSeries = async (event) => {
}, },
}, },
}) })
confirmAction.show = false
await refresh() await refresh()
} catch (error) { } catch (error) {
console.error('Failed to remove event from series:', error) console.error('Failed to remove event from series:', error)
} finally {
confirmAction.running = false
} }
}
confirmAction.show = true
} }
const addEventToSeries = (series) => { const addEventToSeries = (series) => {
@ -572,9 +609,13 @@ const saveSeriesEdit = async () => {
} }
} }
const deleteSeries = async (series) => { const deleteSeries = (series) => {
if (!confirm(`Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`)) return 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 { try {
for (const event of series.events) { for (const event of series.events) {
await $fetch(`/api/admin/events/${event.id}`, { await $fetch(`/api/admin/events/${event.id}`, {
@ -585,10 +626,15 @@ const deleteSeries = async (series) => {
}, },
}) })
} }
confirmAction.show = false
await refresh() await refresh()
} catch (error) { } catch (error) {
console.error('Failed to delete series:', error) console.error('Failed to delete series:', error)
} finally {
confirmAction.running = false
} }
}
confirmAction.show = true
} }
const manageSeriesTickets = (series) => { const manageSeriesTickets = (series) => {

View file

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

View file

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

View file

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

View file

@ -1,12 +1,12 @@
<template> <template>
<div> <div class="member-account-page">
<!-- Unauthenticated --> <!-- Unauthenticated -->
<div v-if="!memberData" class="loading"> <div v-if="!memberData" class="loading">
<p>Please sign in to access your account settings.</p> <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> <button class="btn btn-primary" @click="openLoginModal({ title: 'Sign in to manage your account' })">Sign In</button>
</div> </div>
<div v-else> <div v-else class="account-authenticated">
<!-- PAGE HEADER --> <!-- PAGE HEADER -->
<PageHeader title="Account Settings" subtitle="Manage your membership and billing" /> <PageHeader title="Account Settings" subtitle="Manage your membership and billing" />
@ -17,57 +17,72 @@
<!-- LEFT COLUMN: Membership Status & Email --> <!-- LEFT COLUMN: Membership Status & Email -->
<div class="account-col-left"> <div class="account-col-left">
<section class="account-section">
<div class="account-col-inset">
<div class="section-label">Current Membership</div> <div class="section-label">Current Membership</div>
<div class="membership-card"> <div class="membership-card">
<table> <div class="membership-row">
<tbody> <span class="membership-k">Status</span>
<tr> <span class="membership-v">
<td>Status</td>
<td>
<span class="status-dot" :class="memberData.status || 'active'"></span> <span class="status-dot" :class="memberData.status || 'active'"></span>
{{ memberData.status || 'Active' }} {{ memberData.status || 'Active' }}
</td> </span>
</tr>
<tr>
<td>Circle</td>
<td :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> </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' }}
</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 --> <section class="account-section">
<hr class="section-divider"> <div class="account-col-inset">
<div class="section-label">Email</div> <div class="section-label">Email</div>
<div class="email-display"> <div class="email-display">
<span class="email-value">{{ memberData.email }}</span> <span class="email-value">{{ memberData.email }}</span>
</div> </div>
<div class="email-hint">Used for login magic links and notifications</div> <div class="email-hint">Used for login magic links and notifications</div>
</div>
</section>
<!-- Danger Zone --> <section class="account-section account-section--danger">
<hr class="section-divider danger"> <div class="account-col-inset">
<div class="section-label danger">Danger Zone</div> <div class="section-label danger">Danger Zone</div>
<div class="danger-zone"> <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> <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"> <div v-if="showCancelConfirm" class="cancel-confirm">
{{ isCancelling ? 'Cancelling...' : 'Cancel Membership' }} <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> </button>
</div> </div>
</div> </div>
</section>
</div>
<!-- RIGHT COLUMN: Change Contribution & Circle --> <!-- RIGHT COLUMN: Change Contribution & Circle -->
<div class="account-col-right"> <div class="account-col-right">
<section class="account-section">
<div class="account-col-inset">
<div class="section-label">Change Contribution</div> <div class="section-label">Change Contribution</div>
<TierPicker v-model="selectedTier" :tiers="tiers" /> <TierPicker v-model="selectedTier" :tiers="tiers" />
@ -75,13 +90,15 @@
<button <button
class="btn btn-primary btn-section" class="btn btn-primary btn-section"
@click="handleUpdateTier" @click="handleUpdateTier"
:disabled="selectedTier === memberData.contributionAmount || isUpdating" :disabled="selectedTier === Number(memberData.contributionTier || 0) || isUpdating"
> >
{{ isUpdating ? 'Updating...' : 'Update Contribution' }} {{ isUpdating ? 'Updating...' : 'Update Contribution' }}
</button> </button>
</div>
</section>
<!-- Change Circle --> <section class="account-section">
<hr class="section-divider"> <div class="account-col-inset">
<div class="section-label">Change Circle</div> <div class="section-label">Change Circle</div>
<CirclePicker v-model="selectedCircle" :circles="circleOptions" /> <CirclePicker v-model="selectedCircle" :circles="circleOptions" />
@ -93,6 +110,8 @@
{{ isUpdating ? 'Updating...' : 'Update Circle' }} {{ isUpdating ? 'Updating...' : 'Update Circle' }}
</button> </button>
</div> </div>
</section>
</div>
</div> </div>
</div> </div>
@ -134,7 +153,7 @@ const circleOptions = [
// Initialize from member data // Initialize from member data
watchEffect(() => { watchEffect(() => {
if (memberData.value) { if (memberData.value) {
selectedTier.value = memberData.value.contributionAmount || 0 selectedTier.value = Number(memberData.value.contributionTier || 0)
selectedCircle.value = memberData.value.circle || 'community' selectedCircle.value = memberData.value.circle || 'community'
} }
}) })
@ -154,11 +173,12 @@ const handleUpdateTier = async () => {
try { try {
await $fetch('/api/members/update-contribution', { await $fetch('/api/members/update-contribution', {
method: 'POST', method: 'POST',
body: { amount: selectedTier.value }, body: { contributionTier: String(selectedTier.value) },
}) })
await checkMemberStatus() await checkMemberStatus()
toast.add({ title: 'Contribution updated', color: 'green' }) toast.add({ title: 'Contribution updated', color: 'green' })
} catch (err) { } catch (err) {
selectedTier.value = Number(memberData.value?.contributionTier || 0)
toast.add({ title: 'Update failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' }) toast.add({ title: 'Update failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
} finally { } finally {
isUpdating.value = false isUpdating.value = false
@ -175,18 +195,30 @@ const handleUpdateCircle = async () => {
await checkMemberStatus() await checkMemberStatus()
toast.add({ title: 'Circle updated', color: 'green' }) toast.add({ title: 'Circle updated', color: 'green' })
} catch (err) { } catch (err) {
selectedCircle.value = memberData.value?.circle || 'community'
toast.add({ title: 'Update failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' }) toast.add({ title: 'Update failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
} finally { } finally {
isUpdating.value = false isUpdating.value = false
} }
} }
const handleCancelMembership = async () => { const showCancelConfirm = ref(false)
const handleCancelMembership = () => {
showCancelConfirm.value = true
}
const confirmCancelMembership = async () => {
showCancelConfirm.value = false
isCancelling.value = true isCancelling.value = true
try { try {
await $fetch('/api/members/cancel', { method: 'POST' }) const result = await $fetch('/api/members/cancel-subscription', { method: 'POST' })
await checkMemberStatus() await checkMemberStatus()
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' }) toast.add({ title: 'Membership cancelled', color: 'orange' })
}
} catch (err) { } catch (err) {
toast.add({ title: 'Cancellation failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' }) toast.add({ title: 'Cancellation failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
} finally { } finally {
@ -196,56 +228,120 @@ const handleCancelMembership = async () => {
</script> </script>
<style scoped> <style scoped>
.member-account-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.loading { .loading {
flex: 1;
padding: 48px 32px; padding: 48px 32px;
color: var(--text-dim); color: var(--text-dim);
} }
.account-authenticated {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- CONTENT AREA ---- */ /* ---- CONTENT AREA ---- */
.content-area { .content-area {
flex: 1;
display: grid; display: grid;
grid-template-columns: 1fr 200px; grid-template-columns: 1fr 200px;
align-items: stretch;
min-height: 0;
} }
.page-content { .page-content {
min-width: 0; min-width: 0;
align-self: stretch;
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
} }
/* ---- TWO-COLUMN LAYOUT ---- */ /* ---- TWO-COLUMN LAYOUT ---- */
.account-columns { .account-columns {
flex: 1;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; 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 { .account-col-left {
padding: 24px 28px;
border-right: 1px dashed var(--border); 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 ---- */
.membership-card { .membership-card {
border: 1px dashed var(--border); border: 1px dashed var(--border);
padding: 16px 20px; padding: 0;
margin-bottom: 12px; margin-bottom: 12px;
} }
.membership-card table { .membership-row {
width: 100%; display: grid;
border-collapse: collapse; grid-template-columns: 120px 1fr;
} gap: 0 12px;
.membership-card td { align-items: baseline;
padding: 4px 0; padding: 10px 20px;
font-size: 12px; font-size: 12px;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
} }
.membership-card tr:last-child td { .membership-row:last-child {
border-bottom: none; border-bottom: none;
} }
.membership-card td:first-child { .membership-k {
color: var(--text-faint); color: var(--text-faint);
width: 120px;
} }
.membership-card td:last-child { .membership-v {
color: var(--text); color: var(--text);
} }
@ -279,9 +375,6 @@ const handleCancelMembership = async () => {
} }
/* ---- DANGER ZONE ---- */ /* ---- DANGER ZONE ---- */
.section-divider.danger {
border-color: var(--ember);
}
.section-label.danger { .section-label.danger {
color: var(--ember); color: var(--ember);
} }
@ -293,6 +386,21 @@ const handleCancelMembership = async () => {
max-width: 400px; 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 ---- */
.tier-hint { .tier-hint {
font-size: 11px; font-size: 11px;
@ -313,5 +421,10 @@ const handleCancelMembership = async () => {
border-right: none; border-right: none;
border-bottom: 1px dashed var(--border); 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> </style>

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<template> <template>
<div> <div class="profile-page">
<!-- Loading State --> <!-- Loading State -->
<div v-if="loading" class="loading-state"> <div v-if="loading" class="loading-state">
<p style="color: var(--text-faint)">Loading your profile...</p> <p style="color: var(--text-faint)">Loading your profile...</p>
@ -16,7 +16,7 @@
</button> </button>
</div> </div>
<div v-else> <div v-else class="profile-authenticated">
<!-- PAGE HEADER --> <!-- PAGE HEADER -->
<PageHeader <PageHeader
title="Edit Profile" title="Edit Profile"
@ -25,11 +25,13 @@
<!-- TWO-COLUMN FORM --> <!-- TWO-COLUMN FORM -->
<form class="page-content" @submit.prevent="handleSubmit"> <form class="page-content" @submit.prevent="handleSubmit">
<div class="profile-main">
<div class="profile-columns"> <div class="profile-columns">
<!-- ======== LEFT COLUMN ======== --> <!-- ======== LEFT COLUMN ======== -->
<div class="profile-col-left"> <div class="profile-col-left">
<div class="profile-col-inset">
<div class="section-label">Basics</div> <div class="section-label">Basics</div>
<div class="field"> <div class="field">
@ -73,9 +75,11 @@
</button> </button>
</div> </div>
</div> </div>
</div>
<!-- About You --> <!-- 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="section-label">About You</div>
<div class="row-2"> <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> <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 class="char-count">{{ formData.bio?.length || 0 }} / 300</div>
</div> </div>
</div>
<!-- Skills Exchange --> <!-- 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="section-label">Skills Exchange</div>
<div class="field"> <div class="field">
@ -131,9 +137,11 @@
<label>Details</label> <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> <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>
</div>
<!-- Visibility --> <!-- 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="section-label">Visibility</div>
<div class="toggle-field"> <div class="toggle-field">
@ -143,12 +151,14 @@
<span class="toggle-sub">Your profile will appear in the public member listing</span> <span class="toggle-sub">Your profile will appear in the public member listing</span>
</div> </div>
</div> </div>
</div>
</div> </div>
<!-- ======== RIGHT COLUMN ======== --> <!-- ======== RIGHT COLUMN ======== -->
<div class="profile-col-right"> <div class="profile-col-right">
<div class="profile-col-inset">
<div class="section-label">Peer Support</div> <div class="section-label">Peer Support</div>
<div class="toggle-field"> <div class="toggle-field">
@ -205,9 +215,11 @@
<div class="char-count">{{ formData.peerSupportMessage?.length || 0 }} / 200</div> <div class="char-count">{{ formData.peerSupportMessage?.length || 0 }} / 200</div>
</div> </div>
</div> </div>
</div>
<!-- Notifications --> <!-- 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="section-label">Notifications</div>
<div class="toggle-field"> <div class="toggle-field">
@ -233,6 +245,7 @@
<span class="toggle-sub">When someone wants to connect</span> <span class="toggle-sub">When someone wants to connect</span>
</div> </div>
</div> </div>
</div>
</div> </div>
@ -247,6 +260,7 @@
<span v-if="saveSuccess" class="save-msg save-msg-ok">Profile updated.</span> <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> <span v-if="saveError" class="save-msg save-msg-err">{{ saveError }}</span>
</div> </div>
</div>
</form> </form>
</div> </div>
</div> </div>
@ -441,7 +455,14 @@ const handleSubmit = async () => {
// Save profile data // Save profile data
await $fetch('/api/members/profile', { await $fetch('/api/members/profile', {
method: 'PATCH', method: 'PATCH',
body: formData, body: {
...formData,
notifications: {
events: formData.notifyEvents,
updates: formData.notifyUpdates,
peerRequests: formData.notifyPeerRequests,
},
},
}) })
// Save peer support data separately // Save peer support data separately
@ -504,6 +525,20 @@ useHead({
</script> </script>
<style scoped> <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 / EMPTY STATE ---- */
.loading-state { .loading-state {
display: flex; display: flex;
@ -514,41 +549,83 @@ useHead({
text-align: center; text-align: center;
} }
.profile-page > .loading-state {
flex: 1;
}
/* ---- CONTENT AREA ---- */ /* ---- CONTENT AREA ---- */
.page-content { .page-content {
padding: 0 28px; flex: 1;
display: flex; display: flex;
flex-direction: column; 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 ---- */ /* ---- TWO-COLUMN LAYOUT ---- */
.profile-columns { .profile-columns {
flex: 1;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
gap: 0; 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 { .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-right: 24px;
padding-top: 14px;
border-right: 1px dashed var(--border);
} }
.profile-col-right { .profile-col-right .profile-col-inset {
padding-left: 24px; padding-left: 24px;
padding-top: 14px; padding-right: 28px;
}
/* ---- SECTION DIVIDERS (full bleed) ---- */
.section-divider-left {
margin-left: -28px;
margin-right: -24px;
}
.section-divider-right {
margin-left: -24px;
margin-right: -28px;
} }
/* ---- MULTI-COLUMN ROWS ---- */ /* ---- MULTI-COLUMN ROWS ---- */
@ -710,10 +787,9 @@ useHead({
/* ---- SAVE BAR ---- */ /* ---- SAVE BAR ---- */
.save-bar { .save-bar {
margin-left: -28px; flex-shrink: 0;
margin-right: -28px; padding: 24px 28px 24px;
padding: 16px 28px 24px; margin-top: 0;
margin-top: 20px;
border-top: 1px dashed var(--border); border-top: 1px dashed var(--border);
display: flex; display: flex;
align-items: center; align-items: center;
@ -737,36 +813,24 @@ useHead({
@media (max-width: 1024px) { @media (max-width: 1024px) {
.profile-columns { .profile-columns {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto;
} }
.profile-col-left { .profile-col-left {
padding-right: 0;
border-right: none; border-right: none;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
padding-bottom: 20px; padding-bottom: 20px;
margin-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-left: 28px;
padding-right: 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) { @media (max-width: 768px) {
.page-content {
padding: 0 16px;
}
.row-2 { .row-2 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -775,22 +839,13 @@ useHead({
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.profile-col-left { .profile-col-left .profile-col-inset,
margin-left: -16px; .profile-col-right .profile-col-inset {
margin-right: -16px;
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
} }
.section-divider-left,
.section-divider-right {
margin-left: -16px;
margin-right: -16px;
}
.save-bar { .save-bar {
margin-left: -16px;
margin-right: -16px;
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
flex-wrap: wrap; flex-wrap: wrap;

View file

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

View file

@ -0,0 +1,42 @@
import Member from '../../../models/member.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const body = await validateBody(event, adminMemberUpdateSchema)
const memberId = getRouterParam(event, 'id')
await connectDB()
// If email changed, check for duplicates
const existing = await Member.findById(memberId)
if (!existing) {
throw createError({ statusCode: 404, statusMessage: 'Member not found' })
}
if (body.email !== existing.email) {
const emailTaken = await Member.findOne({ email: body.email })
if (emailTaken) {
throw createError({ statusCode: 409, statusMessage: 'Email already in use by another member' })
}
}
const updated = await Member.findByIdAndUpdate(memberId, {
name: body.name,
email: body.email,
circle: body.circle,
contributionTier: body.contributionTier,
status: body.status,
}, { new: true })
return {
_id: updated._id,
name: updated.name,
email: updated.email,
circle: updated.circle,
contributionTier: updated.contributionTier,
status: updated.status,
role: updated.role,
}
})

View file

@ -1,4 +1,5 @@
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import { randomUUID } from 'crypto'
import { Resend } from 'resend' import { Resend } from 'resend'
import Member from '../../../models/member.js' import Member from '../../../models/member.js'
import { connectDB } from '../../../utils/mongoose.js' import { connectDB } from '../../../utils/mongoose.js'
@ -10,12 +11,12 @@ export default defineEventHandler(async (event) => {
const { memberIds, emailTemplate } = await validateBody(event, memberInviteSchema) const { memberIds, emailTemplate } = await validateBody(event, memberInviteSchema)
await connectDB() await connectDB()
const config = useRuntimeConfig(event) const baseUrl = process.env.BASE_URL
const headers = getHeaders(event) if (!baseUrl) {
const baseUrl = throw createError({ statusCode: 500, statusMessage: 'BASE_URL environment variable is not set' })
process.env.BASE_URL || }
`${headers.host?.includes('localhost') ? 'http' : 'https'}://${headers.host}`
const config = useRuntimeConfig(event)
const members = await Member.find({ _id: { $in: memberIds } }) const members = await Member.find({ _id: { $in: memberIds } })
if (members.length === 0) { if (members.length === 0) {
@ -28,15 +29,32 @@ export default defineEventHandler(async (event) => {
const results = [] const results = []
for (const member of members) { for (const member of members) {
// Skip suspended/cancelled — do not reactivate silently
if (member.status === 'suspended' || member.status === 'cancelled') {
results.push({
memberId: member._id,
email: member.email,
success: false,
error: `Skipped: account is ${member.status}`,
})
continue
}
try { try {
// Generate 48-hour magic login token (same format as login.post.js) // Generate single-use invite token (48h), same jti pattern as login.post.js
const jti = randomUUID()
const token = jwt.sign( const token = jwt.sign(
{ memberId: member._id }, { memberId: member._id, jti },
config.jwtSecret, config.jwtSecret,
{ expiresIn: '48h' } { expiresIn: '48h' },
) )
const loginLink = `${baseUrl}/api/auth/verify?token=${token}` // Store jti for single-use enforcement in verify.post.js
member.magicLinkJti = jti
member.magicLinkJtiUsed = false
// Token in fragment — never hits server logs
const loginLink = `${baseUrl}/verify#${token}`
// Interpolate template variables // Interpolate template variables
const emailText = emailTemplate const emailText = emailTemplate
@ -59,9 +77,9 @@ export default defineEventHandler(async (event) => {
const { error: emailError } = await resend.emails.send({ const { error: emailError } = await resend.emails.send({
from: 'Ghost Guild <welcome@babyghosts.org>', from: 'Ghost Guild <welcome@babyghosts.org>',
to: [member.email], to: [member.email],
subject: 'You\'re invited to Ghost Guild', subject: "You're invited to Ghost Guild",
text: emailText, text: emailText,
html: emailHtml html: emailHtml,
}) })
if (emailError) { if (emailError) {

View file

@ -8,6 +8,7 @@ export default defineEventHandler(async (event) => {
id: member._id, id: member._id,
email: member.email, email: member.email,
name: member.name, name: member.name,
status: member.status,
role: member.role || 'member', role: member.role || 'member',
circle: member.circle, circle: member.circle,
contributionTier: member.contributionTier, contributionTier: member.contributionTier,
@ -23,8 +24,11 @@ export default defineEventHandler(async (event) => {
offering: member.offering, offering: member.offering,
lookingFor: member.lookingFor, lookingFor: member.lookingFor,
showInDirectory: member.showInDirectory, showInDirectory: member.showInDirectory,
notifications: member.notifications,
privacy: member.privacy, privacy: member.privacy,
// Peer support // Peer support
peerSupport: member.peerSupport, peerSupport: member.peerSupport,
// Timestamps
createdAt: member.createdAt,
}; };
}); });

View file

@ -40,9 +40,16 @@ export default defineEventHandler(async (event) => {
}) })
} }
// Issue a fresh token if (decoded.tv !== member.tokenVersion) {
throw createError({
statusCode: 401,
statusMessage: 'Session has been revoked'
})
}
// Issue a fresh token with current tokenVersion
const newToken = jwt.sign( const newToken = jwt.sign(
{ memberId: member._id, email: member.email }, { memberId: member._id, email: member.email, tv: member.tokenVersion },
useRuntimeConfig().jwtSecret, useRuntimeConfig().jwtSecret,
{ expiresIn: '7d' } { expiresIn: '7d' }
) )
@ -51,7 +58,8 @@ export default defineEventHandler(async (event) => {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7 // 7 days path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 days
}) })
return { success: true } return { success: true }

View file

@ -19,7 +19,7 @@ export default defineEventHandler(async (event) => {
} }
if (member.status === 'suspended' || member.status === 'cancelled') { if (member.status === 'suspended' || member.status === 'cancelled') {
return { authenticated: false, member: null, reason: 'account_' + member.status } return { authenticated: false, member: null }
} }
return { return {
@ -29,6 +29,7 @@ export default defineEventHandler(async (event) => {
email: member.email, email: member.email,
name: member.name, name: member.name,
circle: member.circle, circle: member.circle,
status: member.status,
contributionTier: member.contributionTier, contributionTier: member.contributionTier,
membershipLevel: `${member.circle}-${member.contributionTier}` membershipLevel: `${member.circle}-${member.contributionTier}`
} }

View file

@ -0,0 +1,41 @@
import jwt from 'jsonwebtoken'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
// Only allow in development
if (process.env.NODE_ENV === 'production') {
throw createError({ statusCode: 404, statusMessage: 'Not found' })
}
const query = getQuery(event)
const email = query.email
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'email query param required' })
}
await connectDB()
const member = await Member.findOne({ email: email.toLowerCase() })
if (!member) {
throw createError({ statusCode: 404, statusMessage: `No member found with email: ${email}` })
}
const config = useRuntimeConfig(event)
const token = jwt.sign(
{ memberId: member._id, email: member.email },
config.jwtSecret,
{ expiresIn: '7d' }
)
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: false,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
})
await sendRedirect(event, '/member/account', 302)
})

View file

@ -3,12 +3,15 @@ import {
sendEventCancellationEmail, sendEventCancellationEmail,
sendWaitlistNotificationEmail, sendWaitlistNotificationEmail,
} from "../../../utils/resend.js"; } from "../../../utils/resend.js";
import { connectDB } from "../../../utils/mongoose.js";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id"); const id = getRouterParam(event, "id");
const body = await validateBody(event, cancelRegistrationSchema); const body = await validateBody(event, cancelRegistrationSchema);
const { email } = body; const { email } = body;
await connectDB();
try { try {
// Check if id is a valid ObjectId or treat as slug // Check if id is a valid ObjectId or treat as slug
const isObjectId = /^[0-9a-fA-F]{24}$/.test(id); const isObjectId = /^[0-9a-fA-F]{24}$/.test(id);
@ -46,13 +49,15 @@ export default defineEventHandler(async (event) => {
eventDoc.registrations[registrationIndex].membershipLevel, eventDoc.registrations[registrationIndex].membershipLevel,
}; };
// Remove the registration // Use $pull to avoid re-validating the whole document (e.g. legacy location formats)
eventDoc.registrations.splice(registrationIndex, 1); await Event.findByIdAndUpdate(
eventDoc._id,
// Update registered count {
eventDoc.registeredCount = eventDoc.registrations.length; $pull: { registrations: { email: registration.email } },
$inc: { registeredCount: -1 },
await eventDoc.save(); },
{ runValidators: false }
);
// Send cancellation confirmation email // Send cancellation confirmation email
try { try {
@ -90,9 +95,13 @@ export default defineEventHandler(async (event) => {
if (waitlistEntry) { if (waitlistEntry) {
await sendWaitlistNotificationEmail(waitlistEntry, eventData); await sendWaitlistNotificationEmail(waitlistEntry, eventData);
// Mark as notified // Mark as notified using findByIdAndUpdate to avoid re-validating the document
waitlistEntry.notified = true; const entryIndex = eventDoc.tickets.waitlist.entries.indexOf(waitlistEntry);
await eventDoc.save(); await Event.findByIdAndUpdate(
eventDoc._id,
{ $set: { [`tickets.waitlist.entries.${entryIndex}.notified`]: true } },
{ runValidators: false }
);
} }
} catch (waitlistError) { } catch (waitlistError) {
// Log error but don't fail the cancellation // Log error but don't fail the cancellation

View file

@ -76,8 +76,8 @@ export default defineEventHandler(async (event) => {
// If event requires payment and user is not a member, redirect to payment flow // If event requires payment and user is not a member, redirect to payment flow
if ( if (
eventData.pricing.paymentRequired && eventData.pricing?.paymentRequired &&
!eventData.pricing.isFree && !eventData.pricing?.isFree &&
!member !member
) { ) {
throw createError({ throw createError({
@ -109,10 +109,13 @@ export default defineEventHandler(async (event) => {
registeredAt: new Date(), registeredAt: new Date(),
}; };
eventData.registrations.push(registration); // Use $push to avoid re-validating the whole document (e.g. legacy location formats)
const result = await Event.findByIdAndUpdate(
// Save the updated event eventData._id,
await eventData.save(); { $push: { registrations: registration } },
{ new: true, runValidators: false }
);
const newRegistration = result.registrations[result.registrations.length - 1];
// Send confirmation email using Resend // Send confirmation email using Resend
try { try {
@ -125,8 +128,7 @@ export default defineEventHandler(async (event) => {
return { return {
success: true, success: true,
message: "Successfully registered for the event", message: "Successfully registered for the event",
registrationId: registrationId: newRegistration._id,
eventData.registrations[eventData.registrations.length - 1]._id,
}; };
} catch (error) { } catch (error) {
console.error("Error registering for event:", error); console.error("Error registering for event:", error);

View file

@ -7,7 +7,7 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event) const config = useRuntimeConfig(event)
const body = await validateBody(event, helcimCreatePlanSchema) const body = await validateBody(event, helcimCreatePlanSchema)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN const helcimToken = config.helcimApiToken
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, { const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {

View file

@ -45,7 +45,7 @@ export default defineEventHandler(async (event) => {
}) })
} }
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN const helcimToken = config.helcimApiToken
const response = await fetch( const response = await fetch(
`${HELCIM_API_BASE}/customers/${member.helcimCustomerId}`, `${HELCIM_API_BASE}/customers/${member.helcimCustomerId}`,

View file

@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
} }
// Get token directly from environment if not in config // Get token directly from environment if not in config
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN const helcimToken = config.helcimApiToken
if (!helcimToken) { if (!helcimToken) {
throw createError({ throw createError({

View file

@ -38,7 +38,7 @@ export default defineEventHandler(async (event) => {
}) })
} }
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN const helcimToken = config.helcimApiToken
// First, search for existing customer // First, search for existing customer
try { try {

View file

@ -5,16 +5,16 @@ const HELCIM_API_BASE = "https://api.helcim.com/v2";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
await requireAuth(event);
const config = useRuntimeConfig(event); const config = useRuntimeConfig(event);
const body = await validateBody(event, helcimInitializePaymentSchema); const body = await validateBody(event, helcimInitializePaymentSchema);
// Event ticket purchases can be made without authentication
const helcimToken =
config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
// Determine payment type based on whether this is for a subscription or one-time payment
const isEventTicket = body.metadata?.type === "event_ticket"; const isEventTicket = body.metadata?.type === "event_ticket";
if (!isEventTicket) {
await requireAuth(event);
}
const helcimToken = config.helcimApiToken;
const amount = body.amount || 0; const amount = body.amount || 0;
// For event tickets with amount > 0, we do a purchase // For event tickets with amount > 0, we do a purchase

View file

@ -5,7 +5,7 @@ export default defineEventHandler(async (event) => {
try { try {
await requireAdmin(event) await requireAdmin(event)
const config = useRuntimeConfig(event) const config = useRuntimeConfig(event)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN const helcimToken = config.helcimApiToken
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, { const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
method: 'GET', method: 'GET',

View file

@ -157,7 +157,7 @@ export default defineEventHandler(async (event) => {
} }
// Try to create subscription in Helcim // Try to create subscription in Helcim
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN const helcimToken = config.helcimApiToken
// Generate a proper alphanumeric idempotency key (exactly 25 characters) // Generate a proper alphanumeric idempotency key (exactly 25 characters)
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'

View file

@ -5,7 +5,7 @@ export default defineEventHandler(async (event) => {
try { try {
await requireAdmin(event) await requireAdmin(event)
const config = useRuntimeConfig(event) const config = useRuntimeConfig(event)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN const helcimToken = config.helcimApiToken
const response = await fetch(`${HELCIM_API_BASE}/subscriptions`, { const response = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
method: 'GET', method: 'GET',

View file

@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
const { billingAddress } = body const { billingAddress } = body
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN const helcimToken = config.helcimApiToken
// Update customer billing address in Helcim // Update customer billing address in Helcim
const response = await fetch(`${HELCIM_API_BASE}/customers/${body.customerId}`, { const response = await fetch(`${HELCIM_API_BASE}/customers/${body.customerId}`, {

View file

@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event) const config = useRuntimeConfig(event)
const body = await validateBody(event, paymentVerifySchema) const body = await validateBody(event, paymentVerifySchema)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN const helcimToken = config.helcimApiToken
if (!helcimToken) { if (!helcimToken) {
throw createError({ throw createError({

View file

@ -18,8 +18,7 @@ export default defineEventHandler(async (event) => {
}; };
} }
const helcimToken = const helcimToken = config.helcimApiToken;
config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
try { try {
// Cancel Helcim subscription // Cancel Helcim subscription

View file

@ -89,7 +89,7 @@ export default defineEventHandler(async (event) => {
try { try {
const members = await Member.find(dbQuery) const members = await Member.find(dbQuery)
.select( .select(
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport slackUserId createdAt", "name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport createdAt",
) )
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.lean(); .lean();
@ -124,10 +124,15 @@ export default defineEventHandler(async (event) => {
if (isVisible("offering")) filtered.offering = member.offering; if (isVisible("offering")) filtered.offering = member.offering;
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor; if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
// Always show peer support if enabled (it's opt-in, so public by nature) // Peer support: expose only fields needed for matching/contact UX
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
if (member.peerSupport?.enabled) { if (member.peerSupport?.enabled) {
filtered.peerSupport = member.peerSupport; filtered.peerSupport = {
filtered.slackUserId = member.slackUserId; enabled: true,
skillTopics: member.peerSupport.skillTopics,
supportTopics: member.peerSupport.supportTopics,
availability: member.peerSupport.availability,
};
} }
return filtered; return filtered;

View file

@ -1,120 +1,89 @@
import jwt from "jsonwebtoken"; import Member from '../../../models/member.js'
import Member from "../../../models/member.js"; import { connectDB } from '../../../utils/mongoose.js'
import { connectDB } from "../../../utils/mongoose.js";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await connectDB(); await connectDB()
const member = await requireAuth(event)
const token = getCookie(event, "auth-token"); const body = await validateBody(event, peerSupportUpdateSchema)
if (!token) {
throw createError({
statusCode: 401,
statusMessage: "Not authenticated",
});
}
let memberId;
try {
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId;
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired token",
});
}
const body = await validateBody(event, peerSupportUpdateSchema);
// Build update object for peer support settings // Build update object for peer support settings
const updateData = { const updateData = {
"peerSupport.enabled": body.enabled || false, 'peerSupport.enabled': body.enabled || false,
"peerSupport.skillTopics": body.skillTopics || [], 'peerSupport.skillTopics': body.skillTopics || [],
"peerSupport.supportTopics": body.supportTopics || [], 'peerSupport.supportTopics': body.supportTopics || [],
"peerSupport.availability": body.availability || "", 'peerSupport.availability': body.availability || '',
"peerSupport.personalMessage": body.personalMessage || "", 'peerSupport.personalMessage': body.personalMessage || '',
"peerSupport.slackUsername": body.slackUsername || "", 'peerSupport.slackUsername': body.slackUsername || '',
}; }
// If Slack username provided and peer support enabled, try to fetch Slack user ID // If Slack username provided and peer support enabled, try to fetch Slack user ID
if (body.enabled && body.slackUsername) { if (body.enabled && body.slackUsername) {
try { try {
console.log( console.log(
`[Peer Support] Attempting to fetch Slack user ID for: ${body.slackUsername}`, `[Peer Support] Attempting to fetch Slack user ID for: ${body.slackUsername}`,
); )
// Dynamically import the Slack service const { getSlackService } = await import('../../../utils/slack.ts')
const { getSlackService } = await import("../../../utils/slack.ts"); const slackService = getSlackService()
const slackService = getSlackService();
if (slackService) { if (slackService) {
console.log( console.log('[Peer Support] Slack service initialized, looking up user...')
"[Peer Support] Slack service initialized, looking up user...", const slackUserId = await slackService.findUserIdByUsername(body.slackUsername)
);
const slackUserId = await slackService.findUserIdByUsername(
body.slackUsername,
);
if (slackUserId) { if (slackUserId) {
updateData["slackUserId"] = slackUserId; updateData['slackUserId'] = slackUserId
console.log( console.log(
`[Peer Support] ✓ Found Slack user ID for ${body.slackUsername}: ${slackUserId}`, `[Peer Support] ✓ Found Slack user ID for ${body.slackUsername}: ${slackUserId}`,
); )
// Now get/create the DM channel console.log('[Peer Support] Opening DM channel...')
console.log("[Peer Support] Opening DM channel..."); const dmChannelId = await slackService.openDMChannel(slackUserId)
const dmChannelId = await slackService.openDMChannel(slackUserId);
if (dmChannelId) { if (dmChannelId) {
updateData["peerSupport.slackDMChannelId"] = dmChannelId; updateData['peerSupport.slackDMChannelId'] = dmChannelId
console.log(`[Peer Support] ✓ Got DM channel ID: ${dmChannelId}`); console.log(`[Peer Support] ✓ Got DM channel ID: ${dmChannelId}`)
} else { } else {
console.warn("[Peer Support] Could not get DM channel ID"); console.warn('[Peer Support] Could not get DM channel ID')
} }
} else { } else {
console.warn( console.warn(
`[Peer Support] Could not find Slack user ID for username: ${body.slackUsername}`, `[Peer Support] Could not find Slack user ID for username: ${body.slackUsername}`,
); )
} }
} else { } else {
console.log( console.log('[Peer Support] Slack service not configured, skipping user ID lookup')
"[Peer Support] Slack service not configured, skipping user ID lookup",
);
} }
} catch (error) { } catch (error) {
console.error( console.error('[Peer Support] Error fetching Slack user ID:', error.message)
"[Peer Support] Error fetching Slack user ID:", console.error('[Peer Support] Stack trace:', error.stack)
error.message,
);
console.error("[Peer Support] Stack trace:", error.stack);
// Continue anyway - we'll still save the username // Continue anyway - we'll still save the username
} }
} }
try { try {
const member = await Member.findByIdAndUpdate( const updated = await Member.findByIdAndUpdate(
memberId, member._id,
{ $set: updateData }, { $set: updateData },
{ new: true, runValidators: true }, { new: true, runValidators: true },
); )
if (!member) { if (!updated) {
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
statusMessage: "Member not found", statusMessage: 'Member not found',
}); })
} }
return { return {
success: true, success: true,
peerSupport: member.peerSupport, peerSupport: updated.peerSupport,
}; }
} catch (error) { } catch (error) {
console.error("Peer support update error:", error); console.error('Peer support update error:', error)
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: "Failed to update peer support settings", statusMessage: 'Failed to update peer support settings',
}); })
} }
}); })

View file

@ -1,114 +1,94 @@
import Event from "../../models/event"; import { connectDB } from '../../utils/mongoose.js'
import Member from "../../models/member"; import Event from '../../models/event'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const query = getQuery(event); await connectDB()
const { memberId } = query; const member = await requireAuth(event)
if (!memberId) {
throw createError({
statusCode: 400,
statusMessage: "Member ID is required",
});
}
try { try {
// Verify member exists
const member = await Member.findById(memberId);
if (!member) {
throw createError({
statusCode: 404,
statusMessage: "Member not found",
});
}
// Find all events where the user is registered
const events = await Event.find({ const events = await Event.find({
"registrations.memberId": memberId, 'registrations.memberId': member._id,
isCancelled: { $ne: true }, isCancelled: { $ne: true },
}) })
.select("title slug description startDate endDate location") .select('title slug description startDate endDate location')
.sort({ startDate: 1 }); .sort({ startDate: 1 })
// Generate iCal format const ical = generateICalendar(events, member)
const ical = generateICalendar(events, member);
// Set headers for calendar subscription (not download) setHeader(event, 'Content-Type', 'text/calendar; charset=utf-8')
setHeader(event, "Content-Type", "text/calendar; charset=utf-8"); setHeader(event, 'Cache-Control', 'no-cache, no-store, must-revalidate')
setHeader(event, "Cache-Control", "no-cache, no-store, must-revalidate"); setHeader(event, 'Pragma', 'no-cache')
setHeader(event, "Pragma", "no-cache"); setHeader(event, 'Expires', '0')
setHeader(event, "Expires", "0");
return ical; return ical
} catch (error) { } catch (error) {
console.error("Error generating calendar:", error); console.error('Error generating calendar:', error)
if (error.statusCode) { if (error.statusCode) {
throw error; throw error
} }
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: "Failed to generate calendar", statusMessage: 'Failed to generate calendar',
}); })
} }
}); })
function generateICalendar(events, member) { function generateICalendar(events, member) {
const now = new Date(); const now = new Date()
const timestamp = now const timestamp = now
.toISOString() .toISOString()
.replace(/[-:]/g, "") .replace(/[-:]/g, '')
.replace(/\.\d{3}/, ""); .replace(/\.\d{3}/, '')
let ical = [ let ical = [
"BEGIN:VCALENDAR", 'BEGIN:VCALENDAR',
"VERSION:2.0", 'VERSION:2.0',
"PRODID:-//Ghost Guild//Events Calendar//EN", 'PRODID:-//Ghost Guild//Events Calendar//EN',
"CALSCALE:GREGORIAN", 'CALSCALE:GREGORIAN',
"METHOD:PUBLISH", 'METHOD:PUBLISH',
"X-WR-CALNAME:Ghost Guild - My Events", 'X-WR-CALNAME:Ghost Guild - My Events',
"X-WR-TIMEZONE:UTC", 'X-WR-TIMEZONE:UTC',
"X-WR-CALDESC:Your registered Ghost Guild events", 'X-WR-CALDESC:Your registered Ghost Guild events',
"REFRESH-INTERVAL;VALUE=DURATION:PT1H", 'REFRESH-INTERVAL;VALUE=DURATION:PT1H',
"X-PUBLISHED-TTL:PT1H", 'X-PUBLISHED-TTL:PT1H',
]; ]
events.forEach((evt) => { events.forEach((evt) => {
const eventStart = new Date(evt.startDate); const eventStart = new Date(evt.startDate)
const eventEnd = new Date(evt.endDate); const eventEnd = new Date(evt.endDate)
const dtstart = eventStart const dtstart = eventStart
.toISOString() .toISOString()
.replace(/[-:]/g, "") .replace(/[-:]/g, '')
.replace(/\.\d{3}/, ""); .replace(/\.\d{3}/, '')
const dtend = eventEnd const dtend = eventEnd
.toISOString() .toISOString()
.replace(/[-:]/g, "") .replace(/[-:]/g, '')
.replace(/\.\d{3}/, ""); .replace(/\.\d{3}/, '')
const dtstamp = timestamp; const dtstamp = timestamp
// Clean description for iCal format const description = (evt.description || '')
const description = (evt.description || "") .replace(/\n/g, '\\n')
.replace(/\n/g, "\\n") .replace(/,/g, '\\,')
.replace(/,/g, "\\,");
const eventUrl = `https://ghostguild.org/events/${evt.slug || evt._id}`; const eventUrl = `https://ghostguild.org/events/${evt.slug || evt._id}`
ical.push("BEGIN:VEVENT"); ical.push('BEGIN:VEVENT')
ical.push(`UID:${evt._id}@ghostguild.org`); ical.push(`UID:${evt._id}@ghostguild.org`)
ical.push(`DTSTAMP:${dtstamp}`); ical.push(`DTSTAMP:${dtstamp}`)
ical.push(`DTSTART:${dtstart}`); ical.push(`DTSTART:${dtstart}`)
ical.push(`DTEND:${dtend}`); ical.push(`DTEND:${dtend}`)
ical.push(`SUMMARY:${evt.title}`); ical.push(`SUMMARY:${evt.title}`)
ical.push(`DESCRIPTION:${description}\\n\\nView event: ${eventUrl}`); ical.push(`DESCRIPTION:${description}\\n\\nView event: ${eventUrl}`)
ical.push(`LOCATION:${evt.location || "Online"}`); ical.push(`LOCATION:${evt.location || 'Online'}`)
ical.push(`URL:${eventUrl}`); ical.push(`URL:${eventUrl}`)
ical.push(`STATUS:CONFIRMED`); ical.push('STATUS:CONFIRMED')
ical.push("END:VEVENT"); ical.push('END:VEVENT')
}); })
ical.push("END:VCALENDAR"); ical.push('END:VCALENDAR')
return ical.join("\r\n"); return ical.join('\r\n')
} }

View file

@ -1,60 +1,36 @@
import Event from "../../models/event"; import { connectDB } from '../../utils/mongoose.js'
import Member from "../../models/member"; import Event from '../../models/event'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const query = getQuery(event); await connectDB()
const { memberId } = query; const member = await requireAuth(event)
if (!memberId) {
throw createError({
statusCode: 400,
statusMessage: "Member ID is required",
});
}
try { try {
// Verify member exists const now = new Date()
const member = await Member.findById(memberId);
if (!member) {
throw createError({
statusCode: 404,
statusMessage: "Member not found",
});
}
// Find all events where the user is registered
// Filter out cancelled events and only show future events
const now = new Date();
const events = await Event.find({ const events = await Event.find({
"registrations.memberId": memberId, 'registrations.memberId': member._id,
isCancelled: { $ne: true }, isCancelled: { $ne: true },
startDate: { $gte: now }, startDate: { $gte: now },
}) })
.select( .select('title slug description startDate endDate location featureImage maxAttendees registeredCount')
"title slug description startDate endDate location featureImage maxAttendees registeredCount",
)
.sort({ startDate: 1 }) .sort({ startDate: 1 })
.limit(10); .limit(10)
console.log(
`Found ${events.length} registered events for member ${memberId}`,
);
return { return {
events, events,
count: events.length, count: events.length,
}; }
} catch (error) { } catch (error) {
console.error("Error fetching member events:", error); console.error('Error fetching member events:', error)
if (error.statusCode) { if (error.statusCode) {
throw error; throw error
} }
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: "Failed to fetch registered events", statusMessage: 'Failed to fetch registered events',
}); })
} }
}); })

View file

@ -19,6 +19,7 @@ export default defineEventHandler(async (event) => {
"location", "location",
"socialLinks", "socialLinks",
"showInDirectory", "showInDirectory",
"notifications",
]; ];
// Privacy fields from validated body // Privacy fields from validated body
@ -96,6 +97,7 @@ export default defineEventHandler(async (event) => {
offering: member.offering, offering: member.offering,
lookingFor: member.lookingFor, lookingFor: member.lookingFor,
showInDirectory: member.showInDirectory, showInDirectory: member.showInDirectory,
notifications: member.notifications,
}; };
} catch (error) { } catch (error) {
if (error.statusCode) throw error; if (error.statusCode) throw error;

View file

@ -0,0 +1,34 @@
// Update member's circle
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
import { requireAuth } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
const member = await requireAuth(event)
await connectDB()
const body = await validateBody(event, updateCircleSchema)
if (member.circle === body.circle) {
return { success: true, message: 'Already in this circle' }
}
await Member.findByIdAndUpdate(
member._id,
{ $set: { circle: body.circle } },
{ runValidators: false }
)
return {
success: true,
message: `Circle updated to ${body.circle}`,
}
} catch (error) {
if (error.statusCode) throw error
console.error('Error updating circle:', error)
throw createError({
statusCode: 500,
statusMessage: 'An unexpected error occurred',
})
}
})

View file

@ -1,48 +1,19 @@
// Update member's contribution tier // Update member's contribution tier
import jwt from "jsonwebtoken";
import { import {
getHelcimPlanId, getHelcimPlanId,
requiresPayment, requiresPayment,
isValidContributionValue,
} from "../../config/contributions.js"; } from "../../config/contributions.js";
import Member from "../../models/member.js";
import { connectDB } from "../../utils/mongoose.js"; import { connectDB } from "../../utils/mongoose.js";
import Member from "../../models/member.js";
const HELCIM_API_BASE = "https://api.helcim.com/v2"; const HELCIM_API_BASE = "https://api.helcim.com/v2";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const member = await requireAuth(event);
await connectDB(); await connectDB();
const config = useRuntimeConfig(event); const config = useRuntimeConfig(event);
const body = await validateBody(event, updateContributionSchema); const body = await validateBody(event, updateContributionSchema);
const token = getCookie(event, "auth-token");
if (!token) {
throw createError({
statusCode: 401,
statusMessage: "Not authenticated",
});
}
// Decode JWT token
let decoded;
try {
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired token",
});
}
// Get member
const member = await Member.findById(decoded.memberId);
if (!member) {
throw createError({
statusCode: 404,
statusMessage: "Member not found",
});
}
const oldTier = member.contributionTier; const oldTier = member.contributionTier;
const newTier = body.contributionTier; const newTier = body.contributionTier;
@ -55,8 +26,7 @@ export default defineEventHandler(async (event) => {
}; };
} }
const helcimToken = const helcimToken = config.helcimApiToken;
config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
const oldRequiresPayment = requiresPayment(oldTier); const oldRequiresPayment = requiresPayment(oldTier);
const newRequiresPayment = requiresPayment(newTier); const newRequiresPayment = requiresPayment(newTier);
@ -73,8 +43,7 @@ export default defineEventHandler(async (event) => {
} }
// Try to fetch customer info from Helcim to check for saved cards // Try to fetch customer info from Helcim to check for saved cards
const helcimToken = const helcimToken = config.helcimApiToken;
config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
try { try {
const customerResponse = await fetch( const customerResponse = await fetch(
@ -185,11 +154,11 @@ export default defineEventHandler(async (event) => {
} }
// Update member record // Update member record
member.contributionTier = newTier; await Member.findByIdAndUpdate(
member.helcimSubscriptionId = subscription.id; member._id,
member.paymentMethod = "card"; { $set: { contributionTier: newTier, helcimSubscriptionId: subscription.id, paymentMethod: "card", status: "active" } },
member.status = "active"; { runValidators: false }
await member.save(); );
return { return {
success: true, success: true,
@ -241,10 +210,11 @@ export default defineEventHandler(async (event) => {
} }
// Update member to free tier // Update member to free tier
member.contributionTier = newTier; await Member.findByIdAndUpdate(
member.helcimSubscriptionId = null; member._id,
member.paymentMethod = "none"; { $set: { contributionTier: newTier, helcimSubscriptionId: null, paymentMethod: "none" } },
await member.save(); { runValidators: false }
);
return { return {
success: true, success: true,
@ -303,8 +273,11 @@ export default defineEventHandler(async (event) => {
const subscriptionData = await response.json(); const subscriptionData = await response.json();
// Update member record // Update member record
member.contributionTier = newTier; await Member.findByIdAndUpdate(
await member.save(); member._id,
{ $set: { contributionTier: newTier } },
{ runValidators: false }
);
return { return {
success: true, success: true,
@ -321,8 +294,11 @@ export default defineEventHandler(async (event) => {
} }
// Case 4: Moving between free tiers (shouldn't happen but handle it) // Case 4: Moving between free tiers (shouldn't happen but handle it)
member.contributionTier = newTier; await Member.findByIdAndUpdate(
await member.save(); member._id,
{ $set: { contributionTier: newTier } },
{ runValidators: false }
);
return { return {
success: true, success: true,

View file

@ -18,9 +18,9 @@ export default defineEventHandler((event) => {
headers['Content-Security-Policy'] = [ headers['Content-Security-Policy'] = [
"default-src 'self'", "default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://myposjs.helcim.com https://plausible.io", "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://myposjs.helcim.com https://plausible.io",
"style-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: https://res.cloudinary.com https://*.cloudinary.com", "img-src 'self' data: https://res.cloudinary.com https://*.cloudinary.com",
"font-src 'self'", "font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' https://api.helcim.com https://myposjs.helcim.com https://plausible.io", "connect-src 'self' https://api.helcim.com https://myposjs.helcim.com https://plausible.io",
"frame-src 'self' https://myposjs.helcim.com https://secure.helcim.com", "frame-src 'self' https://myposjs.helcim.com https://secure.helcim.com",
"base-uri 'self'", "base-uri 'self'",

View file

@ -35,7 +35,7 @@ function getClientIp(event) {
|| 'unknown' || 'unknown'
} }
const AUTH_PATHS = new Set(['/api/auth/login']) const AUTH_PATHS = new Set(['/api/auth/login', '/api/auth/verify'])
const PAYMENT_PREFIXES = ['/api/helcim/'] const PAYMENT_PREFIXES = ['/api/helcim/']
const UPLOAD_PATHS = new Set(['/api/upload/image']) const UPLOAD_PATHS = new Set(['/api/upload/image'])

View file

@ -45,7 +45,7 @@ const memberSchema = new mongoose.Schema({
slackInvited: { type: Boolean, default: false }, slackInvited: { type: Boolean, default: false },
slackInviteStatus: { slackInviteStatus: {
type: String, type: String,
enum: ["pending", "sent", "failed", "accepted"], enum: ["pending", "sent", "failed", "accepted", "joined"],
default: "pending", default: "pending",
}, },
slackUserId: String, slackUserId: String,
@ -133,9 +133,22 @@ const memberSchema = new mongoose.Schema({
}, },
}, },
notifications: {
events: { type: Boolean, default: true },
updates: { type: Boolean, default: true },
peerRequests: { type: Boolean, default: true },
},
inviteEmailSent: { type: Boolean, default: false }, inviteEmailSent: { type: Boolean, default: false },
inviteEmailSentAt: Date, inviteEmailSentAt: Date,
// Magic link single-use enforcement
magicLinkJti: String,
magicLinkJtiUsed: { type: Boolean, default: false },
// Session revocation via token versioning
tokenVersion: { type: Number, default: 0 },
createdAt: { type: Date, default: Date.now }, createdAt: { type: Date, default: Date.now },
lastLogin: Date, lastLogin: Date,
}); });

View file

@ -44,6 +44,14 @@ export async function requireAuth(event) {
}) })
} }
// Verify session has not been revoked (tokenVersion incremented on logout)
if (decoded.tv !== member.tokenVersion) {
throw createError({
statusCode: 401,
statusMessage: 'Session has been revoked'
})
}
return member return member
} }