fix: use private helcimApiToken for all server-side Helcim API calls
This commit is contained in:
parent
ccd1d0783a
commit
d31b5b4dac
53 changed files with 1755 additions and 572 deletions
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
152
.serena/project.yml
Normal file
152
.serena/project.yml
Normal 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 read‑only.
|
||||||
|
# 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: []
|
||||||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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 ---- */
|
||||||
|
|
|
||||||
|
|
@ -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) }}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,29 @@
|
||||||
<template>
|
<template>
|
||||||
<aside class="events-mini">
|
<aside class="events-mini">
|
||||||
<div class="em-label">Upcoming</div>
|
<div class="em-inset">
|
||||||
<div v-for="event in events" :key="event._id" class="em-item">
|
<div class="em-label">Upcoming</div>
|
||||||
<span class="em-date">{{ formatDate(event.date) }}</span>
|
</div>
|
||||||
<NuxtLink :to="`/events/${event._id}`" class="em-title">{{ event.title }}</NuxtLink>
|
<div v-if="events?.length" class="em-rows">
|
||||||
<span
|
<div v-for="event in events" :key="event._id" class="em-item">
|
||||||
v-if="event.circle"
|
<div class="em-inset em-item-body">
|
||||||
class="em-circle"
|
<span class="em-date">{{ formatDate(event.date) }}</span>
|
||||||
:style="{ color: `var(--c-${event.circle})` }"
|
<NuxtLink :to="`/events/${event._id}`" class="em-title">{{ event.title }}</NuxtLink>
|
||||||
>{{ event.circle }}</span>
|
<span
|
||||||
|
v-if="event.circle"
|
||||||
|
class="em-circle"
|
||||||
|
:style="{ color: `var(--c-${event.circle})` }"
|
||||||
|
>{{ event.circle }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="em-rows">
|
||||||
|
<div class="em-item em-item--plain">
|
||||||
|
<div class="em-inset em-empty">No upcoming events</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="em-inset em-link-wrap">
|
||||||
|
<NuxtLink to="/events" class="em-link">All events →</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!events?.length" class="em-empty">No upcoming events</div>
|
|
||||||
<NuxtLink to="/events" class="em-link">All events →</NuxtLink>
|
|
||||||
</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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ---- */
|
||||||
|
|
|
||||||
|
|
@ -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">×</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}"?`)) {
|
|
||||||
try {
|
const deleteEvent = (event) => {
|
||||||
await $fetch(`/api/admin/events/${String(event._id)}`, {
|
confirmDelete.id = String(event._id)
|
||||||
method: 'DELETE',
|
confirmDelete.title = event.title
|
||||||
})
|
confirmDelete.deleting = false
|
||||||
await refresh()
|
confirmDelete.show = true
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Failed to delete event:', error)
|
|
||||||
}
|
const executeDelete = async () => {
|
||||||
|
confirmDelete.deleting = true
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/admin/events/${confirmDelete.id}`, { method: 'DELETE' })
|
||||||
|
confirmDelete.show = false
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete event:', error)
|
||||||
|
} finally {
|
||||||
|
confirmDelete.deleting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -588,6 +616,77 @@ tbody td {
|
||||||
to { transform: rotate(360deg); }
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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">×</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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">×</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,29 +515,47 @@ 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: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
const removeFromSeries = (event) => {
|
||||||
await $fetch(`/api/admin/events/${event.id}`, {
|
confirmAction.heading = 'Remove from Series'
|
||||||
method: 'PUT',
|
confirmAction.message = `Remove "${event.title}" from its series?`
|
||||||
body: {
|
confirmAction.label = 'Remove'
|
||||||
...event,
|
confirmAction.running = false
|
||||||
series: {
|
confirmAction.execute = async () => {
|
||||||
isSeriesEvent: false,
|
confirmAction.running = true
|
||||||
id: '',
|
try {
|
||||||
title: '',
|
await $fetch(`/api/admin/events/${event.id}`, {
|
||||||
description: '',
|
method: 'PUT',
|
||||||
type: 'workshop_series',
|
body: {
|
||||||
position: 1,
|
...event,
|
||||||
totalEvents: null,
|
series: {
|
||||||
|
isSeriesEvent: false,
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
type: 'workshop_series',
|
||||||
|
position: 1,
|
||||||
|
totalEvents: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
})
|
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,23 +609,32 @@ 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.`
|
||||||
try {
|
confirmAction.label = 'Delete'
|
||||||
for (const event of series.events) {
|
confirmAction.running = false
|
||||||
await $fetch(`/api/admin/events/${event.id}`, {
|
confirmAction.execute = async () => {
|
||||||
method: 'PUT',
|
confirmAction.running = true
|
||||||
body: {
|
try {
|
||||||
...event,
|
for (const event of series.events) {
|
||||||
series: { isSeriesEvent: false, id: '', title: '', description: '', type: 'workshop_series', position: 1, totalEvents: null },
|
await $fetch(`/api/admin/events/${event.id}`, {
|
||||||
},
|
method: 'PUT',
|
||||||
})
|
body: {
|
||||||
|
...event,
|
||||||
|
series: { isSeriesEvent: false, id: '', title: '', description: '', type: 'workshop_series', position: 1, totalEvents: null },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
confirmAction.show = false
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete series:', error)
|
||||||
|
} finally {
|
||||||
|
confirmAction.running = false
|
||||||
}
|
}
|
||||||
await refresh()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete series:', error)
|
|
||||||
}
|
}
|
||||||
|
confirmAction.show = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const manageSeriesTickets = (series) => {
|
const manageSeriesTickets = (series) => {
|
||||||
|
|
|
||||||
|
|
@ -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' }}
|
||||||
|
|
|
||||||
|
|
@ -24,35 +24,51 @@
|
||||||
</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="label">Upcoming Events</div>
|
<div class="block-inset">
|
||||||
|
<div class="label">Upcoming Events</div>
|
||||||
|
</div>
|
||||||
<div v-if="events?.length" class="event-list">
|
<div v-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">
|
||||||
<span class="event-date">{{ formatDate(event.date) }}</span>
|
<div class="block-inset event-item-inner">
|
||||||
<span class="event-title">
|
<span class="event-date">{{ formatDate(event.date) }}</span>
|
||||||
<NuxtLink :to="`/events/${event._id}`">{{ event.title }}</NuxtLink>
|
<span class="event-title">
|
||||||
</span>
|
<NuxtLink :to="`/events/${event._id}`">{{ event.title }}</NuxtLink>
|
||||||
<CircleBadge v-if="event.circle" :circle="event.circle" />
|
</span>
|
||||||
|
<CircleBadge v-if="event.circle" :circle="event.circle" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="empty">No upcoming events</p>
|
<div v-else class="block-inset">
|
||||||
|
<p class="empty">No upcoming events</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content-block">
|
<div class="content-block">
|
||||||
<div class="label">Recently in the Wiki</div>
|
<div class="block-inset">
|
||||||
|
<div class="label">Recently in the Wiki</div>
|
||||||
|
</div>
|
||||||
<div class="wiki-list">
|
<div class="wiki-list">
|
||||||
<div class="wiki-item">
|
<div class="wiki-item">
|
||||||
<a href="/wiki">Revenue sharing models</a>
|
<div class="block-inset wiki-item-inner">
|
||||||
|
<a href="/wiki">Revenue sharing models</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="wiki-item">
|
<div class="wiki-item">
|
||||||
<a href="/wiki">What is a cooperative studio?</a>
|
<div class="block-inset wiki-item-inner">
|
||||||
|
<a href="/wiki">What is a cooperative studio?</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="wiki-item">
|
<div class="wiki-item">
|
||||||
<a href="/wiki">Governance structures</a>
|
<div class="block-inset wiki-item-inner">
|
||||||
|
<a href="/wiki">Governance structures</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="wiki-item">
|
<div class="wiki-item">
|
||||||
<a href="/wiki">Legal incorporation guide</a>
|
<div class="block-inset wiki-item-inner">
|
||||||
|
<a href="/wiki">Legal incorporation guide</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -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); }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,81 +17,100 @@
|
||||||
|
|
||||||
<!-- LEFT COLUMN: Membership Status & Email -->
|
<!-- LEFT COLUMN: Membership Status & Email -->
|
||||||
<div class="account-col-left">
|
<div class="account-col-left">
|
||||||
<div class="section-label">Current Membership</div>
|
<section class="account-section">
|
||||||
|
<div class="account-col-inset">
|
||||||
|
<div class="section-label">Current Membership</div>
|
||||||
|
|
||||||
<div class="membership-card">
|
<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>
|
</div>
|
||||||
<tr>
|
<div class="membership-row">
|
||||||
<td>Circle</td>
|
<span class="membership-k">Circle</span>
|
||||||
<td :style="{ color: `var(--c-${memberData.circle || 'community'})` }">
|
<span class="membership-v" :style="{ color: `var(--c-${memberData.circle || 'community'})` }">
|
||||||
{{ memberData.circle || 'Community' }}
|
{{ memberData.circle || 'Community' }}
|
||||||
</td>
|
</span>
|
||||||
</tr>
|
</div>
|
||||||
<tr>
|
<div class="membership-row">
|
||||||
<td>Contribution</td>
|
<span class="membership-k">Contribution</span>
|
||||||
<td>${{ memberData.contributionAmount || 0 }} / month</td>
|
<span class="membership-v">${{ memberData.contributionTier || 0 }} / month</span>
|
||||||
</tr>
|
</div>
|
||||||
<tr>
|
<div class="membership-row">
|
||||||
<td>Member since</td>
|
<span class="membership-k">Member since</span>
|
||||||
<td>{{ formatMemberSince(memberData.createdAt) }}</td>
|
<span class="membership-v">{{ formatMemberSince(memberData.createdAt) }}</span>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</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>
|
||||||
</button>
|
<div class="cancel-confirm-actions">
|
||||||
</div>
|
<button class="btn btn-danger" @click="confirmCancelMembership" :disabled="isCancelling">
|
||||||
|
{{ isCancelling ? 'Cancelling...' : 'Yes, Cancel' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn" @click="showCancelConfirm = false">Nevermind</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button v-else class="btn btn-danger" @click="handleCancelMembership" :disabled="isCancelling">
|
||||||
|
Cancel Membership
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT COLUMN: Change Contribution & Circle -->
|
<!-- RIGHT COLUMN: Change Contribution & Circle -->
|
||||||
<div class="account-col-right">
|
<div class="account-col-right">
|
||||||
<div class="section-label">Change Contribution</div>
|
<section class="account-section">
|
||||||
|
<div class="account-col-inset">
|
||||||
|
<div class="section-label">Change Contribution</div>
|
||||||
|
|
||||||
<TierPicker v-model="selectedTier" :tiers="tiers" />
|
<TierPicker v-model="selectedTier" :tiers="tiers" />
|
||||||
<div class="tier-hint">Changes take effect on your next billing cycle</div>
|
<div class="tier-hint">Changes take effect on your next billing cycle</div>
|
||||||
<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" />
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-section"
|
class="btn btn-primary btn-section"
|
||||||
@click="handleUpdateCircle"
|
@click="handleUpdateCircle"
|
||||||
:disabled="selectedCircle === memberData.circle || isUpdating"
|
:disabled="selectedCircle === memberData.circle || isUpdating"
|
||||||
>
|
>
|
||||||
{{ isUpdating ? 'Updating...' : 'Update Circle' }}
|
{{ isUpdating ? 'Updating...' : 'Update Circle' }}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</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()
|
||||||
toast.add({ title: 'Membership cancelled', color: 'orange' })
|
if (result.message === 'No active subscription to cancel') {
|
||||||
|
toast.add({ title: 'No active subscription', description: 'You are on the free tier — nothing to cancel.', color: 'neutral' })
|
||||||
|
} else {
|
||||||
|
toast.add({ title: 'Membership cancelled', color: 'orange' })
|
||||||
|
}
|
||||||
} catch (err) {
|
} 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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
157
app/pages/updates/[id]/edit.vue
Normal file
157
app/pages/updates/[id]/edit.vue
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="back-link">
|
||||||
|
<NuxtLink to="/member/my-updates">← Back to My Updates</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending" class="loading">Loading update...</div>
|
||||||
|
|
||||||
|
<template v-else-if="update">
|
||||||
|
<div class="form-header">
|
||||||
|
<h1>Edit Update</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="update-form">
|
||||||
|
<div class="field">
|
||||||
|
<label class="section-label">Content</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.content"
|
||||||
|
rows="8"
|
||||||
|
required
|
||||||
|
:disabled="saving"
|
||||||
|
></textarea>
|
||||||
|
<div class="char-count">{{ form.content.length }} / 50000</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="section-label">Visibility</label>
|
||||||
|
<select v-model="form.privacy" :disabled="saving">
|
||||||
|
<option value="members">Members only</option>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="private">Private (only you)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<NuxtLink to="/member/my-updates" class="btn">Cancel</NuxtLink>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="saving || !form.content.trim()"
|
||||||
|
>
|
||||||
|
{{ saving ? 'Saving...' : 'Save Changes' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="loading">Update not found.</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({ middleware: 'auth' })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const { data: update, pending } = await useFetch(`/api/updates/${route.params.id}`)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
content: update.value?.content || '',
|
||||||
|
privacy: update.value?.privacy || 'members',
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(update, (val) => {
|
||||||
|
if (val) {
|
||||||
|
form.value.content = val.content || ''
|
||||||
|
form.value.privacy = val.privacy || 'members'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/updates/${route.params.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { content: form.value.content, privacy: form.value.privacy },
|
||||||
|
})
|
||||||
|
toast.add({ title: 'Update saved!', color: 'green' })
|
||||||
|
navigateTo('/member/my-updates')
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Failed to save update',
|
||||||
|
description: err.data?.statusMessage || 'Please try again.',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: 'Edit Update - Ghost Guild' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.back-link {
|
||||||
|
padding: 12px 32px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.back-link a { color: var(--candle); text-decoration: none; }
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
padding: 48px 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
padding: 28px 32px 20px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.form-header h1 {
|
||||||
|
font-family: 'Brygada 1918', serif;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-form {
|
||||||
|
padding: 24px 32px;
|
||||||
|
max-width: 640px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
|
textarea, select {
|
||||||
|
font-family: 'Commit Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 12px;
|
||||||
|
resize: vertical;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
textarea:focus, select:focus { outline: none; border-color: var(--candle); }
|
||||||
|
textarea:disabled, select:disabled { opacity: 0.6; }
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
139
app/pages/updates/new.vue
Normal file
139
app/pages/updates/new.vue
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="back-link">
|
||||||
|
<NuxtLink to="/member/my-updates">← Back to My Updates</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-header">
|
||||||
|
<h1>New Update</h1>
|
||||||
|
<p>Share what you're working on with the community</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="update-form">
|
||||||
|
<div class="field">
|
||||||
|
<label class="section-label">Content</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.content"
|
||||||
|
rows="8"
|
||||||
|
required
|
||||||
|
placeholder="What's on your mind? Share a project update, milestone, or thought..."
|
||||||
|
:disabled="saving"
|
||||||
|
></textarea>
|
||||||
|
<div class="char-count">{{ form.content.length }} / 50000</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="section-label">Visibility</label>
|
||||||
|
<select v-model="form.privacy" :disabled="saving">
|
||||||
|
<option value="members">Members only</option>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="private">Private (only you)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<NuxtLink to="/member/my-updates" class="btn">Cancel</NuxtLink>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="saving || !form.content.trim()"
|
||||||
|
>
|
||||||
|
{{ saving ? 'Posting...' : 'Post Update' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({ middleware: 'auth' })
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
content: '',
|
||||||
|
privacy: 'members',
|
||||||
|
})
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/updates', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { content: form.value.content, privacy: form.value.privacy },
|
||||||
|
})
|
||||||
|
toast.add({ title: 'Update posted!', color: 'green' })
|
||||||
|
navigateTo('/member/my-updates')
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Failed to post update',
|
||||||
|
description: err.data?.statusMessage || 'Please try again.',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: 'New Update - Ghost Guild' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.back-link {
|
||||||
|
padding: 12px 32px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.back-link a { color: var(--candle); text-decoration: none; }
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
padding: 28px 32px 0;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
.form-header h1 {
|
||||||
|
font-family: 'Brygada 1918', serif;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.form-header p { font-size: 12px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
.update-form {
|
||||||
|
padding: 24px 32px;
|
||||||
|
max-width: 640px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
|
textarea, select {
|
||||||
|
font-family: 'Commit Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 12px;
|
||||||
|
resize: vertical;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
textarea:focus, select:focus { outline: none; border-color: var(--candle); }
|
||||||
|
textarea:disabled, select:disabled { opacity: 0.6; }
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
80
app/pages/verify.vue
Normal file
80
app/pages/verify.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<template>
|
||||||
|
<div class="verify-page">
|
||||||
|
<div class="verify-box">
|
||||||
|
<div v-if="state === 'verifying'" class="verify-status">
|
||||||
|
<div class="section-label">Ghost Guild</div>
|
||||||
|
<p>Verifying your login link…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="state === 'error'" class="verify-status">
|
||||||
|
<div class="section-label">Login Failed</div>
|
||||||
|
<p class="error-msg">{{ errorMessage }}</p>
|
||||||
|
<NuxtLink to="/" class="btn btn-primary">Back to home</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({ layout: false })
|
||||||
|
|
||||||
|
const state = ref('verifying')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const hash = window.location.hash.slice(1)
|
||||||
|
|
||||||
|
if (!hash) {
|
||||||
|
state.value = 'error'
|
||||||
|
errorMessage.value = 'No login token found. Please request a new login link.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await $fetch('/api/auth/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { token: hash },
|
||||||
|
})
|
||||||
|
|
||||||
|
await navigateTo(data.redirectUrl, { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
state.value = 'error'
|
||||||
|
const status = err?.response?.status
|
||||||
|
if (status === 401) {
|
||||||
|
errorMessage.value = 'This login link is invalid or has expired. Please request a new one.'
|
||||||
|
} else if (status === 403) {
|
||||||
|
errorMessage.value = err?.data?.statusMessage || 'Your account is not active. Please contact support.'
|
||||||
|
} else {
|
||||||
|
errorMessage.value = 'Something went wrong. Please try again or request a new login link.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.verify-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-box {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
padding: 2rem 2.5rem;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
42
server/api/admin/members/[id].put.js
Normal file
42
server/api/admin/members/[id].put.js
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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}`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
server/api/dev/member-login.get.js
Normal file
41
server/api/dev/member-login.get.js
Normal 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)
|
||||||
|
})
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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`, {
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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}`, {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
34
server/api/members/update-circle.post.js
Normal file
34
server/api/members/update-circle.post.js
Normal 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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'",
|
||||||
|
|
|
||||||
|
|
@ -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'])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue