diff --git a/.env.example b/.env.example index 2fefd06..8a6cadf 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ MONGODB_URI=mongodb://localhost:27017/ghostguild # HELCIM_API_TOKEN=your-live-helcim-api-token HELCIM_API_TOKEN=your-test-helcim-api-token NUXT_PUBLIC_HELCIM_ACCOUNT_ID=your-helcim-account-id +NUXT_HELCIM_MONTHLY_PLAN_ID= +NUXT_HELCIM_ANNUAL_PLAN_ID= # Email Configuration (Resend) RESEND_API_KEY=your-resend-api-key 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 5ca2fab..3907ee0 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ logs !.env.example scripts/*.js +# Migration backup files +.migration-backup-*.json + # Playwright e2e/test-results/ playwright-report/ @@ -35,3 +38,6 @@ e2e/.auth/ .worktrees/ .claude/worktrees/ .superpowers/ + +.claude +scripts/dump-babyghosts-preregistrations.mjs diff --git a/.husky/pre-push b/.husky/pre-push old mode 100644 new mode 100755 diff --git a/.serena/project.yml b/.serena/project.yml index 9d24cb3..0d43951 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -3,21 +3,26 @@ 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 angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html 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 scss 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.) # Note: # - For C, use cpp # - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: # Some languages require additional setup/installations. @@ -65,53 +70,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 +91,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 +122,19 @@ 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: + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] 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 4f1ab74..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,98 @@ 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. */ +button.zine-select, +button.timezone-select { + display: flex !important; + width: 100%; + padding: 5px 8px !important; + font-family: "Commit Mono", monospace !important; + font-size: 13px !important; + color: var(--text-bright) !important; + background: var(--input-bg) !important; + border: 1px solid var(--border) !important; + border-radius: 0 !important; + box-shadow: none !important; + outline: none !important; + min-height: 0; + --tw-ring-shadow: 0 0 #0000; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-color: transparent; +} + +button.zine-select:hover, +button.timezone-select:hover { + background: var(--input-bg) !important; +} + +button.zine-select:focus, +button.zine-select:focus-visible, +button.zine-select[aria-expanded="true"], +button.timezone-select:focus, +button.timezone-select:focus-visible, +button.timezone-select[aria-expanded="true"] { + border-color: var(--candle) !important; +} + +.tz-content { + background: var(--input-bg) !important; + border: 1px solid var(--border) !important; + border-radius: 0 !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important; + --tw-ring-shadow: 0 0 #0000 !important; + --tw-ring-offset-shadow: 0 0 #0000 !important; + font-family: "Commit Mono", monospace !important; +} + +.tz-input { + border-bottom: 1px dashed var(--border) !important; +} + +.tz-input input { + font-family: "Commit Mono", monospace !important; + font-size: 13px !important; + color: var(--text-bright) !important; + background: transparent !important; + border-radius: 0 !important; + padding: 6px 8px !important; + box-shadow: none !important; + --tw-ring-shadow: 0 0 #0000 !important; + --tw-ring-offset-shadow: 0 0 #0000 !important; +} + +.tz-item { + font-family: "Commit Mono", monospace !important; + font-size: 13px !important; + color: var(--text) !important; + border-radius: 0 !important; + padding: 6px 8px !important; +} + +.tz-item::before { + border-radius: 0 !important; +} + +.tz-item[data-highlighted]::before, +.tz-item[data-highlighted]:not([data-disabled])::before { + background: var(--surface-hover) !important; +} + +.tz-item[data-highlighted], +.tz-item[data-highlighted]:not([data-disabled]) { + color: var(--text-bright) !important; +} + /* ---- MOBILE ---- */ @media (max-width: 1023px) { body { diff --git a/app/components/AppNavigation.vue b/app/components/AppNavigation.vue index 1add2ef..07cc322 100644 --- a/app/components/AppNavigation.vue +++ b/app/components/AppNavigation.vue @@ -25,11 +25,6 @@ /> -
  • - Sign out -
  • @@ -138,11 +133,11 @@ +
    +
    +

    + You'll be charged ${{ firstCharge }} today (${{ form.contributionAmount }}/month × 12). +

    +

    + Then ${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}, until you cancel. +

    +
    +
    +
    @@ -169,43 +223,28 @@ - -
    -

    Payment Information

    -

    - You're signing up for ${{ form.contributionTier }} CAD / month. -

    - -
    {{ errorMessage }}
    - - -

    Click "Complete Payment" below to open the secure payment modal and verify your payment method.

    -
    - -
    - - -
    -
    - - -
    -

    Welcome to Ghost Guild!

    -

    Your membership is active. Redirecting to your dashboard...

    - Go to Dashboard -
    + + + + diff --git a/app/pages/admin/wiki.vue b/app/pages/admin/wiki.vue index 9650bb3..f6c5cab 100644 --- a/app/pages/admin/wiki.vue +++ b/app/pages/admin/wiki.vue @@ -954,8 +954,8 @@ const applyBatchVisibility = async (hidden) => { } .sync-created { - color: var(--green, #4a7); - border-color: var(--green, #4a7); + color: var(--green); + border-color: var(--green); } .sync-updated { diff --git a/app/pages/auth/logout-confirm.vue b/app/pages/auth/logout-confirm.vue index 3061387..0611b11 100644 --- a/app/pages/auth/logout-confirm.vue +++ b/app/pages/auth/logout-confirm.vue @@ -82,7 +82,7 @@ if (import.meta.server && !xsrf.value) { .auth-title { font-family: var(--font-display); font-size: 28px; - font-weight: 700; + font-weight: 600; line-height: 1.1; letter-spacing: -0.01em; color: var(--candle); diff --git a/app/pages/auth/logout-success.vue b/app/pages/auth/logout-success.vue index 28e6fce..ea00d18 100644 --- a/app/pages/auth/logout-success.vue +++ b/app/pages/auth/logout-success.vue @@ -46,7 +46,7 @@ useHead({ title: "Signed Out — Ghost Guild" }); .auth-title { font-family: var(--font-display); font-size: 28px; - font-weight: 700; + font-weight: 600; line-height: 1.1; letter-spacing: -0.01em; color: var(--candle); diff --git a/app/pages/auth/oidc-error.vue b/app/pages/auth/oidc-error.vue index 9a060c4..0a86bfb 100644 --- a/app/pages/auth/oidc-error.vue +++ b/app/pages/auth/oidc-error.vue @@ -70,7 +70,7 @@ const hasDetail = computed( .auth-title { font-family: var(--font-display); font-size: 28px; - font-weight: 700; + font-weight: 600; line-height: 1.1; letter-spacing: -0.01em; color: var(--candle); @@ -97,7 +97,7 @@ const hasDetail = computed( .auth-detail-code { color: var(--ember); - font-weight: 700; + font-weight: 600; margin: 0 0 4px; } diff --git a/app/pages/auth/wiki-login.vue b/app/pages/auth/wiki-login.vue index a135fa2..f7e0d01 100644 --- a/app/pages/auth/wiki-login.vue +++ b/app/pages/auth/wiki-login.vue @@ -8,6 +8,7 @@ const uid = route.query.uid as string; const email = ref(""); const sent = ref(false); +const notRegistered = ref(false); const loading = ref(false); const error = ref(""); @@ -15,13 +16,21 @@ async function sendMagicLink() { if (!email.value || !uid) return; loading.value = true; error.value = ""; + notRegistered.value = false; try { - await $fetch("/oidc/interaction/login", { - method: "POST", - body: { email: email.value, uid }, - }); - sent.value = true; + const response = await $fetch<{ success: boolean; registered: boolean }>( + "/oidc/interaction/login", + { + method: "POST", + body: { email: email.value, uid }, + } + ); + if (response.registered === false) { + notRegistered.value = true; + } else { + sent.value = true; + } } catch (e: any) { error.value = e?.data?.statusMessage || "Something went wrong. Please try again."; @@ -29,6 +38,12 @@ async function sendMagicLink() { loading.value = false; } } + +function resetForm() { + sent.value = false; + notRegistered.value = false; + email.value = ""; +} @@ -71,8 +88,8 @@

    {{ series.title }}

    @@ -90,6 +107,11 @@ >
    + @@ -111,9 +133,8 @@ const filterOptions = [ const { data: eventsData } = await useFetch("/api/events"); const { data: seriesData } = await useFetch("/api/series"); -const now = new Date(); - const filteredEvents = computed(() => { + const now = new Date(); if (!eventsData.value) return []; return eventsData.value.filter((event) => { if (!includePastEvents.value && new Date(event.startDate) < now) @@ -131,18 +152,24 @@ const activeSeries = computed(() => { ); }); -const formatDate = (dateStr) => { - if (!dateStr) return ""; - const d = new Date(dateStr); - const opts = { month: "short", day: "numeric" }; - if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric"; +const formatDate = (event) => { + if (!event?.startDate) return ""; + const tz = event.displayTimezone || "America/Toronto"; + const d = new Date(event.startDate); + const opts = { month: "short", day: "numeric", timeZone: tz }; + const dYear = d.toLocaleDateString("en-US", { year: "numeric", timeZone: tz }); + const nowYear = new Date().toLocaleDateString("en-US", { year: "numeric", timeZone: tz }); + if (dYear !== nowYear) opts.year = "numeric"; return d.toLocaleDateString("en-US", opts); }; -const formatTime = (dateStr) => { - if (!dateStr) return ""; - const d = new Date(dateStr); - return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); +const formatTime = (event) => { + if (!event?.startDate) return ""; + return new Date(event.startDate).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + timeZone: event.displayTimezone || "America/Toronto", + }); }; const formatLocation = (event) => { @@ -154,9 +181,15 @@ const formatLocation = (event) => { return event.location; }; +const isSoldOut = (event) => { + if (!event.maxAttendees) return false; + return (event.registeredCount || 0) >= event.maxAttendees; +}; + const isAlmostFull = (event) => { if (!event.maxAttendees) return false; - return (event.registeredCount || 0) / event.maxAttendees > 0.8; + if (isSoldOut(event)) return false; + return (event.registeredCount || 0) / event.maxAttendees >= 0.8; }; @@ -205,8 +238,12 @@ const isAlmostFull = (event) => { .event-row:hover { padding-left: 4px; } -.event-row.is-cancelled { - opacity: 0.5; +.event-row.is-cancelled .event-title a { + text-decoration: line-through; + text-decoration-thickness: 1px; +} +.event-row.is-cancelled .event-tagline { + text-decoration: line-through; } .event-date-col { @@ -289,10 +326,29 @@ const isAlmostFull = (event) => { color: var(--text-faint); white-space: nowrap; padding-top: 2px; + display: inline-flex; + align-items: center; + gap: 6px; } .seats-warn { color: var(--ember); } +.capacity-badge { + font-size: 9px; + letter-spacing: 0.07em; + text-transform: uppercase; + padding: 1px 5px; + border: 1px dashed currentColor; + line-height: 1.5; + white-space: nowrap; +} +.capacity-badge.limited { + color: var(--ember); +} +.capacity-badge.sold-out { + color: var(--text-faint); + border-style: solid; +} .event-badges { display: flex; @@ -326,14 +382,21 @@ const isAlmostFull = (event) => { } .series-box { padding: 20px 24px; - border-right: 1px dashed var(--border); text-decoration: none; transition: background 0.15s; + border-right: 1px dashed var(--border); + border-bottom: 1px dashed var(--border); } -.series-box:last-child { +.series-box:nth-child(2n) { border-right: none; } -.series-box:hover { +.series-box:nth-last-child(-n + 2) { + border-bottom: none; +} +.series-box-filler { + pointer-events: none; +} +.series-box:not(.series-box-filler):hover { background: var(--surface-hover); } .series-box h2 { @@ -358,17 +421,47 @@ const isAlmostFull = (event) => { } -.filter-toggle { - display: flex; +.past-toggle { + display: inline-flex; align-items: center; - gap: 6px; + gap: 8px; margin-left: auto; + font-family: "Commit Mono", monospace; font-size: 11px; + letter-spacing: 0.04em; color: var(--text-faint); + background: transparent; + border: 1px dashed var(--border); + padding: 4px 10px; cursor: pointer; + transition: all 0.15s; } -.filter-toggle input { - accent-color: var(--candle-dim); +.past-toggle:hover { + border-color: var(--candle-faint); + color: var(--text-dim); +} +.past-toggle:focus-visible { + outline: 2px dashed var(--candle); + outline-offset: 3px; +} +.past-toggle.active { + border-color: var(--candle); + border-style: solid; + color: var(--candle); +} +.past-toggle-box { + display: inline-flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + border: 1px solid currentColor; + flex-shrink: 0; +} +.past-toggle-check { + font-size: 12px; + line-height: 1; + color: var(--candle); } .empty { @@ -399,8 +492,17 @@ const isAlmostFull = (event) => { border-right: none; border-bottom: 1px dashed var(--border); } + .series-box:nth-child(2n) { + border-right: none; + } + .series-box:nth-last-child(-n + 2) { + border-bottom: 1px dashed var(--border); + } .series-box:last-child { border-bottom: none; } + .series-box-filler { + display: none; + } } diff --git a/app/pages/index.vue b/app/pages/index.vue index e00cf5b..a675749 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -42,7 +42,7 @@
    - {{ formatDate(event.startDate) }} + {{ formatDate(event) }} {{ event.title @@ -87,18 +87,24 @@ > From the Wiki
    -

    What is a cooperative studio?

    -

    - A cooperative studio is a game development company owned and governed by - the people who work there. Decisions are made collectively. Profits are - shared according to contribution, not ownership stake. -

    -

    - The games industry is full of stories about crunch, layoffs, and studios - that extract value from workers. Cooperatives are one alternative — not - the only one, but one worth - practicing together. -

    + +

    Read more in the wiki →

    @@ -121,6 +127,23 @@ const { data: wikiArticles } = await useFetch("/api/wiki/recent", { default: () => [], }); +const DEFAULT_WIKI_FEATURE_TITLE = "What is a cooperative studio?"; + +const { data: wikiFeature } = await useFetch( + "/api/site-content/homepage.wiki_feature", + { default: () => ({ title: "", body: "" }) }, +); + +const hasCustomWikiFeature = computed(() => !!wikiFeature.value?.body?.trim()); + +const customWikiParagraphs = computed(() => { + const body = wikiFeature.value?.body?.trim() || ""; + return body + .split(/\n{2,}/) + .map((p) => p.trim()) + .filter(Boolean); +}); + const circleData = [ { value: "community", @@ -141,14 +164,17 @@ const circleData = [ label: "Practitioner", metaphor: "The alcove", blurb: - "Where experience is shared and knowledge given back. You're here to teach, advise, mentor, and help shape the program itself. Alumni welcome.", + "Where experience is shared and knowledge given back. You're here to support newcomers, help shape the Cooperative Foundations program, and find peers.", }, ]; -const formatDate = (dateStr) => { - if (!dateStr) return ""; - const d = new Date(dateStr); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +const formatDate = (event) => { + if (!event?.startDate) return ""; + return new Date(event.startDate).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + timeZone: event.displayTimezone || "America/Toronto", + }); }; diff --git a/app/pages/join.vue b/app/pages/join.vue index e1996c5..67bc0d6 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -32,7 +32,7 @@
    - ${{ memberData?.contributionTier || "0" }} CAD/month + ${{ memberData?.contributionAmount ?? 0 }} CAD/month
    @@ -59,86 +59,43 @@ + + +
    @@ -388,9 +386,9 @@ import { reactive, ref, computed, onMounted, onUnmounted } from "vue"; import { getCircleOptions } from "~/config/circles"; import { - getContributionOptions, requiresPayment, - getContributionTierByValue, + CONTRIBUTION_PRESETS, + getGuidanceLabel, } from "~/config/contributions"; // Auth state @@ -406,7 +404,8 @@ const form = reactive({ email: "", name: "", circle: "community", - contributionTier: "15", + contributionAmount: 15, + agreedToGuidelines: false, billingAddress: { street: "", city: "", @@ -418,9 +417,17 @@ const form = reactive({ // UI state const isSubmitting = ref(false); -const currentStep = ref(1); // 1: Info, 2: Billing (paid only), 3: Payment, 4: Confirmation const errorMessage = ref(""); const successMessage = ref(""); +const cadence = ref("monthly"); // 'monthly' | 'annual' + +// Flow overlay state — drives the post-submit full-viewport UI. +// 'idle' = overlay hidden; user is editing the form. +// 'creating-customer' | 'opening-payment' | 'processing-payment' +// | 'creating-subscription' = progress states, overlay shows a spinner + label. +// 'success' = overlay shows confirmation, auto-redirect is queued. +// 'error' = overlay shows error + Retry/Back buttons. +const flowState = ref("idle"); // Helcim state const customerId = ref(null); @@ -431,8 +438,12 @@ const paymentToken = ref(null); // Circle options from central config const circleOptions = getCircleOptions(); -// Contribution options from central config -const contributionOptions = getContributionOptions(); +const formatContributionAmount = (amount) => { + if (!amount || amount === 0) return "$0"; + const display = cadence.value === "annual" ? amount * 12 : amount; + const suffix = cadence.value === "annual" ? "/yr" : "/mo"; + return `$${display}${suffix}`; +}; // Initialize composables const { @@ -443,115 +454,108 @@ const { // Form validation const isFormValid = computed(() => { - return form.name && form.email && form.circle && form.contributionTier; + return ( + form.name && + form.email && + form.circle && + Number.isInteger(form.contributionAmount) && + form.contributionAmount >= 0 && + form.agreedToGuidelines + ); }); // Check if payment is required const needsPayment = computed(() => { - return requiresPayment(form.contributionTier); + return requiresPayment(form.contributionAmount); }); -// Get selected tier info -const selectedTier = computed(() => { - return getContributionTierByValue(form.contributionTier); +const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount)); + +const firstCharge = computed(() => { + const amount = form.contributionAmount || 0; + return cadence.value === "annual" ? amount * 12 : amount; }); -// Step 1: Create customer +const flowSummary = computed(() => ({ + name: form.name, + email: form.email, + circle: form.circle, + contribution: formatContributionAmount(form.contributionAmount), +})); + const handleSubmit = async () => { if (isSubmitting.value || !isFormValid.value) return; isSubmitting.value = true; errorMessage.value = ""; + flowState.value = "creating-customer"; try { - // Create customer in Helcim + // Create customer const response = await $fetch("/api/helcim/customer", { method: "POST", body: { name: form.name, email: form.email, circle: form.circle, - contributionTier: form.contributionTier, + contributionAmount: form.contributionAmount, + agreedToGuidelines: form.agreedToGuidelines, billingAddress: form.billingAddress, }, }); - if (response.success) { - customerId.value = response.customerId; - customerCode.value = response.customerCode; - - // Token is now set as httpOnly cookie by the server - // No need to manually set cookie on client side - - // Move to next step - if (needsPayment.value) { - currentStep.value = 2; - // Initialize HelcimPay.js session for card verification - await initializeHelcimPay(customerId.value, customerCode.value, 0); - } else { - // For free tier, create subscription directly - await createSubscription(); - // Check member status to ensure user is properly authenticated - await checkMemberStatus(); - - // Automatically redirect to welcome page after a short delay - setTimeout(() => { - navigateTo("/welcome"); - }, 3000); // 3 second delay to show success message - } + if (!response.success) { + throw new Error("Failed to create account."); } - } catch (error) { - console.error("Error creating customer:", error); - errorMessage.value = - error.data?.message || "Failed to create account. Please try again."; - } finally { - isSubmitting.value = false; - } -}; -// Step 2: Process payment -const processPayment = async () => { - if (isSubmitting.value) return; + customerId.value = response.customerId; + customerCode.value = response.customerCode; - isSubmitting.value = true; - errorMessage.value = ""; + // Free tier: no Helcim modal, go straight to subscription. + if (!needsPayment.value) { + flowState.value = "creating-subscription"; + await createSubscription(); + return; + } + + // Paid tier: initialize HelcimPay session, then auto-open modal. + flowState.value = "opening-payment"; + await initializeHelcimPay(customerId.value, customerCode.value, 0); - try { - // Verify payment through HelcimPay.js const paymentResult = await verifyPayment(); + if (!paymentResult?.success) { + throw new Error("Payment was not completed."); + } + paymentToken.value = paymentResult.cardToken; - if (paymentResult.success) { - paymentToken.value = paymentResult.cardToken; + flowState.value = "processing-payment"; + await $fetch("/api/helcim/verify-payment", { + method: "POST", + body: { + cardToken: paymentResult.cardToken, + customerId: customerId.value, + }, + }); - // Verify payment on server - const verifyResult = await $fetch("/api/helcim/verify-payment", { - method: "POST", - body: { - cardToken: paymentResult.cardToken, - customerId: customerId.value, - }, - }); + flowState.value = "creating-subscription"; + const subscriptionResult = await createSubscription( + paymentResult.cardToken, + ); - // Create subscription (don't let subscription errors prevent form progression) - const subscriptionResult = await createSubscription( - paymentResult.cardToken, - ); - - if (!subscriptionResult || !subscriptionResult.success) { - console.warn( - "Subscription creation failed but payment succeeded:", - subscriptionResult?.error, - ); - // Still progress to success page since payment worked - currentStep.value = 3; - successMessage.value = - "Payment successful! Subscription setup may need manual completion."; - } + if (!subscriptionResult || subscriptionResult.success === false) { + // Payment succeeded but subscription couldn't be created. + // Keep overlay in success state; admin follow-up will reconcile. + successMessage.value = + "Payment successful. Subscription setup may need manual completion."; + flowState.value = "success"; } } catch (error) { - console.error("Payment process error:", error); + console.error("Join flow error:", error); errorMessage.value = - error.message || "Payment verification failed. Please try again."; + error.data?.message || + error.message || + "Something went wrong. Please try again."; + flowState.value = "error"; } finally { isSubmitting.value = false; } @@ -565,23 +569,20 @@ const createSubscription = async (cardToken = null) => { body: { customerId: customerId.value, customerCode: customerCode.value, - contributionTier: form.contributionTier, + contributionAmount: form.contributionAmount, + cadence: cadence.value, cardToken: cardToken, }, }); if (response.success) { subscriptionData.value = response.subscription; - currentStep.value = 3; + flowState.value = "success"; successMessage.value = "Your membership is active."; - // Check member status to ensure user is properly authenticated - await checkMemberStatus(); - - // Automatically redirect to welcome page after a short delay - setTimeout(() => { - navigateTo("/welcome"); - }, 3000); // 3 second delay to show success message + // Sign-in cookie is now issued by the email-verify magic link + // (see /api/helcim/customer). Don't auto-navigate to a gated page — + // the success state instructs the user to check their inbox. } else { throw new Error("Subscription creation failed - response not successful"); } @@ -605,27 +606,9 @@ const createSubscription = async (cardToken = null) => { } }; -// Go back to previous step -const goBack = () => { - if (currentStep.value > 1) { - currentStep.value--; - errorMessage.value = ""; - } -}; - -// Reset form -const resetForm = () => { - currentStep.value = 1; - customerId.value = null; - customerCode.value = null; - subscriptionData.value = null; - paymentToken.value = null; +const closeFlowOverlay = () => { + flowState.value = "idle"; errorMessage.value = ""; - successMessage.value = ""; - form.email = ""; - form.name = ""; - form.circle = "community"; - form.contributionTier = "15"; }; // Cleanup on unmount @@ -671,11 +654,12 @@ onUnmounted(() => { position: relative; } :deep(.parchment-inset ul li::before) { - content: "--"; + content: "›"; position: absolute; left: 0; - color: var(--candle-dim); - opacity: 0.5; + color: var(--candle-faint); + font-size: 14px; + line-height: 1.4; } .parchment-link { @@ -767,7 +751,7 @@ onUnmounted(() => { padding: 0; } .tier-list li { - padding: 5px 0; + padding: 4px 0; font-size: 12px; color: var(--text-dim); border-bottom: 1px dashed var(--border); @@ -789,6 +773,13 @@ onUnmounted(() => { margin-top: 16px; } +.charity-note { + font-size: 12px; + color: var(--text-dim); + line-height: 1.65; + margin-top: 16px; +} + /* ---- FORM SECTION ---- */ .form-section { padding: 32px; @@ -843,6 +834,79 @@ onUnmounted(() => { color: var(--text-faint); } +/* ---- CADENCE RADIOS ---- */ +.cadence-radios { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} + +/* ---- CONTRIBUTION AMOUNT INPUT + CHIPS ---- */ +.contribution-input-row { + display: flex; + align-items: center; + gap: 0.25rem; +} +.contribution-currency { + font-weight: 600; +} +.contribution-input { + flex: 1; + padding: 0.5rem 0.75rem; + background: var(--input-bg); + border: 1px solid var(--parch); + font-family: "Commit Mono", monospace; + font-size: 1rem; +} +.contribution-input:focus { + outline: none; + border-color: var(--candle); +} +.contribution-presets { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} +.contribution-preset-chip { + padding: 0.25rem 0.75rem; + background: transparent; + border: 1px dashed var(--parch); + font-family: "Commit Mono", monospace; + font-size: 0.875rem; + cursor: pointer; +} +.contribution-preset-chip:hover { + border-style: solid; + border-color: var(--candle); +} +.contribution-guidance { + margin-top: 0.5rem; + font-size: 0.875rem; + font-style: italic; + color: var(--ink-soft, currentColor); +} + +/* ---- BILLING SUMMARY ---- */ +.billing-summary { + padding: 12px 16px; + border: 1px dashed var(--border); + background: var(--surface); +} +.billing-summary-line { + font-size: 13px; + color: var(--text); + line-height: 1.5; + margin: 0; +} +.billing-summary-line + .billing-summary-line { + margin-top: 4px; +} +.billing-summary-line strong { + color: var(--text-bright); + font-weight: 600; +} + /* ---- CIRCLE RADIOS ---- */ .circle-radios { display: grid; @@ -961,6 +1025,26 @@ onUnmounted(() => { color: var(--candle-dim); } +/* ---- CHECKBOX ---- */ +.checkbox-label { + display: flex; + align-items: flex-start; + gap: 8px; + cursor: pointer; + font-size: 12px; + color: var(--text-dim); + line-height: 1.5; +} +.checkbox-label input { + margin-top: 3px; + flex-shrink: 0; +} +.checkbox-label a, +.checkbox-label :deep(a) { + color: var(--candle); + text-decoration: underline; +} + /* ---- ERROR & SUCCESS BOXES ---- */ .error-box { border: 1px dashed var(--ember); @@ -979,26 +1063,6 @@ onUnmounted(() => { max-width: 600px; } -/* ---- DETAILS LIST (confirmation) ---- */ -.details-list { - display: flex; - flex-direction: column; - gap: 8px; -} -.details-row { - display: flex; - justify-content: space-between; - align-items: baseline; - font-size: 13px; -} -.details-row dt { - color: var(--text-faint); -} -.details-row dd { - color: var(--text-bright); - font-weight: 500; -} - /* ---- PAYMENT INSTRUCTION ---- */ .payment-instruction { font-size: 13px; diff --git a/app/pages/member/account.vue b/app/pages/member/account.vue index 44a8f30..5f22673 100644 --- a/app/pages/member/account.vue +++ b/app/pages/member/account.vue @@ -34,7 +34,7 @@ + /> {{ formatStatus(memberData.status || "active") }} @@ -57,9 +57,11 @@
    Contribution - ${{ memberData.contributionTier || 0 }} / month + {{ currentContributionLabel }} +
    +
    + Next payment + {{ formatNextPaymentDate(nextPaymentDate) }}
    Member since @@ -70,6 +72,89 @@
    + + + + +
    + Next charge + ${{ nextChargeAmount }} on {{ formatNextPaymentDate(nextPaymentDate) }} +
    + +
    +
    Loading…
    +
    + +
    +
    + Payment history temporarily unavailable. Try again in a few minutes. +
    +
    + +
    +
    + No payments yet. Your first charge will appear here after your next billing cycle. +
    +
    + +
    +
    + {{ formatTxnDate(txn.date) }} + {{ formatTxnAmount(txn.amount, txn.currency) }} + {{ formatTxnStatus(txn.status) }} +
    +
    +
    + + + + +

    + Replace the card on file. Future charges will use the new card. +

    + +
    + + + + + Advanced billing in Helcim → + + + @@ -84,26 +169,26 @@
    + >