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/.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 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..0a9aa0b 100644 --- a/app/components/EventsMiniSidebar.vue +++ b/app/components/EventsMiniSidebar.vue @@ -6,7 +6,7 @@
- {{ formatDate(event.startDate) }} + {{ formatDate(event) }} [] }, }); -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", + }); }; @@ -104,7 +107,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..2e266f7 100644 --- a/app/components/NaturalDateInput.vue +++ b/app/components/NaturalDateInput.vue @@ -1,67 +1,40 @@ + +

+ → {{ previewText }} +

+

+ {{ errorMessage }} +

@@ -69,176 +42,197 @@ import * as chrono from "chrono-node"; const props = defineProps({ - modelValue: { - type: String, - default: "", - }, + modelValue: { type: String, default: "" }, placeholder: { type: String, - default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"', - }, - inputClass: { - type: [String, Object], - default: "", - }, - required: { - type: Boolean, - default: false, + default: 'e.g., "tomorrow at 3pm", "Nov 22 at 3pm"', }, + displayTimezone: { type: String, default: "" }, + required: { type: Boolean, default: false }, }); const emit = defineEmits(["update:modelValue"]); -const naturalInput = ref(""); -const parsedDate = ref(null); -const isValidParse = ref(false); +const rawInput = ref(""); +const isValid = ref(false); const hasError = ref(false); const errorMessage = ref(""); -const datetimeValue = ref(""); +// previewDate holds the parsed value as a UTC Date so we can format it in +// arbitrary timezones without re-parsing. Source of truth for the preview. +const previewDate = ref(null); -// Initialize with current value -onMounted(() => { - if (props.modelValue) { - const date = new Date(props.modelValue); - if (!isNaN(date.getTime())) { - parsedDate.value = date; - datetimeValue.value = formatForDatetimeLocal(date); - isValidParse.value = true; - } - } +const trailingState = computed(() => { + if (!rawInput.value.trim()) return undefined; + if (hasError.value) return "error"; + if (isValid.value) return "success"; + return undefined; }); -// Watch for external changes to modelValue -watch( - () => props.modelValue, - (newValue) => { - if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) { - const date = new Date(newValue); - if (!isNaN(date.getTime())) { - parsedDate.value = date; - datetimeValue.value = formatForDatetimeLocal(date); - isValidParse.value = true; - naturalInput.value = ""; // Clear natural input when set externally - } - } else if (!newValue) { - reset(); - } - }, -); - -const parseNaturalInput = () => { - const input = naturalInput.value.trim(); - - if (!input) { - reset(); - return; - } - - try { - // Parse with chrono-node - const results = chrono.parse(input); - - if (results.length > 0) { - const result = results[0]; - const date = result.date(); - - // Validate the parsed date - if (date && !isNaN(date.getTime())) { - parsedDate.value = date; - isValidParse.value = true; - hasError.value = false; - datetimeValue.value = formatForDatetimeLocal(date); - emit("update:modelValue", formatForDatetimeLocal(date)); - } else { - setError("Could not parse this date format"); - } - } else { - setError( - 'Could not understand this date format. Try something like "tomorrow at 3pm" or "next Friday"', - ); - } - } catch (error) { - setError("Error parsing date"); - } -}; - -const onBlur = () => { - // If we have a valid parse but the input changed, try to parse again - if (naturalInput.value.trim() && !isValidParse.value) { - parseNaturalInput(); - } -}; - -const onDatetimeChange = () => { - if (datetimeValue.value) { - const date = new Date(datetimeValue.value); - if (!isNaN(date.getTime())) { - parsedDate.value = date; - isValidParse.value = true; - hasError.value = false; - naturalInput.value = ""; // Clear natural input when using traditional picker - emit("update:modelValue", datetimeValue.value); - } - } else { - reset(); - } -}; - -const reset = () => { - parsedDate.value = null; - isValidParse.value = false; - hasError.value = false; - errorMessage.value = ""; - emit("update:modelValue", ""); -}; - -const setError = (message) => { - isValidParse.value = false; - hasError.value = true; - errorMessage.value = message; - parsedDate.value = null; -}; - -const formatForDatetimeLocal = (date) => { - if (!date) return ""; - // Format as YYYY-MM-DDTHH:MM for datetime-local input - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - return `${year}-${month}-${day}T${hours}:${minutes}`; -}; - -const formatParsedDate = (date) => { - if (!date) return ""; - - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - const tomorrow = new Date(now); - tomorrow.setDate(tomorrow.getDate() + 1); - const isTomorrow = date.toDateString() === tomorrow.toDateString(); - - const timeStr = date.toLocaleString("en-US", { +const previewText = computed(() => { + if (!previewDate.value) return ""; + const tz = activeTZ(); + const date = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", hour: "numeric", minute: "2-digit", hour12: true, - }); + }).format(previewDate.value); + const abbr = shortTimezoneName(previewDate.value, tz); + return abbr ? `${date} ${abbr}` : date; +}); - if (isToday) { - return `Today at ${timeStr}`; - } else if (isTomorrow) { - return `Tomorrow at ${timeStr}`; - } else { - return date.toLocaleString("en-US", { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - hour: "numeric", - minute: "2-digit", - hour12: true, - }); +const activeTZ = () => + props.displayTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone; + +// Seed the input from modelValue without triggering chrono. The parent's +// value is canonical — we just render it as a chrono-friendly readable +// string so the user can backspace and tweak in place. +const seedFromModelValue = () => { + if (!props.modelValue) { + rawInput.value = ""; + isValid.value = false; + hasError.value = false; + errorMessage.value = ""; + previewDate.value = null; + return; } + const tz = activeTZ(); + const utc = zonedLocalToUTC(props.modelValue, tz); + if (!utc) return; + previewDate.value = utc; + isValid.value = true; + hasError.value = false; + errorMessage.value = ""; + rawInput.value = readableSeed(utc, tz); +}; + +onMounted(seedFromModelValue); + +watch( + () => props.modelValue, + (next) => { + const tz = activeTZ(); + const expected = previewDate.value + ? utcToZonedLocal(previewDate.value, tz) + : ""; + if (next === expected) return; + seedFromModelValue(); + }, +); + +watch( + () => props.displayTimezone, + () => { + // Re-interpret the current input under the new TZ so the preview and + // emitted value reflect the new timezone semantics. + if (rawInput.value.trim()) parse(rawInput.value); + }, +); + +const onInputChange = (value) => { + rawInput.value = value; + parse(value); +}; + +const parse = (input) => { + const trimmed = input.trim(); + if (!trimmed) { + isValid.value = false; + hasError.value = false; + errorMessage.value = ""; + previewDate.value = null; + emit("update:modelValue", ""); + return; + } + const tz = activeTZ(); + let results; + try { + results = chrono.parse(trimmed, referenceNowInTZ(tz)); + } catch { + setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\""); + return; + } + if (!results.length) { + setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\""); + return; + } + const date = results[0].date(); + if (!date || Number.isNaN(date.getTime())) { + setError("Couldn't read that date"); + return; + } + // chrono returned a Date whose browser-local components match what the + // user typed in the event timezone (because we shifted the reference). + // Read those components as wall-clock in displayTimezone. + const localStr = browserComponentsToString(date); + const utc = zonedLocalToUTC(localStr, tz); + if (!utc) { + setError("Couldn't parse this date"); + return; + } + isValid.value = true; + hasError.value = false; + errorMessage.value = ""; + previewDate.value = utc; + emit("update:modelValue", localStr); +}; + +const setError = (msg) => { + isValid.value = false; + hasError.value = true; + errorMessage.value = msg; + previewDate.value = null; + emit("update:modelValue", ""); +}; + +// Build a Date object whose browser-local components equal the current +// wall-clock in the given IANA timezone, so chrono's "tomorrow"/"next +// Friday" anchor to the event TZ rather than the editor's browser TZ. +const referenceNowInTZ = (tz) => { + const nowStr = utcToZonedLocal(new Date(), tz); + if (!nowStr) return new Date(); + const [d, t] = nowStr.split("T"); + const [y, mo, day] = d.split("-").map(Number); + const [h, mi] = t.split(":").map(Number); + return new Date(y, mo - 1, day, h, mi); +}; + +const browserComponentsToString = (date) => { + const y = date.getFullYear(); + const mo = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + const h = String(date.getHours()).padStart(2, "0"); + const mi = String(date.getMinutes()).padStart(2, "0"); + return `${y}-${mo}-${d}T${h}:${mi}`; +}; + +const readableSeed = (utc, tz) => { + // Format chosen to round-trip cleanly through chrono.parse. + return new Intl.DateTimeFormat("en-US", { + timeZone: tz, + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(utc); }; + + 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 @@ -
- - Go to Dashboard Now - -
diff --git a/app/composables/useEventDateUtils.js b/app/composables/useEventDateUtils.js index ab536b4..2aec85e 100644 --- a/app/composables/useEventDateUtils.js +++ b/app/composables/useEventDateUtils.js @@ -1,85 +1,98 @@ -// Utility composable for event date handling with timezone support +// Utility composable for event date handling with timezone support. +// Pass `{ timeZone: event.displayTimezone }` to render in the event's TZ. export const useEventDateUtils = () => { - const TIMEZONE = "America/Toronto"; + const DEFAULT_TIMEZONE = "America/Toronto"; - // Format a date to a specific format const formatDate = (date, options = {}) => { + if (!date) return ""; const dateObj = date instanceof Date ? date : new Date(date); - const { month = "short", day = "numeric", year = "numeric" } = options; + if (isNaN(dateObj.getTime())) return ""; + const { + month = "short", + day = "numeric", + year = "numeric", + weekday, + timeZone, + } = options; return new Intl.DateTimeFormat("en-US", { + ...(weekday && { weekday }), month, day, year, + ...(timeZone && { timeZone }), }).format(dateObj); }; - // Format event date range - const formatDateRange = (startDate, endDate, compact = false) => { + const formatDateRange = (startDate, endDate, compact = false, timeZone) => { if (!startDate || !endDate) return "No dates"; const start = new Date(startDate); const end = new Date(endDate); - const startMonth = start.toLocaleDateString("en-US", { month: "short" }); - const endMonth = end.toLocaleDateString("en-US", { month: "short" }); - const startDay = start.getDate(); - const endDay = end.getDate(); - const year = end.getFullYear(); + const tzOpts = timeZone ? { timeZone } : {}; + const startMonth = start.toLocaleDateString("en-US", { month: "short", ...tzOpts }); + const endMonth = end.toLocaleDateString("en-US", { month: "short", ...tzOpts }); + const startDay = Number( + start.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }), + ); + const endDay = Number( + end.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }), + ); + const year = Number( + end.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }), + ); + const startMonthIdx = startMonth; // compared as label string + const endMonthIdx = endMonth; + const startYear = Number( + start.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }), + ); if (compact) { - if ( - start.getMonth() === end.getMonth() && - start.getFullYear() === end.getFullYear() - ) { + if (startMonthIdx === endMonthIdx && startYear === year) { return `${startMonth} ${startDay}-${endDay}`; } return `${startMonth} ${startDay} - ${endMonth} ${endDay}`; } - if ( - start.getMonth() === end.getMonth() && - start.getFullYear() === end.getFullYear() - ) { + if (startMonthIdx === endMonthIdx && startYear === year) { return `${startMonth} ${startDay}-${endDay}, ${year}`; - } else if (start.getFullYear() === end.getFullYear()) { + } else if (startYear === year) { return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`; } else { - return `${formatDate(startDate)} - ${formatDate(endDate)}`; + return `${formatDate(startDate, { timeZone })} - ${formatDate(endDate, { timeZone })}`; } }; - // Check if a date is in the past const isPastDate = (date) => { const dateObj = date instanceof Date ? date : new Date(date); - const now = new Date(); - return dateObj < now; + return dateObj < new Date(); }; - // Check if a date is today - const isToday = (date) => { + const isToday = (date, timeZone) => { const dateObj = date instanceof Date ? date : new Date(date); const today = new Date(); + const opts = { year: "numeric", month: "2-digit", day: "2-digit", ...(timeZone && { timeZone }) }; return ( - dateObj.getDate() === today.getDate() && - dateObj.getMonth() === today.getMonth() && - dateObj.getFullYear() === today.getFullYear() + dateObj.toLocaleDateString("en-US", opts) === + today.toLocaleDateString("en-US", opts) ); }; - // Get a readable time string - const formatTime = (date, includeSeconds = false) => { + const formatTime = (date, includeSeconds = false, timeZone) => { const dateObj = date instanceof Date ? date : new Date(date); - const options = { + return new Intl.DateTimeFormat("en-US", { hour: "2-digit", minute: "2-digit", ...(includeSeconds && { second: "2-digit" }), - }; - return new Intl.DateTimeFormat("en-US", options).format(dateObj); + ...(timeZone && { timeZone }), + }).format(dateObj); }; return { - TIMEZONE, + DEFAULT_TIMEZONE, + // Legacy alias for callers that hard-coded the constant. + TIMEZONE: DEFAULT_TIMEZONE, formatDate, formatDateRange, isPastDate, 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/composables/useSiteMeta.js b/app/composables/useSiteMeta.js new file mode 100644 index 0000000..007a644 --- /dev/null +++ b/app/composables/useSiteMeta.js @@ -0,0 +1,58 @@ +/** + * useSiteMeta — set page-level SEO + social meta with site defaults baked in. + * + * Builds absolute URLs from runtimeConfig.public.appUrl so og:image and og:url + * resolve for crawlers. Defaults og:type=website, twitter:card=summary_large_image, + * og:site_name=Ghost Guild. Set noindex:true to emit robots="noindex, nofollow". + * + * Pass a function (or refs in fields) to keep tags reactive when content loads + * asynchronously via useFetch. + */ +export function useSiteMeta(input) { + const runtimeConfig = useRuntimeConfig() + const route = useRoute() + const appUrl = (runtimeConfig.public.appUrl || '').replace(/\/$/, '') + + const resolve = () => (typeof input === 'function' ? input() : input) || {} + + const buildAbsolute = (path) => { + if (!path) return undefined + if (/^https?:\/\//i.test(path)) return path + return `${appUrl}${path.startsWith('/') ? '' : '/'}${path}` + } + + const titleGetter = () => resolve().title || 'Ghost Guild' + const descGetter = () => resolve().description || undefined + const isBareTitle = () => Boolean(resolve().bareTitle) + const imageGetter = () => buildAbsolute(resolve().image || '/og/default.png') + const typeGetter = () => resolve().type || 'website' + const robotsGetter = () => + resolve().noindex ? 'noindex, nofollow' : undefined + const canonicalGetter = () => buildAbsolute(route.path) + + useSeoMeta({ + title: titleGetter, + description: descGetter, + ogSiteName: 'Ghost Guild', + ogTitle: titleGetter, + ogDescription: descGetter, + ogType: typeGetter, + ogUrl: canonicalGetter, + ogImage: imageGetter, + ogImageWidth: 1200, + ogImageHeight: 630, + twitterCard: 'summary_large_image', + twitterTitle: titleGetter, + twitterDescription: descGetter, + twitterImage: imageGetter, + robots: robotsGetter, + }) + + useHead({ + link: [{ rel: 'canonical', href: canonicalGetter }], + }) + + if (isBareTitle()) { + useHead({ titleTemplate: null }) + } +} diff --git a/app/config/eventTypes.js b/app/config/eventTypes.js new file mode 100644 index 0000000..e256338 --- /dev/null +++ b/app/config/eventTypes.js @@ -0,0 +1,21 @@ +// Central configuration for Ghost Guild event types. +// Keep values in sync with the `eventType` enum in server/models/event.js. +export const EVENT_TYPES = [ + { value: "talk", label: "Talk / Presentation" }, + { value: "workshop", label: "Workshop" }, + { value: "community-meetup", label: "Community Meetup" }, + { value: "coworking", label: "Co-working Session" }, + { value: "peer-session", label: "Peer Session" }, + { value: "skills-share", label: "Skills Share" }, + { value: "info-session", label: "Info Session" }, +]; + +export const EVENT_TYPE_VALUES = EVENT_TYPES.map((t) => t.value); + +const labelLookup = Object.fromEntries( + EVENT_TYPES.map((t) => [t.value, t.label]), +); + +export function eventTypeLabel(value) { + return labelLookup[value] || value || ""; +} 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/layouts/admin.vue b/app/layouts/admin.vue index 5e0baad..0c0d201 100644 --- a/app/layouts/admin.vue +++ b/app/layouts/admin.vue @@ -217,6 +217,8 @@ + +` diff --git a/app/pages/events/[slug].vue b/app/pages/events/[slug].vue index c3b57e6..6a5ac57 100644 --- a/app/pages/events/[slug].vue +++ b/app/pages/events/[slug].vue @@ -7,7 +7,7 @@ ← Back to Events
-
+

{{ event.title }}

@@ -22,15 +22,14 @@
Location - {{ event.location }} + + Platform TBD + +
-
- Capacity - {{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats -
@@ -48,7 +47,7 @@ + >
@@ -82,7 +81,7 @@

About This Event

-

{{ event.description }}

+
@@ -91,17 +90,23 @@ class="section" >

About the {{ event.series.title }} Series

-

{{ event.series.description }}

+
+
+ + +
+

Additional Information

+

Agenda

-
    +
    • {{ item }}
    • -
+
@@ -128,6 +133,7 @@ :event-id="event._id || event.id" :event-start-date="event.startDate" :event-title="event.title" + :event-timezone="eventTimeZone" :user-email="memberData?.email" :user-name="memberData?.name" @success="handleTicketSuccess" @@ -139,7 +145,7 @@
Event Details
Type - {{ event.eventType }} + {{ eventTypeLabel(event.eventType) }}
Members only @@ -165,6 +171,8 @@ @@ -294,10 +307,19 @@ useHead(() => ({ margin-bottom: 4px; } +/* ---- PAGE FILL (aside border reaches viewport bottom) ---- */ +.page-fill { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + /* ---- TWO-COLUMN BODY ---- */ .event-body { display: grid; grid-template-columns: 1fr 280px; + flex: 1; } .event-main { min-width: 0; @@ -328,12 +350,79 @@ useHead(() => ({ margin-bottom: 8px; } .section p { - font-size: 12px; + font-size: 14px; color: var(--text-dim); line-height: 1.7; max-width: 560px; } +.prose { + font-size: 14px; + color: var(--text-dim); + line-height: 1.7; + max-width: 560px; +} +.prose :deep(p) { + margin-bottom: 12px; +} +.prose :deep(p:last-child) { + margin-bottom: 0; +} +.prose :deep(a) { + color: var(--ember); + text-decoration: underline; + text-underline-offset: 2px; +} +.prose :deep(strong) { + color: var(--text-bright); +} +.prose :deep(ul), +.prose :deep(ol) { + list-style: none; + padding: 0; + margin: 8px 0 12px; +} +.prose :deep(ul li), +.prose :deep(ol li) { + position: relative; + padding: 2px 0 2px 16px; + margin-bottom: 4px; +} +.prose :deep(ul li::before) { + content: "›"; + position: absolute; + left: 0; + top: 2px; + color: var(--candle-faint); + font-size: 14px; + line-height: 1.7; +} +.prose :deep(ol) { + counter-reset: prose-item; +} +.prose :deep(ol li) { + counter-increment: prose-item; + padding-left: 28px; +} +.prose :deep(ol li::before) { + content: counter(prose-item) "."; + position: absolute; + left: 0; + top: 2px; + color: var(--candle-faint); +} +.prose :deep(blockquote) { + border-left: 2px solid var(--candle-faint); + padding-left: 12px; + margin: 12px 0; + color: var(--text-faint); +} +.prose :deep(code) { + font-family: "Commit Mono", monospace; + background: var(--input-bg); + padding: 0 4px; +} + .circle-badges { display: flex; gap: 6px; @@ -346,10 +435,27 @@ useHead(() => ({ } .agenda-list { - padding-left: 20px; - font-size: 12px; + list-style: none; + padding: 0; + margin: 8px 0 0; + font-size: 14px; color: var(--text-dim); - line-height: 2; + line-height: 1.7; + max-width: 560px; +} +.agenda-list li { + position: relative; + padding: 2px 0 2px 16px; + margin-bottom: 4px; +} +.agenda-list li::before { + content: "›"; + position: absolute; + left: 0; + top: 2px; + color: var(--candle-faint); + font-size: 14px; + line-height: 1.7; } .speaker { diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue index 88e0469..da0ce91 100644 --- a/app/pages/events/index.vue +++ b/app/pages/events/index.vue @@ -34,8 +34,8 @@ :class="{ 'is-cancelled': event.isCancelled }" >
- {{ formatDate(event.startDate) }} - {{ formatTime(event.startDate) }} + {{ formatDate(event) }} + {{ formatTime(event) }}
@@ -45,34 +45,21 @@ cancelled + Registered
{{ event.tagline }}
{{ - event.eventType + eventTypeLabel(event.eventType) }} · {{ formatLocation(event) }}
- - - -
Members @@ -88,8 +75,8 @@

{{ series.title }}

@@ -107,6 +94,11 @@ >
+
@@ -114,23 +106,27 @@ diff --git a/app/pages/index.vue b/app/pages/index.vue index d813d33..e4b1487 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -42,7 +42,7 @@
- {{ formatDate(event.startDate) }} + {{ formatDate(event) }} {{ event.title @@ -117,6 +117,33 @@ definePageMeta({ layout: "default", }); +const runtimeConfig = useRuntimeConfig(); +const siteUrl = (runtimeConfig.public.appUrl || "").replace(/\/$/, ""); + +useSiteMeta({ + title: "Ghost Guild", + bareTitle: true, + description: + "Ghost Guild is where game developers explore cooperative models. Membership, events, and resources for people figuring it out together. Pay what you can.", +}); + +useHead({ + script: [ + { + type: "application/ld+json", + innerHTML: JSON.stringify({ + "@context": "https://schema.org", + "@type": "Organization", + name: "Ghost Guild", + url: siteUrl || "https://ghostguild.org", + logo: `${siteUrl || "https://ghostguild.org"}/og/default.png`, + description: + "A membership community for game developers exploring cooperative models. A program of Baby Ghosts, a Canadian non-profit.", + }), + }, + ], +}); + const { data: events } = await useFetch("/api/events", { query: { limit: 4, upcoming: true }, default: () => [], @@ -131,12 +158,10 @@ const DEFAULT_WIKI_FEATURE_TITLE = "What is a cooperative studio?"; const { data: wikiFeature } = await useFetch( "/api/site-content/homepage.wiki_feature", - { default: () => ({ title: "", body: "" }) } + { default: () => ({ title: "", body: "" }) }, ); -const hasCustomWikiFeature = computed( - () => !!wikiFeature.value?.body?.trim() -); +const hasCustomWikiFeature = computed(() => !!wikiFeature.value?.body?.trim()); const customWikiParagraphs = computed(() => { const body = wikiFeature.value?.body?.trim() || ""; @@ -166,14 +191,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 82e4f00..26a2621 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -64,26 +64,37 @@

Pay what you can

  • $0 I need support right now
  • -
  • {{ formatContributionAmount(5) }} I can contribute
  • - {{ formatContributionAmount(15) }} I can sustain the community - (suggested) + {{ formatContributionAmount(5) }} I + can contribute
  • -
  • {{ formatContributionAmount(30) }} I can support others too
  • - {{ formatContributionAmount(50) }} I want to sponsor multiple - members + {{ formatContributionAmount(15) }} I + can sustain the community (suggested) +
  • +
  • + {{ formatContributionAmount(30) }} I + can support others too +
  • +
  • + {{ formatContributionAmount(50) }} I + want to sponsor multiple members

- Baby Ghosts Studio Development Fund is a registered Canadian charity. - Members who file Canadian taxes can claim their contributions. - We'll help you set up tax receipts once you've joined. + Baby Ghosts Studio Development Fund is a registered Canadian + charity. Members who file Canadian taxes can claim their + contributions. We'll help you set up tax receipts once you've + joined.

Pay what you can. If you can pay more, you're making room for @@ -118,7 +129,7 @@ type="text" placeholder="Your name" required - > + />

@@ -129,7 +140,7 @@ type="email" placeholder="you@example.com" required - > + />
@@ -141,7 +152,7 @@ type="radio" name="circle" value="community" - > + />
-
+
-

{{ guidanceLabel }}

+

+ {{ guidanceLabel }} +

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

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

- @@ -56,9 +60,7 @@ :to="`/events/${evt.slug || evt._id}`" class="event-item" > - {{ - formatEventDate(evt.startDate) - }} + {{ formatEventDate(evt) }} {{ evt.title }} @@ -218,12 +220,18 @@ diff --git a/app/pages/policies/terms.vue b/app/pages/policies/terms.vue index e46a684..cf6f093 100644 --- a/app/pages/policies/terms.vue +++ b/app/pages/policies/terms.vue @@ -250,8 +250,10 @@ diff --git a/app/pages/series/[id].vue b/app/pages/series/[id].vue index 14b3d13..193f579 100644 --- a/app/pages/series/[id].vue +++ b/app/pages/series/[id].vue @@ -1,5 +1,5 @@