diff --git a/.env.example b/.env.example index 8a6cadf..2fefd06 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,6 @@ 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 2a29c40..8edaae1 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://mongo-ci:27017/ghostguild-test + MONGODB_URI: mongodb://localhost: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,35 +39,15 @@ 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 > /tmp/server.log 2>&1 & + 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' - - name: Server log on failure - if: failure() - run: cat /tmp/server.log || true - - run: npx playwright test - - uses: actions/upload-artifact@v3 + - run: npx playwright test --ignore-snapshots + - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report @@ -88,3 +68,39 @@ 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 3907ee0..5ca2fab 100644 --- a/.gitignore +++ b/.gitignore @@ -26,9 +26,6 @@ logs !.env.example scripts/*.js -# Migration backup files -.migration-backup-*.json - # Playwright e2e/test-results/ playwright-report/ @@ -38,6 +35,3 @@ e2e/.auth/ .worktrees/ .claude/worktrees/ .superpowers/ - -.claude -scripts/dump-babyghosts-preregistrations.mjs diff --git a/.husky/pre-push b/.husky/pre-push old mode 100755 new mode 100644 diff --git a/.serena/project.yml b/.serena/project.yml index 0d43951..9d24cb3 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -3,26 +3,21 @@ project_name: "ghostguild-org" # list of languages for which language servers are started; choose from: -# 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 +# 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 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. @@ -70,17 +65,53 @@ read_only: false # list of tool names to exclude. # This extends the existing exclusions (e.g. from the global configuration) -# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +# +# 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). -# 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 @@ -91,14 +122,11 @@ 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, 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. +# 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). -# 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 @@ -122,19 +150,3 @@ 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 0375bac..54b1438 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM node:22-alpine AS builder +FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ @@ -7,11 +7,8 @@ RUN npm ci --ignore-scripts && npx nuxt prepare COPY . . RUN npm run build -# 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 +# Production stage — only the self-contained .output is needed +FROM node:20-alpine WORKDIR /app COPY --from=builder /app/.output .output diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 9ee189f..4f1ab74 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -27,10 +27,7 @@ --text: #2a2015; --text-bright: #1a1008; --text-dim: #5a5040; - /* 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; + --text-faint: #746a58; --parch: #2a2015; --parch-hover: #3a3025; --parch-text: #ede4d0; @@ -276,98 +273,6 @@ 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 07cc322..1add2ef 100644 --- a/app/components/AppNavigation.vue +++ b/app/components/AppNavigation.vue @@ -25,6 +25,11 @@ /> +
  • + Sign out +
  • @@ -133,11 +138,11 @@ -
    -
    -

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

    -

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

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

    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 f6c5cab..9650bb3 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); - border-color: var(--green); + color: var(--green, #4a7); + border-color: var(--green, #4a7); } .sync-updated { diff --git a/app/pages/auth/logout-confirm.vue b/app/pages/auth/logout-confirm.vue index 0611b11..3061387 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: 600; + font-weight: 700; 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 ea00d18..28e6fce 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: 600; + font-weight: 700; 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 0a86bfb..9a060c4 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: 600; + font-weight: 700; 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: 600; + font-weight: 700; margin: 0 0 4px; } diff --git a/app/pages/auth/wiki-login.vue b/app/pages/auth/wiki-login.vue index f7e0d01..a135fa2 100644 --- a/app/pages/auth/wiki-login.vue +++ b/app/pages/auth/wiki-login.vue @@ -8,7 +8,6 @@ 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(""); @@ -16,21 +15,13 @@ async function sendMagicLink() { if (!email.value || !uid) return; loading.value = true; error.value = ""; - notRegistered.value = false; try { - 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; - } + await $fetch("/oidc/interaction/login", { + method: "POST", + body: { email: email.value, uid }, + }); + sent.value = true; } catch (e: any) { error.value = e?.data?.statusMessage || "Something went wrong. Please try again."; @@ -38,12 +29,6 @@ async function sendMagicLink() { loading.value = false; } } - -function resetForm() { - sent.value = false; - notRegistered.value = false; - email.value = ""; -} @@ -88,8 +71,8 @@

    {{ series.title }}

    @@ -107,11 +90,6 @@ >
    - @@ -133,8 +111,9 @@ 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) @@ -152,24 +131,18 @@ const activeSeries = computed(() => { ); }); -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"; +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"; return d.toLocaleDateString("en-US", opts); }; -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 formatTime = (dateStr) => { + if (!dateStr) return ""; + const d = new Date(dateStr); + return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); }; const formatLocation = (event) => { @@ -181,15 +154,9 @@ 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; - if (isSoldOut(event)) return false; - return (event.registeredCount || 0) / event.maxAttendees >= 0.8; + return (event.registeredCount || 0) / event.maxAttendees > 0.8; }; @@ -238,12 +205,8 @@ const isAlmostFull = (event) => { .event-row:hover { padding-left: 4px; } -.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-row.is-cancelled { + opacity: 0.5; } .event-date-col { @@ -326,29 +289,10 @@ 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; @@ -382,21 +326,14 @@ 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:nth-child(2n) { +.series-box:last-child { border-right: none; } -.series-box:nth-last-child(-n + 2) { - border-bottom: none; -} -.series-box-filler { - pointer-events: none; -} -.series-box:not(.series-box-filler):hover { +.series-box:hover { background: var(--surface-hover); } .series-box h2 { @@ -421,47 +358,17 @@ const isAlmostFull = (event) => { } -.past-toggle { - display: inline-flex; +.filter-toggle { + display: flex; align-items: center; - gap: 8px; + gap: 6px; 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; } -.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); +.filter-toggle input { + accent-color: var(--candle-dim); } .empty { @@ -492,17 +399,8 @@ 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 a675749..e00cf5b 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -42,7 +42,7 @@
    - {{ formatDate(event) }} + {{ formatDate(event.startDate) }} {{ event.title @@ -87,24 +87,18 @@ > 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 →

    @@ -127,23 +121,6 @@ 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", @@ -164,17 +141,14 @@ const circleData = [ label: "Practitioner", metaphor: "The alcove", blurb: - "Where experience is shared and knowledge given back. You're here to support newcomers, help shape the Cooperative Foundations program, and find peers.", + "Where experience is shared and knowledge given back. You're here to teach, advise, mentor, and help shape the program itself. Alumni welcome.", }, ]; -const formatDate = (event) => { - if (!event?.startDate) return ""; - return new Date(event.startDate).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - timeZone: event.displayTimezone || "America/Toronto", - }); +const formatDate = (dateStr) => { + if (!dateStr) return ""; + const d = new Date(dateStr); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); }; diff --git a/app/pages/join.vue b/app/pages/join.vue index 67bc0d6..e1996c5 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -32,7 +32,7 @@
    - ${{ memberData?.contributionAmount ?? 0 }} CAD/month + ${{ memberData?.contributionTier || "0" }} CAD/month
    @@ -59,43 +59,86 @@ - - -
    @@ -386,9 +388,9 @@ import { reactive, ref, computed, onMounted, onUnmounted } from "vue"; import { getCircleOptions } from "~/config/circles"; import { + getContributionOptions, requiresPayment, - CONTRIBUTION_PRESETS, - getGuidanceLabel, + getContributionTierByValue, } from "~/config/contributions"; // Auth state @@ -404,8 +406,7 @@ const form = reactive({ email: "", name: "", circle: "community", - contributionAmount: 15, - agreedToGuidelines: false, + contributionTier: "15", billingAddress: { street: "", city: "", @@ -417,17 +418,9 @@ 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); @@ -438,12 +431,8 @@ const paymentToken = ref(null); // Circle options from central config const circleOptions = getCircleOptions(); -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}`; -}; +// Contribution options from central config +const contributionOptions = getContributionOptions(); // Initialize composables const { @@ -454,108 +443,115 @@ const { // Form validation const isFormValid = computed(() => { - return ( - form.name && - form.email && - form.circle && - Number.isInteger(form.contributionAmount) && - form.contributionAmount >= 0 && - form.agreedToGuidelines - ); + return form.name && form.email && form.circle && form.contributionTier; }); // Check if payment is required const needsPayment = computed(() => { - return requiresPayment(form.contributionAmount); + return requiresPayment(form.contributionTier); }); -const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount)); - -const firstCharge = computed(() => { - const amount = form.contributionAmount || 0; - return cadence.value === "annual" ? amount * 12 : amount; +// Get selected tier info +const selectedTier = computed(() => { + return getContributionTierByValue(form.contributionTier); }); -const flowSummary = computed(() => ({ - name: form.name, - email: form.email, - circle: form.circle, - contribution: formatContributionAmount(form.contributionAmount), -})); - +// Step 1: Create customer const handleSubmit = async () => { if (isSubmitting.value || !isFormValid.value) return; isSubmitting.value = true; errorMessage.value = ""; - flowState.value = "creating-customer"; try { - // Create customer + // Create customer in Helcim const response = await $fetch("/api/helcim/customer", { method: "POST", body: { name: form.name, email: form.email, circle: form.circle, - contributionAmount: form.contributionAmount, - agreedToGuidelines: form.agreedToGuidelines, + contributionTier: form.contributionTier, billingAddress: form.billingAddress, }, }); - if (!response.success) { - throw new Error("Failed to create account."); - } + if (response.success) { + customerId.value = response.customerId; + customerCode.value = response.customerCode; - 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 - // Free tier: no Helcim modal, go straight to subscription. - if (!needsPayment.value) { - flowState.value = "creating-subscription"; - await createSubscription(); - return; - } + // 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(); - // Paid tier: initialize HelcimPay session, then auto-open modal. - flowState.value = "opening-payment"; - await initializeHelcimPay(customerId.value, customerCode.value, 0); - - const paymentResult = await verifyPayment(); - if (!paymentResult?.success) { - throw new Error("Payment was not completed."); - } - paymentToken.value = paymentResult.cardToken; - - flowState.value = "processing-payment"; - 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, - ); - - 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"; + // Automatically redirect to welcome page after a short delay + setTimeout(() => { + navigateTo("/welcome"); + }, 3000); // 3 second delay to show success message + } } } catch (error) { - console.error("Join flow error:", error); + console.error("Error creating customer:", error); errorMessage.value = - error.data?.message || - error.message || - "Something went wrong. Please try again."; - flowState.value = "error"; + 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; + + isSubmitting.value = true; + errorMessage.value = ""; + + try { + // Verify payment through HelcimPay.js + const paymentResult = await verifyPayment(); + + if (paymentResult.success) { + paymentToken.value = paymentResult.cardToken; + + // Verify payment on server + const verifyResult = await $fetch("/api/helcim/verify-payment", { + method: "POST", + body: { + cardToken: paymentResult.cardToken, + customerId: customerId.value, + }, + }); + + // 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."; + } + } + } catch (error) { + console.error("Payment process error:", error); + errorMessage.value = + error.message || "Payment verification failed. Please try again."; } finally { isSubmitting.value = false; } @@ -569,20 +565,23 @@ const createSubscription = async (cardToken = null) => { body: { customerId: customerId.value, customerCode: customerCode.value, - contributionAmount: form.contributionAmount, - cadence: cadence.value, + contributionTier: form.contributionTier, cardToken: cardToken, }, }); if (response.success) { subscriptionData.value = response.subscription; - flowState.value = "success"; + currentStep.value = 3; successMessage.value = "Your membership is active."; - // 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. + // 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 } else { throw new Error("Subscription creation failed - response not successful"); } @@ -606,9 +605,27 @@ const createSubscription = async (cardToken = null) => { } }; -const closeFlowOverlay = () => { - flowState.value = "idle"; +// 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; errorMessage.value = ""; + successMessage.value = ""; + form.email = ""; + form.name = ""; + form.circle = "community"; + form.contributionTier = "15"; }; // Cleanup on unmount @@ -654,12 +671,11 @@ onUnmounted(() => { position: relative; } :deep(.parchment-inset ul li::before) { - content: "›"; + content: "--"; position: absolute; left: 0; - color: var(--candle-faint); - font-size: 14px; - line-height: 1.4; + color: var(--candle-dim); + opacity: 0.5; } .parchment-link { @@ -751,7 +767,7 @@ onUnmounted(() => { padding: 0; } .tier-list li { - padding: 4px 0; + padding: 5px 0; font-size: 12px; color: var(--text-dim); border-bottom: 1px dashed var(--border); @@ -773,13 +789,6 @@ 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; @@ -834,79 +843,6 @@ 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; @@ -1025,26 +961,6 @@ 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); @@ -1063,6 +979,26 @@ 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 5f22673..44a8f30 100644 --- a/app/pages/member/account.vue +++ b/app/pages/member/account.vue @@ -34,7 +34,7 @@ + > {{ formatStatus(memberData.status || "active") }} @@ -57,11 +57,9 @@
    Contribution - {{ currentContributionLabel }} -
    -
    - Next payment - {{ formatNextPaymentDate(nextPaymentDate) }} + ${{ memberData.contributionTier || 0 }} / month
    Member since @@ -72,89 +70,6 @@
    - - - - -
    - 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 → - - - @@ -169,26 +84,26 @@
    + autofocus + />