diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml
index 8edaae1..2a29c40 100644
--- a/.forgejo/workflows/test.yml
+++ b/.forgejo/workflows/test.yml
@@ -21,16 +21,16 @@ jobs:
playwright:
runs-on: ubuntu-latest
needs: vitest
- services:
- mongo:
- image: mongo:7
- ports:
- - 27017:27017
env:
- MONGODB_URI: mongodb://localhost:27017/ghostguild-test
+ MONGODB_URI: mongodb://mongo-ci:27017/ghostguild-test
JWT_SECRET: ci-test-jwt-secret
+ RESEND_API_KEY: re_ci_dummy_not_used
+ HELCIM_API_TOKEN: helcim_ci_dummy_not_used
+ OIDC_COOKIE_SECRET: ci-oidc-cookie-secret-not-secret
NUXT_PUBLIC_COMING_SOON: 'false'
NODE_ENV: development
+ ALLOW_DEV_TEST_ENDPOINTS: 'true'
+ BASE_URL: http://localhost:3000
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -39,15 +39,35 @@ jobs:
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
+ - name: Start MongoDB
+ run: |
+ docker rm -f mongo-ci 2>/dev/null || true
+ docker run -d --name mongo-ci mongo:7
+ # Forgejo runs each job inside its own container; attach Mongo to
+ # that container's network so MONGODB_URI=mongodb://mongo-ci:27017
+ # resolves from inside the runner.
+ RUNNER_NET=$(docker inspect "$HOSTNAME" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' | awk '{print $1}')
+ docker network connect "$RUNNER_NET" mongo-ci
+ docker ps
+ - name: Wait for MongoDB
+ run: timeout 30 sh -c 'until docker exec mongo-ci mongosh --quiet --eval "1" >/dev/null 2>&1; do sleep 1; done'
+ - name: MongoDB log on failure
+ if: failure()
+ run: docker logs mongo-ci || true
+ - name: Seed test data
+ run: node scripts/seed-all.js && node scripts/seed-tags.js
- run: npm run build
- name: Start server
- run: node .output/server/index.mjs &
+ run: node .output/server/index.mjs > /tmp/server.log 2>&1 &
env:
PORT: 3000
- name: Wait for server
run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
- - run: npx playwright test --ignore-snapshots
- - uses: actions/upload-artifact@v4
+ - name: Server log on failure
+ if: failure()
+ run: cat /tmp/server.log || true
+ - run: npx playwright test
+ - uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-report
@@ -68,39 +88,3 @@ jobs:
-H 'Content-type: application/json' \
--data "{\"text\":\":x: *Ghost Guild CI failed* on \`${{ github.ref_name }}\`\nCommit: ${{ github.sha }}\n${{ github.server_url }}/${{ github.repository }}/actions\"}"
- visual:
- runs-on: ubuntu-latest
- needs: vitest
- continue-on-error: true
- services:
- mongo:
- image: mongo:7
- ports:
- - 27017:27017
- env:
- MONGODB_URI: mongodb://localhost:27017/ghostguild-test
- JWT_SECRET: ci-test-jwt-secret
- NUXT_PUBLIC_COMING_SOON: 'false'
- NODE_ENV: development
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 22
- cache: npm
- - run: npm ci
- - run: npx playwright install --with-deps chromium
- - run: npm run build
- - name: Start server
- run: node .output/server/index.mjs &
- env:
- PORT: 3000
- - name: Wait for server
- run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done'
- - run: npx playwright test e2e/visual/
- - uses: actions/upload-artifact@v4
- if: failure()
- with:
- name: visual-diffs
- path: e2e/test-results/
- retention-days: 7
diff --git a/.gitignore b/.gitignore
index 0454ac9..3907ee0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,3 +40,4 @@ e2e/.auth/
.superpowers/
.claude
+scripts/dump-babyghosts-preregistrations.mjs
diff --git a/.serena/project.yml b/.serena/project.yml
index 9d24cb3..ddf640a 100644
--- a/.serena/project.yml
+++ b/.serena/project.yml
@@ -3,15 +3,18 @@ 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
+# al ansible bash clojure cpp
+# cpp_ccls crystal csharp csharp_omnisharp dart
+# elixir elm erlang fortran fsharp
+# go groovy haskell haxe hlsl
+# java json julia kotlin lean4
+# lua luau markdown matlab msl
+# nix ocaml pascal perl php
+# php_phpactor powershell python python_jedi python_ty
+# r rego ruby ruby_solargraph rust
+# scala solidity swift systemverilog 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.)
@@ -65,53 +68,17 @@ 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.
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
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).
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
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.
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
@@ -122,11 +89,14 @@ fixed_tools: []
# 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.
+# list of mode names that are to be activated by default, overriding the setting in the global configuration.
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
+# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
@@ -150,3 +120,8 @@ read_only_memory_patterns: []
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
+
+# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
+added_modes:
diff --git a/Dockerfile b/Dockerfile
index 54b1438..0375bac 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# Build stage
-FROM node:20-alpine AS builder
+FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
@@ -7,8 +7,11 @@ RUN npm ci --ignore-scripts && npx nuxt prepare
COPY . .
RUN npm run build
-# Production stage — only the self-contained .output is needed
-FROM node:20-alpine
+# Production stage — only the self-contained .output is needed.
+# bash + curl are added so Dokploy scheduled tasks (which wrap commands in
+# `bash -c "..."`) can run; alpine ships only ash and has no curl by default.
+FROM node:22-alpine
+RUN apk add --no-cache bash curl
WORKDIR /app
COPY --from=builder /app/.output .output
diff --git a/app/assets/css/main.css b/app/assets/css/main.css
index 4b39e60..9ee189f 100644
--- a/app/assets/css/main.css
+++ b/app/assets/css/main.css
@@ -27,7 +27,10 @@
--text: #2a2015;
--text-bright: #1a1008;
--text-dim: #5a5040;
- --text-faint: #746a58;
+ /* Darkened from #746a58 (4.01:1 on --surface, fails WCAG AA) to #665c4b
+ (4.94:1 on --surface, 5.13:1 on --bg). Stays visually quieter than
+ --text-dim (5.80:1) while meeting AA for small text. */
+ --text-faint: #665c4b;
--parch: #2a2015;
--parch-hover: #3a3025;
--parch-text: #ede4d0;
@@ -273,6 +276,14 @@ p a, blockquote a {
min-width: 0;
}
+/* ---- Nuxt UI placeholder contrast ----
+ Default Nuxt UI placeholder uses `text-dimmed` (#a6a09b) which fails WCAG
+ AA on cream and white backgrounds (≈2.4:1). Override globally to --text-dim
+ so USelect/USelectMenu placeholders meet the 4.5:1 ratio. */
+[data-slot="placeholder"] {
+ color: var(--text-dim);
+}
+
/* ---- SHARED USelectMenu STYLES ----
Apply via:
Classes are global (not scoped) because Nuxt UI portals the popup content to body. */
diff --git a/app/components/BoardPostCard.vue b/app/components/BoardPostCard.vue
index ff6e9d4..a79a535 100644
--- a/app/components/BoardPostCard.vue
+++ b/app/components/BoardPostCard.vue
@@ -158,7 +158,7 @@ const slackLinks = computed(() => {
diff --git a/app/components/EventsMiniSidebar.vue b/app/components/EventsMiniSidebar.vue
index de6066d..df953d6 100644
--- a/app/components/EventsMiniSidebar.vue
+++ b/app/components/EventsMiniSidebar.vue
@@ -104,7 +104,7 @@ const formatDate = (dateStr) => {
}
.em-circle {
- font-size: 9px;
+ font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-top: 2px;
diff --git a/app/components/FilterBar.vue b/app/components/FilterBar.vue
index 688de8e..c63f40e 100644
--- a/app/components/FilterBar.vue
+++ b/app/components/FilterBar.vue
@@ -22,7 +22,7 @@ defineEmits(['update:modelValue'])
diff --git a/app/components/LoginModal.vue b/app/components/LoginModal.vue
index 67bc904..e3fd0b6 100644
--- a/app/components/LoginModal.vue
+++ b/app/components/LoginModal.vue
@@ -40,7 +40,7 @@
type="email"
placeholder="your.email@example.com"
required
- />
+ >
@@ -182,7 +182,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
.modal-overline {
font-family: 'Brygada 1918', serif;
- font-size: 14px;
+ font-size: 13px;
font-weight: 600;
color: var(--candle);
margin-bottom: 12px;
@@ -218,7 +218,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
.info-box {
font-size: 11px;
color: var(--text-faint);
- padding: 10px 14px;
+ padding: 12px 16px;
border: 1px dashed var(--border);
margin-bottom: 16px;
line-height: 1.6;
diff --git a/app/components/NaturalDateInput.vue b/app/components/NaturalDateInput.vue
index c2d1130..4e97e05 100644
--- a/app/components/NaturalDateInput.vue
+++ b/app/components/NaturalDateInput.vue
@@ -18,12 +18,14 @@
@@ -31,7 +33,8 @@
@@ -41,7 +44,8 @@
@@ -51,7 +55,7 @@
-
+
Use traditional date picker
diff --git a/app/components/OnboardingWidget.vue b/app/components/OnboardingWidget.vue
index 97c246d..3f9f11b 100644
--- a/app/components/OnboardingWidget.vue
+++ b/app/components/OnboardingWidget.vue
@@ -118,7 +118,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
display: inline-block;
margin-top: 8px;
padding: 4px 12px;
- border: 1px dashed rgba(237, 228, 208, 0.25);
+ border: 1px dashed color-mix(in srgb, var(--parch-text) 25%, transparent);
color: var(--parch-accent);
font-size: 11px;
text-decoration: none;
@@ -134,7 +134,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
.ow-progress {
margin-top: 10px;
padding-top: 8px;
- border-top: 1px dashed rgba(237, 228, 208, 0.12);
+ border-top: 1px dashed color-mix(in srgb, var(--parch-text) 12%, transparent);
font-size: 11px;
color: var(--parch-text-dim);
display: flex;
@@ -153,7 +153,7 @@ const barEmpty = computed(() => '-'.repeat((4 - completedCount.value) * 2) + ']'
}
.ow-bar-empty {
- color: rgba(237, 228, 208, 0.2);
+ color: color-mix(in srgb, var(--parch-text) 20%, transparent);
}
.ow-skip {
diff --git a/app/components/SeriesPassPurchase.vue b/app/components/SeriesPassPurchase.vue
index 8d3e7f1..fff5fd4 100644
--- a/app/components/SeriesPassPurchase.vue
+++ b/app/components/SeriesPassPurchase.vue
@@ -9,14 +9,11 @@
-
-
+
+
Unable to Load Series Pass
-
{{ error }}
+
{{ error }}
@@ -48,7 +45,7 @@
{{
@@ -103,18 +100,20 @@
-
+
Member Benefit
-
+
This series pass is free for Ghost Guild members!
@@ -144,6 +143,7 @@
By registering, you'll be automatically registered for all
{{ seriesInfo.totalEvents }} events in this series.
+ We'll create a free guest account so you can access your pass.
@@ -182,7 +182,7 @@ const props = defineProps({
const emit = defineEmits(["purchase-success", "purchase-error"]);
const toast = useToast();
-const { initializeTicketPayment, verifyPayment } = useHelcimPay();
+const { initializeSeriesTicketPayment, verifyPayment } = useHelcimPay();
// State
const loading = ref(true);
@@ -264,10 +264,9 @@ const handleSubmit = async () => {
paymentProcessing.value = true;
// Initialize Helcim payment for series pass
- await initializeTicketPayment(
+ await initializeSeriesTicketPayment(
props.seriesId,
form.value.email,
- passInfo.value.ticket.price,
props.seriesInfo.title,
);
@@ -298,12 +297,17 @@ const handleSubmit = async () => {
}
);
+ // Refresh client auth state if server signed us in (guest upgrade)
+ if (purchaseResponse?.signedIn) {
+ await useAuth().checkMemberStatus();
+ }
+
// Show success message
toast.add({
title: "Series Pass Purchased!",
description: `You're now registered for all ${purchaseResponse.registration.eventsRegistered} events in this series.`,
color: "green",
- timeout: 5000,
+ duration: 5000,
});
// Emit success event
@@ -323,7 +327,7 @@ const handleSubmit = async () => {
title: "Purchase Failed",
description: errorMessage,
color: "red",
- timeout: 5000,
+ duration: 5000,
});
emit("purchase-error", errorMessage);
@@ -350,3 +354,18 @@ const formatPrice = (price, currency = "CAD") => {
}).format(price);
};
+
+
diff --git a/app/components/SignupFlowOverlay.vue b/app/components/SignupFlowOverlay.vue
index f29559f..10fe663 100644
--- a/app/components/SignupFlowOverlay.vue
+++ b/app/components/SignupFlowOverlay.vue
@@ -33,14 +33,9 @@
- We've sent a confirmation email to {{ summary?.email }}. Redirecting
- you to your dashboard...
+ Check {{ summary?.email }} for a sign-in link to finish setting up
+ your account. The link expires in 15 minutes.
-
-
- Go to Dashboard Now
-
-
@@ -113,7 +108,7 @@ const stepLabel = computed(() => {
position: fixed;
inset: 0;
z-index: 50;
- background: rgba(42, 32, 21, 0.72);
+ background: color-mix(in srgb, var(--parch) 72%, transparent);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
diff --git a/app/components/TierPicker.vue b/app/components/TierPicker.vue
deleted file mode 100644
index 5cf6318..0000000
--- a/app/components/TierPicker.vue
+++ /dev/null
@@ -1,99 +0,0 @@
-
-
-
- {{ tier.display }}
- {{ tier.subtitle }}
-
-
-
-
-
-
-
diff --git a/app/composables/useHelcim.js b/app/composables/useHelcim.js
deleted file mode 100644
index efc96b1..0000000
--- a/app/composables/useHelcim.js
+++ /dev/null
@@ -1,90 +0,0 @@
-// Helcim API integration composable
-export const useHelcim = () => {
- const config = useRuntimeConfig()
- const helcimToken = config.public.helcimToken
-
- // Base URL for Helcim API
- const HELCIM_API_BASE = 'https://api.helcim.com/v2'
-
- // Helper function to make API requests
- const makeHelcimRequest = async (endpoint, method = 'GET', body = null) => {
- try {
- const response = await $fetch(`${HELCIM_API_BASE}${endpoint}`, {
- method,
- headers: {
- 'accept': 'application/json',
- 'content-type': 'application/json',
- 'api-token': helcimToken
- },
- body: body ? JSON.stringify(body) : undefined
- })
- return response
- } catch (error) {
- console.error('Helcim API error:', error)
- throw error
- }
- }
-
- // Create a customer
- const createCustomer = async (customerData) => {
- return await makeHelcimRequest('/customers', 'POST', {
- customerType: 'PERSON',
- contactName: customerData.name,
- email: customerData.email,
- billingAddress: customerData.billingAddress || {}
- })
- }
-
- // Create a subscription
- const createSubscription = async (customerId, planId, cardToken) => {
- return await makeHelcimRequest('/recurring/subscriptions', 'POST', {
- customerId,
- planId,
- cardToken,
- startDate: new Date().toISOString().split('T')[0] // Today's date
- })
- }
-
- // Get customer details
- const getCustomer = async (customerId) => {
- return await makeHelcimRequest(`/customers/${customerId}`)
- }
-
- // Get subscription details
- const getSubscription = async (subscriptionId) => {
- return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`)
- }
-
- // Update subscription
- const updateSubscription = async (subscriptionId, updates) => {
- return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'PATCH', updates)
- }
-
- // Cancel subscription
- const cancelSubscription = async (subscriptionId) => {
- return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'DELETE')
- }
-
- // Get payment plans
- const getPaymentPlans = async () => {
- return await makeHelcimRequest('/recurring/plans')
- }
-
- // Verify card token (for testing)
- const verifyCardToken = async (cardToken) => {
- return await makeHelcimRequest('/cards/verify', 'POST', {
- cardToken
- })
- }
-
- return {
- createCustomer,
- createSubscription,
- getCustomer,
- getSubscription,
- updateSubscription,
- cancelSubscription,
- getPaymentPlans,
- verifyCardToken
- }
-}
\ No newline at end of file
diff --git a/app/composables/useHelcimPay.js b/app/composables/useHelcimPay.js
index 3703295..5e07a30 100644
--- a/app/composables/useHelcimPay.js
+++ b/app/composables/useHelcimPay.js
@@ -3,7 +3,7 @@ export const useHelcimPay = () => {
let checkoutToken = null;
let secretToken = null;
- // Initialize HelcimPay.js session
+ // Initialize HelcimPay.js session (membership signup flow)
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
try {
const response = await $fetch("/api/helcim/initialize-payment", {
@@ -12,6 +12,7 @@ export const useHelcimPay = () => {
customerId,
customerCode,
amount,
+ metadata: { type: "membership_signup" },
},
});
@@ -28,26 +29,14 @@ export const useHelcimPay = () => {
}
};
- // Initialize payment for event ticket purchase
- const initializeTicketPayment = async (
- eventId,
- email,
- amount,
- eventTitle = null,
- ) => {
+ const _initializeTicket = async (metadata, errorPrefix) => {
try {
const response = await $fetch("/api/helcim/initialize-payment", {
method: "POST",
body: {
customerId: null,
- customerCode: email, // Use email as customer code for event tickets
- amount,
- metadata: {
- type: "event_ticket",
- eventId,
- email,
- eventTitle,
- },
+ customerCode: metadata.email,
+ metadata,
},
});
@@ -57,16 +46,29 @@ export const useHelcimPay = () => {
return {
success: true,
checkoutToken: response.checkoutToken,
+ amount: response.amount,
};
}
- throw new Error("Failed to initialize ticket payment session");
+ throw new Error(`Failed to initialize ${errorPrefix} session`);
} catch (error) {
- console.error("Ticket payment initialization error:", error);
+ console.error(`${errorPrefix} initialization error:`, error);
throw error;
}
};
+ const initializeTicketPayment = (eventId, email, eventTitle = null) =>
+ _initializeTicket(
+ { type: "event_ticket", eventId, email, eventTitle },
+ "ticket payment",
+ );
+
+ const initializeSeriesTicketPayment = (seriesId, email, seriesTitle = null) =>
+ _initializeTicket(
+ { type: "series_ticket", seriesId, email, eventTitle: seriesTitle },
+ "series payment",
+ );
+
// Show payment modal
const showPaymentModal = () => {
return new Promise((resolve, reject) => {
@@ -139,6 +141,7 @@ export const useHelcimPay = () => {
if (typeof window.appendHelcimPayIframe === "function") {
// Set up event listener for HelcimPay.js responses
const helcimPayJsIdentifierKey = "helcim-pay-js-" + checkoutToken;
+ let observerTimer, paymentTimer;
const handleHelcimPayEvent = (event) => {
console.log("Received window message:", event.data);
@@ -148,6 +151,8 @@ export const useHelcimPay = () => {
// Remove event listener to prevent multiple responses
window.removeEventListener("message", handleHelcimPayEvent);
+ clearTimeout(observerTimer);
+ clearTimeout(paymentTimer);
// Close the Helcim modal
if (typeof window.removeHelcimPayIframe === "function") {
@@ -237,10 +242,10 @@ export const useHelcimPay = () => {
);
// Clean up observer after a timeout
- setTimeout(() => observer.disconnect(), 5000);
+ observerTimer = setTimeout(() => observer.disconnect(), 5000);
// Add timeout to clean up if no response (10 minutes for manual card entry)
- setTimeout(() => {
+ paymentTimer = setTimeout(() => {
console.log("Payment timeout reached, cleaning up event listener...");
window.removeEventListener("message", handleHelcimPayEvent);
reject(new Error("Payment timeout - no response received"));
@@ -272,6 +277,7 @@ export const useHelcimPay = () => {
return {
initializeHelcimPay,
initializeTicketPayment,
+ initializeSeriesTicketPayment,
verifyPayment,
cleanup,
};
diff --git a/app/composables/useMemberPayment.js b/app/composables/useMemberPayment.js
index 0064b71..fcab6fe 100644
--- a/app/composables/useMemberPayment.js
+++ b/app/composables/useMemberPayment.js
@@ -25,45 +25,81 @@ export const useMemberPayment = () => {
paymentSuccess.value = false
try {
- // Step 1: Get or create Helcim customer
- await getOrCreateCustomer()
-
- // Step 2: Initialize Helcim payment with $0 for card verification
- await initializeHelcimPay(
- customerId.value,
- customerCode.value,
- 0,
+ // Fast-path: when both Helcim ids are already cached on the member doc
+ // AND a card's on file, we can skip the paid getOrCreateCustomer round
+ // trip entirely and go straight to subscription creation.
+ const hasCachedHelcimIds = Boolean(
+ memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode
)
- // Step 3: Show payment modal and get payment result
- const paymentResult = await verifyPayment()
- console.log('Payment result:', paymentResult)
+ let existing = null
+ let probedExistingCard = false
+ let cardToken = null
- if (!paymentResult.success) {
- throw new Error('Payment verification failed')
+ if (hasCachedHelcimIds) {
+ existing = await $fetch('/api/helcim/existing-card').catch((err) => {
+ console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
+ return null
+ })
+ probedExistingCard = true
+ if (existing?.cardToken) {
+ customerId.value = memberData.value.helcimCustomerId
+ customerCode.value = memberData.value.helcimCustomerCode
+ cardToken = existing.cardToken
+ }
}
- // Step 4: Verify payment on backend
- const verifyResult = await $fetch('/api/helcim/verify-payment', {
- method: 'POST',
- body: {
- cardToken: paymentResult.cardToken,
- customerId: customerId.value,
- },
- })
+ if (!cardToken) {
+ // Skip HelcimPay verify if a card's already on file — Helcim refuses
+ // to re-save it, breaking retries after a partial-failed signup.
+ const [, existingFromFull] = await Promise.all([
+ getOrCreateCustomer(),
+ probedExistingCard
+ ? Promise.resolve(existing)
+ : $fetch('/api/helcim/existing-card').catch((err) => {
+ console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
+ return null
+ }),
+ ])
- if (!verifyResult.success) {
- throw new Error('Payment verification failed on backend')
+ cardToken = existingFromFull?.cardToken || null
+ }
+
+ if (!cardToken) {
+ await initializeHelcimPay(
+ customerId.value,
+ customerCode.value,
+ 0,
+ )
+
+ const paymentResult = await verifyPayment()
+
+ if (!paymentResult.success) {
+ throw new Error('Payment verification failed')
+ }
+
+ const verifyResult = await $fetch('/api/helcim/verify-payment', {
+ method: 'POST',
+ body: {
+ cardToken: paymentResult.cardToken,
+ customerId: customerId.value,
+ },
+ })
+
+ if (!verifyResult.success) {
+ throw new Error('Payment verification failed on backend')
+ }
+
+ cardToken = paymentResult.cardToken
}
- // Step 5: Create subscription with proper contribution tier
const subscriptionResponse = await $fetch('/api/helcim/subscription', {
method: 'POST',
body: {
customerId: customerId.value,
customerCode: customerCode.value,
contributionAmount: memberData.value?.contributionAmount ?? 5,
- cardToken: paymentResult.cardToken,
+ cardToken,
},
})
@@ -71,7 +107,6 @@ export const useMemberPayment = () => {
throw new Error('Subscription creation failed')
}
- // Step 6: Payment successful - refresh member data
paymentSuccess.value = true
await checkMemberStatus()
diff --git a/app/config/memberStatus.js b/app/config/memberStatus.js
new file mode 100644
index 0000000..04850e8
--- /dev/null
+++ b/app/config/memberStatus.js
@@ -0,0 +1,8 @@
+export const STATUS_LABELS = {
+ active: "Active",
+ pending_payment: "Payment setup incomplete",
+ suspended: "Paused",
+ cancelled: "Closed",
+};
+
+export const statusLabel = (s) => STATUS_LABELS[s] || "Pending";
diff --git a/app/middleware/coming-soon.global.js b/app/middleware/coming-soon.global.js
index c1ee747..d2433f8 100644
--- a/app/middleware/coming-soon.global.js
+++ b/app/middleware/coming-soon.global.js
@@ -21,6 +21,15 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
return;
}
+ // Logged-in admins bypass coming-soon (and see the public site + their dashboard)
+ try {
+ const headers = import.meta.server ? useRequestHeaders(["cookie"]) : undefined;
+ const member = await $fetch("/api/auth/member", { headers });
+ if (member?.role === "admin") return;
+ } catch {
+ // Not authenticated — fall through to redirect
+ }
+
// Redirect all other routes to coming-soon
return navigateTo("/coming-soon");
});
diff --git a/app/pages/about.vue b/app/pages/about.vue
index a423811..5920e9b 100644
--- a/app/pages/about.vue
+++ b/app/pages/about.vue
@@ -38,16 +38,16 @@
The Circles
-
Founder
+
Founder
For people actively building cooperatives.
-
Practitioner
+
Practitioner
For experienced practitioners sharing what they know.
diff --git a/app/pages/admin/events/index.vue b/app/pages/admin/events/index.vue
index 051e4c8..0cafd01 100644
--- a/app/pages/admin/events/index.vue
+++ b/app/pages/admin/events/index.vue
@@ -570,7 +570,7 @@ tbody td {
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--c-founder);
- border: 1px dashed rgba(138, 68, 32, 0.3);
+ border: 1px dashed color-mix(in srgb, var(--ember) 30%, transparent);
padding: 2px 8px;
}
@@ -583,7 +583,7 @@ tbody td {
font-size: 10px;
font-weight: 600;
color: var(--c-founder);
- border: 1px dashed rgba(138, 68, 32, 0.4);
+ border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
border-radius: 50%;
}
@@ -632,12 +632,12 @@ tbody td {
.status-upcoming {
color: var(--candle);
- border-color: rgba(122, 90, 16, 0.3);
+ border-color: color-mix(in srgb, var(--candle) 30%, transparent);
}
.status-ongoing {
color: var(--green);
- border-color: rgba(74, 106, 56, 0.3);
+ border-color: color-mix(in srgb, var(--green) 30%, transparent);
}
.status-past {
@@ -647,7 +647,7 @@ tbody td {
.status-cancelled {
color: var(--ember);
- border-color: rgba(138, 68, 32, 0.3);
+ border-color: color-mix(in srgb, var(--ember) 30%, transparent);
margin-top: 4px;
}
diff --git a/app/pages/admin/index.vue b/app/pages/admin/index.vue
index e117a8f..289a813 100644
--- a/app/pages/admin/index.vue
+++ b/app/pages/admin/index.vue
@@ -65,7 +65,7 @@
{{ member.email }}
- {{ member.circle }}
+
{{ formatDate(member.createdAt) }}
diff --git a/app/pages/admin/members/[id].vue b/app/pages/admin/members/[id].vue
index 4d8be5d..e082be7 100644
--- a/app/pages/admin/members/[id].vue
+++ b/app/pages/admin/members/[id].vue
@@ -16,7 +16,7 @@
{{ member.email }}
@@ -39,11 +39,11 @@