Compare commits
68 commits
chore/visu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a361b6857 | |||
| cc89c28f03 | |||
| 384d3197ce | |||
| 4a05e91715 | |||
| 622cc8e53b | |||
| 2ffaf0ef09 | |||
| 31144617d7 | |||
| 877ef1a220 | |||
| 9e18560ebf | |||
| 96470a604a | |||
| 49f4eae11c | |||
| 75b1f84d18 | |||
| 9dd007657a | |||
| acbd3c0737 | |||
| a76ba2f8c7 | |||
| e6f05b5471 | |||
| 9e4030ccfd | |||
| f5b7a3eeba | |||
| 6fa3e08fe0 | |||
| e1d224e260 | |||
| 2a66b0eb8a | |||
| 397c00125a | |||
| 050d117abf | |||
| 94b242100c | |||
| 790f44b4e9 | |||
| 13c72b5ee0 | |||
| 9858316b30 | |||
| 0927b66b4f | |||
| 84aea08a5f | |||
| 73e67d02bb | |||
| c3695de5ca | |||
| b45f92a574 | |||
| b7d9d91b1a | |||
| 47e106171e | |||
| 6bfb078e45 | |||
| f66189cfd6 | |||
| 1578055a27 | |||
| 6e98720310 | |||
| f428cbb219 | |||
| f05c1f6d40 | |||
| 0985f6acb1 | |||
| 43eda6db04 | |||
| 386cb7e4b2 | |||
| a797f8e17c | |||
| 16aaeddcee | |||
| d1b5107478 | |||
| 9ddb45c4d8 | |||
| f62fd4f586 | |||
| ba84429917 | |||
| 593b1238f9 | |||
| 8dd55ccc09 | |||
| 03dfdab20e | |||
| 6a6f036877 | |||
| 1c8f30fe6f | |||
| 7f0a586311 | |||
| b9fa9f603c | |||
| 33ba082b82 | |||
| a949252915 | |||
| 9b79ae6bf4 | |||
| c6a5e25d06 | |||
| 441a5f5608 | |||
| d9444b022b | |||
| da5e7efcb7 | |||
| d4000c18cf | |||
| 313b8598df | |||
| d06c83cfc4 | |||
| 9c7d6fa446 | |||
| 07943266b7 |
|
|
@ -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
|
||||
|
|
|
|||
0
.husky/pre-push
Normal file → Executable file
|
|
@ -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: []
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -178,7 +178,8 @@ const slackLinks = computed(() => {
|
|||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.post-actions {
|
||||
|
|
@ -233,7 +234,8 @@ const slackLinks = computed(() => {
|
|||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.block-text {
|
||||
|
|
@ -244,7 +246,8 @@ const slackLinks = computed(() => {
|
|||
|
||||
.post-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
margin: 8px 0;
|
||||
white-space: pre-wrap;
|
||||
|
|
@ -293,7 +296,8 @@ const slackLinks = computed(() => {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||
color: var(--text-dim);
|
||||
font-family: "Commit Mono", monospace;
|
||||
}
|
||||
.author-name {
|
||||
|
|
@ -308,7 +312,8 @@ const slackLinks = computed(() => {
|
|||
}
|
||||
.slack-handle {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
/* --text-faint fails WCAG AA (4.01:1) on the cream card bg */
|
||||
color: var(--text-dim);
|
||||
font-family: "Commit Mono", monospace;
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -29,13 +29,14 @@ const props = defineProps({
|
|||
limit: { type: Number, default: 3 },
|
||||
})
|
||||
|
||||
const upcomingEvents = ref([])
|
||||
let upcomingEvents = ref([])
|
||||
if (props.cols === 'events-sidebar') {
|
||||
const { data } = await useFetch('/api/events', {
|
||||
query: { upcoming: true, limit: props.limit },
|
||||
default: () => [],
|
||||
server: false,
|
||||
})
|
||||
upcomingEvents.value = data.value || []
|
||||
upcomingEvents = computed(() => data.value || [])
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -38,14 +38,14 @@
|
|||
|
||||
<!-- Already Registered -->
|
||||
<div v-else-if="ticketInfo?.alreadyRegistered" class="ticket-panel">
|
||||
<div class="box-title">Registration</div>
|
||||
<p class="ticket-status" style="color: var(--green)">
|
||||
You're Registered!
|
||||
</p>
|
||||
<p class="ticket-detail">
|
||||
<template v-if="ticketInfo.viaSeriesPass">
|
||||
You have access to this event via your series pass for
|
||||
<strong>{{ ticketInfo.series?.title }}</strong>.
|
||||
<strong>{{ ticketInfo.series?.title }}</strong
|
||||
>.
|
||||
</template>
|
||||
<template v-else>
|
||||
You're all set for this event. Check your email for confirmation
|
||||
|
|
@ -70,13 +70,11 @@
|
|||
|
||||
<!-- Registration (logged-in member) -->
|
||||
<div
|
||||
v-if="ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn"
|
||||
v-if="
|
||||
ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn
|
||||
"
|
||||
class="ticket-panel"
|
||||
>
|
||||
<div class="box-title">
|
||||
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="ticketInfo.isMember && ticketInfo.isFree"
|
||||
class="ticket-notice"
|
||||
|
|
@ -90,8 +88,7 @@
|
|||
class="ticket-notice"
|
||||
style="color: var(--candle)"
|
||||
>
|
||||
Payment of {{ ticketInfo.formattedPrice }} will be processed
|
||||
securely
|
||||
Payment of {{ ticketInfo.formattedPrice }} will be processed securely
|
||||
</p>
|
||||
|
||||
<button
|
||||
|
|
@ -129,7 +126,7 @@
|
|||
autocomplete="name"
|
||||
required
|
||||
:disabled="processing"
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
|
@ -142,7 +139,7 @@
|
|||
autocomplete="email"
|
||||
required
|
||||
:disabled="processing"
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
|
|
@ -160,11 +157,15 @@
|
|||
v-model="form.createAccount"
|
||||
type="checkbox"
|
||||
:disabled="processing"
|
||||
/>
|
||||
<span
|
||||
>Create a free guest account so I can manage my
|
||||
registration</span
|
||||
>
|
||||
<span>Create a free guest account so I can manage my registration</span>
|
||||
</label>
|
||||
<p class="field-hint consent-hint">
|
||||
Guest accounts let you view your tickets and register faster next time. We won't add you to member communications.
|
||||
Guest accounts let you view your tickets and register faster next
|
||||
time. We won't add you to member communications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -190,24 +191,18 @@
|
|||
class="ticket-panel"
|
||||
>
|
||||
<div class="box-title">Waitlist</div>
|
||||
<p class="ticket-status" style="color: var(--ember)">
|
||||
Event Sold Out
|
||||
</p>
|
||||
<p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
|
||||
<p class="ticket-detail">
|
||||
This event is currently at capacity. Join the waitlist to be notified
|
||||
if spots become available.
|
||||
</p>
|
||||
<button class="btn" @click="handleJoinWaitlist">
|
||||
Join Waitlist
|
||||
</button>
|
||||
<button class="btn" @click="handleJoinWaitlist">Join Waitlist</button>
|
||||
</div>
|
||||
|
||||
<!-- Sold Out (No Waitlist) -->
|
||||
<div v-else-if="!ticketInfo.available" class="ticket-panel">
|
||||
<div class="box-title">Tickets</div>
|
||||
<p class="ticket-status" style="color: var(--ember)">
|
||||
Event Sold Out
|
||||
</p>
|
||||
<p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
|
||||
<p class="ticket-detail">
|
||||
Unfortunately, this event is at capacity and no longer accepting
|
||||
registrations.
|
||||
|
|
@ -224,13 +219,17 @@ const props = defineProps({
|
|||
required: true,
|
||||
},
|
||||
eventStartDate: {
|
||||
type: Date,
|
||||
type: [String, Date],
|
||||
required: true,
|
||||
},
|
||||
eventTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
eventTimezone: {
|
||||
type: String,
|
||||
default: "America/Toronto",
|
||||
},
|
||||
userEmail: {
|
||||
type: String,
|
||||
default: null,
|
||||
|
|
@ -307,7 +306,9 @@ const fetchTicketInfo = async (emailOverride = null) => {
|
|||
}
|
||||
|
||||
// Regular ticket availability check
|
||||
const params = effectiveEmail ? `?email=${encodeURIComponent(effectiveEmail)}` : "";
|
||||
const params = effectiveEmail
|
||||
? `?email=${encodeURIComponent(effectiveEmail)}`
|
||||
: "";
|
||||
const response = await $fetch(
|
||||
`/api/events/${props.eventId}/tickets/available${params}`,
|
||||
);
|
||||
|
|
@ -415,6 +416,7 @@ const formatEventDate = (date) => {
|
|||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: props.eventTimezone || "America/Toronto",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<div v-if="events?.length" class="em-rows">
|
||||
<div v-for="event in events" :key="event._id" class="em-item">
|
||||
<div class="em-inset em-item-body">
|
||||
<span class="em-date">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="em-date">{{ formatDate(event) }}</span>
|
||||
<NuxtLink
|
||||
:to="`/events/${event.slug || event._id}`"
|
||||
class="em-title"
|
||||
|
|
@ -37,10 +37,13 @@ defineProps({
|
|||
events: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -77,12 +77,7 @@
|
|||
<input
|
||||
:value="modelValue.alt || ''"
|
||||
placeholder="Describe this image..."
|
||||
class="w-full px-3 py-2"
|
||||
style="
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
"
|
||||
class="w-full px-3 py-2 alt-text-input"
|
||||
@input="updateAltText($event.target.value)"
|
||||
>
|
||||
</div>
|
||||
|
|
@ -225,3 +220,16 @@ const updateAltText = (altText) => {
|
|||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alt-text-input {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.alt-text-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--candle);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,71 +1,40 @@
|
|||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="relative">
|
||||
<div class="natural-date-input">
|
||||
<UInput
|
||||
v-model="naturalInput"
|
||||
:model-value="rawInput"
|
||||
:placeholder="placeholder"
|
||||
:color="
|
||||
hasError && naturalInput.trim()
|
||||
? 'error'
|
||||
: isValidParse && naturalInput.trim()
|
||||
? 'success'
|
||||
: undefined
|
||||
"
|
||||
@input="parseNaturalInput"
|
||||
@blur="onBlur"
|
||||
:color="trailingState"
|
||||
@update:model-value="onInputChange"
|
||||
>
|
||||
<template #trailing>
|
||||
<Icon
|
||||
v-if="isValidParse && naturalInput.trim()"
|
||||
v-if="isValid && rawInput.trim()"
|
||||
name="heroicons:check-circle"
|
||||
class="w-5 h-5"
|
||||
style="color: var(--candle)"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="hasError && naturalInput.trim()"
|
||||
v-else-if="hasError && rawInput.trim()"
|
||||
name="heroicons:exclamation-circle"
|
||||
class="w-5 h-5"
|
||||
style="color: var(--ember)"
|
||||
/>
|
||||
</template>
|
||||
</UInput>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="parsedDate && isValidParse"
|
||||
class="text-sm px-3 py-2"
|
||||
style="color: var(--candle); background: color-mix(in srgb, var(--candle) 15%, transparent); border: 1px solid var(--candle)"
|
||||
<p
|
||||
v-if="rawInput.trim() && isValid"
|
||||
class="preview-line"
|
||||
style="color: var(--candle)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="heroicons:calendar" class="w-4 h-4" />
|
||||
<span>{{ formatParsedDate(parsedDate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasError && naturalInput.trim()"
|
||||
class="text-sm px-3 py-2"
|
||||
style="color: var(--ember); background: color-mix(in srgb, var(--ember) 15%, transparent); border: 1px solid var(--ember)"
|
||||
→ {{ previewText }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="rawInput.trim() && hasError"
|
||||
class="preview-line"
|
||||
style="color: var(--ember)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="heroicons:exclamation-triangle" class="w-4 h-4" />
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fallback datetime-local input -->
|
||||
<details class="text-sm">
|
||||
<summary class="cursor-pointer" style="color: var(--text-dim)">
|
||||
Use traditional date picker
|
||||
</summary>
|
||||
<div class="mt-2">
|
||||
<UInput
|
||||
v-model="datetimeValue"
|
||||
type="datetime-local"
|
||||
@change="onDatetimeChange"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -73,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
|
||||
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;
|
||||
});
|
||||
|
||||
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,
|
||||
(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();
|
||||
}
|
||||
(next) => {
|
||||
const tz = activeTZ();
|
||||
const expected = previewDate.value
|
||||
? utcToZonedLocal(previewDate.value, tz)
|
||||
: "";
|
||||
if (next === expected) return;
|
||||
seedFromModelValue();
|
||||
},
|
||||
);
|
||||
|
||||
const parseNaturalInput = () => {
|
||||
const input = naturalInput.value.trim();
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
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 onInputChange = (value) => {
|
||||
rawInput.value = value;
|
||||
parse(value);
|
||||
};
|
||||
|
||||
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;
|
||||
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", "");
|
||||
};
|
||||
|
||||
const setError = (message) => {
|
||||
isValidParse.value = false;
|
||||
hasError.value = true;
|
||||
errorMessage.value = message;
|
||||
parsedDate.value = null;
|
||||
// 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 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 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 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", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
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",
|
||||
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);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.natural-date-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-line {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@
|
|||
class="breadcrumb-link"
|
||||
>{{ crumb.label }}</NuxtLink
|
||||
>
|
||||
<span v-else class="breadcrumb-current">{{ crumb.label }}</span>
|
||||
<ClientOnly v-else>
|
||||
<span class="breadcrumb-current">{{ crumb.label }}</span>
|
||||
<template #fallback>
|
||||
<span class="breadcrumb-current"> </span>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
</span>
|
||||
</slot>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
58
app/composables/useSiteMeta.js
Normal file
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
21
app/config/eventTypes.js
Normal file
|
|
@ -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 || "";
|
||||
}
|
||||
8
app/config/memberStatus.js
Normal file
|
|
@ -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";
|
||||
|
|
@ -217,6 +217,8 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
useSiteMeta({ title: "Admin", noindex: true });
|
||||
|
||||
const route = useRoute();
|
||||
const isMobileMenuOpen = ref(false);
|
||||
const { logout } = useAuth();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Logged-in admins bypass coming-soon (and see the public site + their dashboard)
|
||||
try {
|
||||
const headers = import.meta.server ? useRequestHeaders(["cookie"]) : undefined;
|
||||
const member = await $fetch("/api/auth/member", { headers });
|
||||
if (member?.role === "admin") return;
|
||||
} catch {
|
||||
// Not authenticated — fall through to redirect
|
||||
}
|
||||
|
||||
// Redirect all other routes to coming-soon
|
||||
return navigateTo("/coming-soon");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -104,7 +104,13 @@
|
|||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
<script setup>
|
||||
useSiteMeta({
|
||||
title: 'About',
|
||||
description:
|
||||
'A membership community for game developers exploring cooperative models. Three circles, pay what you can. A program of Baby Ghosts, a Canadian non-profit advancing cooperative practice in the game industry since 2023.',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ---- ABOUT HERO ---- */
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ import {
|
|||
} from "~/config/contributions";
|
||||
|
||||
definePageMeta({ layout: false });
|
||||
useSiteMeta({ title: "Accept Invitation", noindex: true });
|
||||
|
||||
const { checkMemberStatus } = useAuth();
|
||||
const { initializeHelcimPay, verifyPayment } = useHelcimPay();
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@
|
|||
</div>
|
||||
|
||||
<form @submit.prevent="saveEvent">
|
||||
<div class="form-layout">
|
||||
<div class="form-main">
|
||||
<!-- Basic Information -->
|
||||
<div class="form-section">
|
||||
<h2 class="section-heading">Basic Information</h2>
|
||||
|
|
@ -38,6 +40,7 @@
|
|||
placeholder="Enter a clear, descriptive event title"
|
||||
required
|
||||
:color="fieldErrors.title ? 'error' : undefined"
|
||||
:ui="{ base: 'title-input' }"
|
||||
class="w-full"
|
||||
/>
|
||||
<p v-if="fieldErrors.title" class="field-error">
|
||||
|
|
@ -60,7 +63,8 @@
|
|||
v-model="eventForm.description"
|
||||
placeholder="Provide a clear description of what attendees can expect from this event"
|
||||
required
|
||||
:rows="4"
|
||||
:rows="8"
|
||||
autoresize
|
||||
:color="fieldErrors.description ? 'error' : undefined"
|
||||
class="w-full"
|
||||
/>
|
||||
|
|
@ -77,7 +81,8 @@
|
|||
<UTextarea
|
||||
v-model="eventForm.content"
|
||||
placeholder="Add detailed information, agenda, requirements, or other important details"
|
||||
:rows="6"
|
||||
:rows="12"
|
||||
autoresize
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="help-text">
|
||||
|
|
@ -85,6 +90,21 @@
|
|||
requirements
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Event Agenda</label>
|
||||
<UTextarea
|
||||
v-model="agendaText"
|
||||
placeholder="Introduction and welcome - 10 mins Main talk - 30 mins Q&A - 15 mins"
|
||||
:rows="6"
|
||||
autoresize
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="help-text">
|
||||
One agenda item per line. Help attendees know what to expect
|
||||
during the event.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Details -->
|
||||
|
|
@ -97,12 +117,7 @@
|
|||
<USelect
|
||||
v-model="eventForm.eventType"
|
||||
aria-label="Event type"
|
||||
:items="[
|
||||
{ label: 'Community Meetup', value: 'community' },
|
||||
{ label: 'Workshop', value: 'workshop' },
|
||||
{ label: 'Social Event', value: 'social' },
|
||||
{ label: 'Showcase', value: 'showcase' },
|
||||
]"
|
||||
:items="EVENT_TYPES"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="help-text">
|
||||
|
|
@ -111,19 +126,32 @@
|
|||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label> Location <span class="required">*</span> </label>
|
||||
<UInput
|
||||
v-model="eventForm.location"
|
||||
placeholder="e.g., https://zoom.us/j/123... or #channel-name"
|
||||
required
|
||||
:color="fieldErrors.location ? 'error' : undefined"
|
||||
<label> Event Timezone <span class="required">*</span> </label>
|
||||
<USelectMenu
|
||||
v-model="eventForm.displayTimezone"
|
||||
:items="timezoneItems"
|
||||
value-key="value"
|
||||
searchable
|
||||
searchable-placeholder="Search timezones..."
|
||||
placeholder="Select a timezone"
|
||||
class="w-full"
|
||||
/>
|
||||
<p v-if="fieldErrors.location" class="field-error">
|
||||
{{ fieldErrors.location }}
|
||||
</p>
|
||||
<p class="help-text">
|
||||
Enter a video conference link or Slack channel (starting with #)
|
||||
Dates below are interpreted in this timezone. Attendees see the
|
||||
event time in this zone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Location</label>
|
||||
<UInput
|
||||
v-model="eventForm.location"
|
||||
placeholder="e.g., https://zoom.us/j/123..., #channel-name, or TBD"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="help-text">
|
||||
Video conference link, Slack channel (#channel-name), or 'TBD' if
|
||||
the platform is undecided
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -131,6 +159,7 @@
|
|||
<label> Start Date & Time <span class="required">*</span> </label>
|
||||
<NaturalDateInput
|
||||
v-model="eventForm.startDate"
|
||||
:display-timezone="eventForm.displayTimezone"
|
||||
placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
|
||||
:required="true"
|
||||
/>
|
||||
|
|
@ -143,6 +172,7 @@
|
|||
<label> End Date & Time <span class="required">*</span> </label>
|
||||
<NaturalDateInput
|
||||
v-model="eventForm.endDate"
|
||||
:display-timezone="eventForm.displayTimezone"
|
||||
placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
|
||||
:required="true"
|
||||
/>
|
||||
|
|
@ -169,6 +199,7 @@
|
|||
<label>Registration Deadline</label>
|
||||
<NaturalDateInput
|
||||
v-model="eventForm.registrationDeadline"
|
||||
:display-timezone="eventForm.displayTimezone"
|
||||
placeholder="e.g., 'tomorrow at noon', '1 hour before event'"
|
||||
/>
|
||||
<p class="help-text">
|
||||
|
|
@ -178,6 +209,87 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Settings -->
|
||||
<div class="form-section">
|
||||
<h2 class="section-heading">Event Settings</h2>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="check-group">
|
||||
<label class="check-label">
|
||||
<input v-model="eventForm.isOnline" type="checkbox" >
|
||||
<div>
|
||||
<strong>Online Event</strong>
|
||||
<span class="help-text">
|
||||
Event will be conducted virtually
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="check-label">
|
||||
<input
|
||||
v-model="eventForm.registrationRequired"
|
||||
type="checkbox"
|
||||
>
|
||||
<div>
|
||||
<strong>Registration Required</strong>
|
||||
<span class="help-text">
|
||||
Attendees must register before attending
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="check-group">
|
||||
<label class="check-label">
|
||||
<input v-model="eventForm.isVisible" type="checkbox" >
|
||||
<div>
|
||||
<strong>Visible on Public Calendar</strong>
|
||||
<span class="help-text">
|
||||
Event will appear on the public events page
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="check-label">
|
||||
<input v-model="eventForm.isCancelled" type="checkbox" >
|
||||
<div>
|
||||
<strong>Event Cancelled</strong>
|
||||
<span class="help-text"> Mark this event as cancelled </span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="check-label">
|
||||
<input v-model="eventForm.membersOnly" type="checkbox" >
|
||||
<div>
|
||||
<strong>Members Only</strong>
|
||||
<span class="help-text">
|
||||
Hide this event from the public; only members can see it
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancellation Message (conditional) -->
|
||||
<div v-if="eventForm.isCancelled" class="form-section">
|
||||
<div class="field">
|
||||
<label>Cancellation Message</label>
|
||||
<UTextarea
|
||||
v-model="eventForm.cancellationMessage"
|
||||
placeholder="Explain why the event was cancelled and any next steps..."
|
||||
:rows="3"
|
||||
color="error"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="help-text">
|
||||
This message will be displayed to users viewing the event page
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="form-aside">
|
||||
<!-- Target Audience -->
|
||||
<div class="form-section">
|
||||
<h2 class="section-heading">Target Audience</h2>
|
||||
|
|
@ -190,39 +302,24 @@
|
|||
v-model="eventForm.targetCircles"
|
||||
value="community"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div>
|
||||
>
|
||||
<strong>Community Circle</strong>
|
||||
<span class="help-text">
|
||||
New members and those exploring the community
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="check-label">
|
||||
<input
|
||||
v-model="eventForm.targetCircles"
|
||||
value="founder"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div>
|
||||
>
|
||||
<strong>Founder Circle</strong>
|
||||
<span class="help-text">
|
||||
Entrepreneurs and business leaders
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="check-label">
|
||||
<input
|
||||
v-model="eventForm.targetCircles"
|
||||
value="practitioner"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div>
|
||||
>
|
||||
<strong>Practitioner Circle</strong>
|
||||
<span class="help-text">
|
||||
Experts and professionals sharing knowledge
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<p class="help-text">
|
||||
|
|
@ -243,11 +340,30 @@
|
|||
:items="tagOptions"
|
||||
value-key="value"
|
||||
multiple
|
||||
placeholder="Select tags..."
|
||||
searchable
|
||||
create-item
|
||||
placeholder="Select or type to add tags..."
|
||||
class="w-full"
|
||||
@create="onTagCreate"
|
||||
/>
|
||||
<div class="field new-tag-pool">
|
||||
<label>New tag pool</label>
|
||||
<USelect
|
||||
v-model="newTagPool"
|
||||
:items="[
|
||||
{ label: 'Cooperative', value: 'cooperative' },
|
||||
{ label: 'Craft', value: 'craft' },
|
||||
]"
|
||||
value-key="value"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="help-text">
|
||||
Tag this event to help with discovery and recommendations
|
||||
Pool assigned to any new tag you create from this field.
|
||||
</p>
|
||||
</div>
|
||||
<p class="help-text">
|
||||
Tag this event to help with discovery and recommendations. Type a
|
||||
new tag and press enter to add it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -257,7 +373,7 @@
|
|||
<h2 class="section-heading">Ticketing</h2>
|
||||
|
||||
<label class="check-label">
|
||||
<input v-model="eventForm.tickets.enabled" type="checkbox" />
|
||||
<input v-model="eventForm.tickets.enabled" type="checkbox" >
|
||||
<div>
|
||||
<strong>Enable Ticketing</strong>
|
||||
<span class="help-text"> Allow ticket sales for this event </span>
|
||||
|
|
@ -269,7 +385,7 @@
|
|||
<input
|
||||
v-model="eventForm.tickets.public.available"
|
||||
type="checkbox"
|
||||
/>
|
||||
>
|
||||
<div>
|
||||
<strong>Public Tickets Available</strong>
|
||||
<span class="help-text">
|
||||
|
|
@ -278,6 +394,12 @@
|
|||
</div>
|
||||
</label>
|
||||
|
||||
<div class="note-box">
|
||||
<strong>Note:</strong> Public ticket pricing applies to non-members.
|
||||
Members register for events from their dashboard at no charge,
|
||||
regardless of public ticket settings.
|
||||
</div>
|
||||
|
||||
<div v-if="eventForm.tickets.public.available">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
|
|
@ -345,6 +467,7 @@
|
|||
<label>Early Bird Deadline</label>
|
||||
<NaturalDateInput
|
||||
v-model="eventForm.tickets.public.earlyBirdDeadline"
|
||||
:display-timezone="eventForm.displayTimezone"
|
||||
placeholder="e.g., '1 week before event', 'next Monday'"
|
||||
/>
|
||||
<p class="help-text">
|
||||
|
|
@ -353,11 +476,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note-box">
|
||||
<strong>Note:</strong> Members always get free access to all events
|
||||
regardless of ticket settings.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series Management -->
|
||||
|
|
@ -365,7 +483,7 @@
|
|||
<h2 class="section-heading">Series Management</h2>
|
||||
|
||||
<label class="check-label">
|
||||
<input v-model="eventForm.series.isSeriesEvent" type="checkbox" />
|
||||
<input v-model="eventForm.series.isSeriesEvent" type="checkbox" >
|
||||
<div>
|
||||
<strong>Part of Event Series</strong>
|
||||
<span class="help-text">
|
||||
|
|
@ -381,7 +499,6 @@
|
|||
<USelect
|
||||
v-model="selectedSeriesId"
|
||||
aria-label="Select series"
|
||||
@update:model-value="onSeriesSelect"
|
||||
:items="
|
||||
availableSeries.map((series) => ({
|
||||
label: `${series.title} (${series.eventCount || 0} events)`,
|
||||
|
|
@ -391,6 +508,7 @@
|
|||
placeholder="Choose existing series or create new..."
|
||||
value-key="value"
|
||||
class="w-full"
|
||||
@update:model-value="onSeriesSelect"
|
||||
/>
|
||||
<NuxtLink to="/admin/series/create" class="btn btn-primary">
|
||||
New Series
|
||||
|
|
@ -448,113 +566,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Agenda -->
|
||||
<div class="form-section">
|
||||
<h2 class="section-heading">Event Agenda</h2>
|
||||
|
||||
<div class="agenda-items">
|
||||
<div
|
||||
v-for="(item, index) in eventForm.agenda"
|
||||
:key="index"
|
||||
class="agenda-row"
|
||||
>
|
||||
<UInput
|
||||
v-model="eventForm.agenda[index]"
|
||||
placeholder="Enter agenda item (e.g., 'Introduction and welcome - 10 mins')"
|
||||
class="w-full"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeAgendaItem(index)"
|
||||
class="link-btn link-btn-danger"
|
||||
>
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="addAgendaItem"
|
||||
class="btn add-agenda-btn"
|
||||
>
|
||||
+ Add Agenda Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="help-text">
|
||||
Add agenda items to help attendees know what to expect during the
|
||||
event
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Event Settings -->
|
||||
<div class="form-section">
|
||||
<h2 class="section-heading">Event Settings</h2>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="check-group">
|
||||
<label class="check-label">
|
||||
<input v-model="eventForm.isOnline" type="checkbox" />
|
||||
<div>
|
||||
<strong>Online Event</strong>
|
||||
<span class="help-text">
|
||||
Event will be conducted virtually
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="check-label">
|
||||
<input
|
||||
v-model="eventForm.registrationRequired"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div>
|
||||
<strong>Registration Required</strong>
|
||||
<span class="help-text">
|
||||
Attendees must register before attending
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="check-group">
|
||||
<label class="check-label">
|
||||
<input v-model="eventForm.isVisible" type="checkbox" />
|
||||
<div>
|
||||
<strong>Visible on Public Calendar</strong>
|
||||
<span class="help-text">
|
||||
Event will appear on the public events page
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="check-label">
|
||||
<input v-model="eventForm.isCancelled" type="checkbox" />
|
||||
<div>
|
||||
<strong>Event Cancelled</strong>
|
||||
<span class="help-text"> Mark this event as cancelled </span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancellation Message (conditional) -->
|
||||
<div v-if="eventForm.isCancelled" class="form-section">
|
||||
<div class="field">
|
||||
<label>Cancellation Message</label>
|
||||
<UTextarea
|
||||
v-model="eventForm.cancellationMessage"
|
||||
placeholder="Explain why the event was cancelled and any next steps..."
|
||||
:rows="3"
|
||||
color="error"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="help-text">
|
||||
This message will be displayed to users viewing the event page
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
|
|
@ -565,9 +577,9 @@
|
|||
<button
|
||||
v-if="!editingEvent"
|
||||
type="button"
|
||||
@click="saveAndCreateAnother"
|
||||
:disabled="creating"
|
||||
class="btn"
|
||||
@click="saveAndCreateAnother"
|
||||
>
|
||||
{{ creating ? "Saving..." : "Save & Create Another" }}
|
||||
</button>
|
||||
|
|
@ -589,6 +601,9 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TIMEZONE_OPTIONS } from "~/config/timezones";
|
||||
import { EVENT_TYPES } from "~/config/eventTypes";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
middleware: "admin",
|
||||
|
|
@ -607,9 +622,32 @@ const availableSeries = ref([]);
|
|||
const availableTags = ref([]);
|
||||
|
||||
const tagOptions = computed(() =>
|
||||
availableTags.value.map((t) => ({ label: t.label, value: t.slug }))
|
||||
availableTags.value.map((t) => ({ label: t.label, value: t.slug })),
|
||||
);
|
||||
|
||||
const newTagPool = ref("cooperative");
|
||||
|
||||
const onTagCreate = async (item) => {
|
||||
const label = typeof item === "string" ? item : item?.label || item?.value;
|
||||
if (!label?.trim()) return;
|
||||
try {
|
||||
const { tag } = await $fetch("/api/admin/tags", {
|
||||
method: "POST",
|
||||
body: { label: label.trim(), pool: newTagPool.value },
|
||||
});
|
||||
if (!availableTags.value.some((t) => t.slug === tag.slug)) {
|
||||
availableTags.value.push({ slug: tag.slug, label: tag.label });
|
||||
}
|
||||
if (!eventForm.tags.includes(tag.slug)) {
|
||||
eventForm.tags.push(tag.slug);
|
||||
}
|
||||
} catch (err) {
|
||||
formErrors.value.push(
|
||||
`Failed to create tag "${label}": ${err?.data?.statusMessage || err?.statusMessage || err?.message || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const eventForm = reactive({
|
||||
title: "",
|
||||
description: "",
|
||||
|
|
@ -617,11 +655,13 @@ const eventForm = reactive({
|
|||
featureImage: null,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
eventType: "community",
|
||||
eventType: "community-meetup",
|
||||
displayTimezone: "America/Toronto",
|
||||
location: "",
|
||||
isOnline: true,
|
||||
isVisible: true,
|
||||
isCancelled: false,
|
||||
membersOnly: false,
|
||||
cancellationMessage: "",
|
||||
targetCircles: [],
|
||||
tags: [],
|
||||
|
|
@ -649,15 +689,57 @@ const eventForm = reactive({
|
|||
},
|
||||
});
|
||||
|
||||
// Agenda management functions
|
||||
const addAgendaItem = () => {
|
||||
eventForm.agenda.push("");
|
||||
// Format a Date/ISO value into a datetime-local string using local-time components.
|
||||
// `toISOString().slice(0,16)` drifts by the browser's UTC offset on edit round-trip.
|
||||
const formatForDatetimeLocal = (value) => {
|
||||
if (!value) return "";
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
const removeAgendaItem = (index) => {
|
||||
eventForm.agenda.splice(index, 1);
|
||||
// Render the form's datetime fields in the event's display timezone.
|
||||
const formatForEventTZ = (value) => {
|
||||
if (!value) return "";
|
||||
return utcToZonedLocal(value, eventForm.displayTimezone) || formatForDatetimeLocal(value);
|
||||
};
|
||||
|
||||
const utcOffsetLabel = (tz) => {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
timeZoneName: "longOffset",
|
||||
}).formatToParts(new Date());
|
||||
const name = parts.find((p) => p.type === "timeZoneName")?.value || "";
|
||||
if (name === "GMT") return "UTC+00:00";
|
||||
return name.replace("GMT", "UTC");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const timezoneItems = computed(() => {
|
||||
const list = TIMEZONE_OPTIONS.map((t) => {
|
||||
const off = utcOffsetLabel(t.value);
|
||||
return { ...t, label: off ? `${t.label} (${off})` : t.label };
|
||||
});
|
||||
const saved = eventForm.displayTimezone;
|
||||
if (saved && !TIMEZONE_OPTIONS.some((t) => t.value === saved)) {
|
||||
list.unshift({ label: saved, value: saved });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const agendaText = computed({
|
||||
get() {
|
||||
return (eventForm.agenda || []).join("\n");
|
||||
},
|
||||
set(v) {
|
||||
eventForm.agenda = v.split("\n");
|
||||
},
|
||||
});
|
||||
|
||||
// Load available series and tags
|
||||
onMounted(async () => {
|
||||
try {
|
||||
|
|
@ -698,34 +780,32 @@ const onSeriesSelect = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Check if we're editing an event
|
||||
if (route.query.edit) {
|
||||
try {
|
||||
const response = await $fetch(`/api/admin/events/${route.query.edit}`);
|
||||
const event = response.data;
|
||||
|
||||
if (event) {
|
||||
function populateEditForm(payload) {
|
||||
const event = payload?.data;
|
||||
if (!event) return;
|
||||
editingEvent.value = event;
|
||||
// Pin the form's timezone first so subsequent date conversions use it.
|
||||
eventForm.displayTimezone = event.displayTimezone || "America/Toronto";
|
||||
Object.assign(eventForm, {
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
content: event.content || "",
|
||||
featureImage: event.featureImage || null,
|
||||
startDate: new Date(event.startDate).toISOString().slice(0, 16),
|
||||
endDate: new Date(event.endDate).toISOString().slice(0, 16),
|
||||
startDate: utcToZonedLocal(event.startDate, eventForm.displayTimezone),
|
||||
endDate: utcToZonedLocal(event.endDate, eventForm.displayTimezone),
|
||||
eventType: event.eventType,
|
||||
displayTimezone: eventForm.displayTimezone,
|
||||
location: event.location || "",
|
||||
isOnline: event.isOnline,
|
||||
isVisible: event.isVisible !== undefined ? event.isVisible : true,
|
||||
isCancelled: event.isCancelled || false,
|
||||
membersOnly: event.membersOnly || false,
|
||||
cancellationMessage: event.cancellationMessage || "",
|
||||
targetCircles: event.targetCircles || [],
|
||||
tags: event.tags || [],
|
||||
maxAttendees: event.maxAttendees || "",
|
||||
registrationRequired: event.registrationRequired,
|
||||
registrationDeadline: event.registrationDeadline
|
||||
? new Date(event.registrationDeadline).toISOString().slice(0, 16)
|
||||
: "",
|
||||
registrationDeadline: utcToZonedLocal(event.registrationDeadline, eventForm.displayTimezone),
|
||||
agenda: event.agenda || [],
|
||||
tickets: event.tickets || {
|
||||
enabled: false,
|
||||
|
|
@ -746,22 +826,30 @@ if (route.query.edit) {
|
|||
description: "",
|
||||
},
|
||||
});
|
||||
// Handle early bird deadline formatting
|
||||
if (event.tickets?.public?.earlyBirdDeadline) {
|
||||
eventForm.tickets.public.earlyBirdDeadline = new Date(
|
||||
eventForm.tickets.public.earlyBirdDeadline = utcToZonedLocal(
|
||||
event.tickets.public.earlyBirdDeadline,
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load event for editing:", error);
|
||||
eventForm.displayTimezone,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// useFetch forwards auth cookies to SSR; $fetch did not, leaving the
|
||||
// SSR-rendered form empty and triggering hydration mismatches that left
|
||||
// required textareas DOM-empty in dev.
|
||||
if (route.query.edit) {
|
||||
const { data: editEvent, error: editError } = await useFetch(
|
||||
`/api/admin/events/${route.query.edit}`,
|
||||
);
|
||||
if (editError.value) {
|
||||
console.error("Failed to load event for editing:", editError.value);
|
||||
}
|
||||
if (editEvent.value) populateEditForm(editEvent.value);
|
||||
watch(editEvent, populateEditForm, { immediate: false });
|
||||
}
|
||||
|
||||
// Check if we're duplicating an event
|
||||
if (route.query.duplicate && process.client) {
|
||||
if (route.query.duplicate && import.meta.client) {
|
||||
const duplicateData = sessionStorage.getItem("duplicateEventData");
|
||||
if (duplicateData) {
|
||||
try {
|
||||
|
|
@ -775,7 +863,7 @@ if (route.query.duplicate && process.client) {
|
|||
}
|
||||
|
||||
// Check if we're creating a series event
|
||||
if (route.query.series && process.client) {
|
||||
if (route.query.series && import.meta.client) {
|
||||
const seriesData = sessionStorage.getItem("seriesEventData");
|
||||
if (seriesData) {
|
||||
try {
|
||||
|
|
@ -814,12 +902,6 @@ const validateForm = () => {
|
|||
fieldErrors.value.endDate = "Please select when the event ends";
|
||||
}
|
||||
|
||||
if (!eventForm.location.trim()) {
|
||||
formErrors.value.push("Location is required");
|
||||
fieldErrors.value.location =
|
||||
"Please enter a location (URL or Slack channel)";
|
||||
}
|
||||
|
||||
// Date validation
|
||||
if (eventForm.startDate && eventForm.endDate) {
|
||||
const startDate = new Date(eventForm.startDate);
|
||||
|
|
@ -836,23 +918,6 @@ const validateForm = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Location format validation
|
||||
if (eventForm.location.trim()) {
|
||||
const urlPattern = /^https?:\/\/.+/;
|
||||
const slackPattern = /^#[a-zA-Z0-9-_]+$/;
|
||||
|
||||
if (
|
||||
!urlPattern.test(eventForm.location) &&
|
||||
!slackPattern.test(eventForm.location)
|
||||
) {
|
||||
formErrors.value.push(
|
||||
"Location must be a valid URL or Slack channel (starting with #)",
|
||||
);
|
||||
fieldErrors.value.location =
|
||||
"Enter a video conference link (https://...) or Slack channel (#channel-name)";
|
||||
}
|
||||
}
|
||||
|
||||
// Registration deadline validation
|
||||
if (eventForm.registrationDeadline && eventForm.startDate) {
|
||||
const regDeadline = new Date(eventForm.registrationDeadline);
|
||||
|
|
@ -887,15 +952,40 @@ const saveEvent = async (redirect = true) => {
|
|||
// Individual series creation is handled through the series management page
|
||||
}
|
||||
|
||||
const tz = eventForm.displayTimezone || "America/Toronto";
|
||||
const toUTC = (v) => {
|
||||
const d = zonedLocalToUTC(v, tz);
|
||||
return d ? d.toISOString() : v;
|
||||
};
|
||||
const payload = {
|
||||
...eventForm,
|
||||
startDate: toUTC(eventForm.startDate),
|
||||
endDate: toUTC(eventForm.endDate),
|
||||
registrationDeadline: eventForm.registrationDeadline
|
||||
? toUTC(eventForm.registrationDeadline)
|
||||
: eventForm.registrationDeadline,
|
||||
agenda: (eventForm.agenda || [])
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean),
|
||||
tickets: {
|
||||
...eventForm.tickets,
|
||||
public: {
|
||||
...eventForm.tickets.public,
|
||||
earlyBirdDeadline: eventForm.tickets.public.earlyBirdDeadline
|
||||
? toUTC(eventForm.tickets.public.earlyBirdDeadline)
|
||||
: eventForm.tickets.public.earlyBirdDeadline,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (editingEvent.value) {
|
||||
await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
|
||||
method: "PUT",
|
||||
body: eventForm,
|
||||
body: payload,
|
||||
});
|
||||
} else {
|
||||
await $fetch("/api/admin/events", {
|
||||
method: "POST",
|
||||
body: eventForm,
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -934,11 +1024,13 @@ const saveAndCreateAnother = async () => {
|
|||
featureImage: null,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
eventType: "community",
|
||||
eventType: "community-meetup",
|
||||
displayTimezone: "America/Toronto",
|
||||
location: "",
|
||||
isOnline: true,
|
||||
isVisible: true,
|
||||
isCancelled: false,
|
||||
membersOnly: false,
|
||||
cancellationMessage: "",
|
||||
targetCircles: [],
|
||||
tags: [],
|
||||
|
|
@ -978,7 +1070,42 @@ const saveAndCreateAnother = async () => {
|
|||
|
||||
<style scoped>
|
||||
.create-form {
|
||||
max-width: 800px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Vertical divider between main + aside, full viewport height */
|
||||
.create-form::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 340px;
|
||||
border-left: 1px dashed var(--border);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.form-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-main {
|
||||
min-width: 0;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.form-aside {
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.form-aside .form-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
|
@ -1015,7 +1142,21 @@ const saveAndCreateAnother = async () => {
|
|||
}
|
||||
|
||||
.form-body {
|
||||
padding: 24px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-body > .error-box,
|
||||
.form-body > .success-box {
|
||||
margin: 24px 28px 0;
|
||||
}
|
||||
|
||||
.form-body > form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
|
|
@ -1023,7 +1164,9 @@ const saveAndCreateAnother = async () => {
|
|||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
padding-bottom: 10px;
|
||||
margin-left: -28px;
|
||||
margin-right: -28px;
|
||||
padding: 0 28px 10px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
|
@ -1121,7 +1264,7 @@ const saveAndCreateAnother = async () => {
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 20px;
|
||||
padding: 20px 28px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
|
|
@ -1154,59 +1297,50 @@ const saveAndCreateAnother = async () => {
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
.agenda-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agenda-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agenda-row .w-full {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--candle);
|
||||
cursor: pointer;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.link-btn-danger {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.add-agenda-btn {
|
||||
align-self: flex-start;
|
||||
color: var(--candle);
|
||||
border-color: var(--candle);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.btn:disabled,
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:deep(.title-input) {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 24px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.create-form::after {
|
||||
display: none;
|
||||
}
|
||||
.form-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.form-aside {
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
padding: 24px 20px 16px;
|
||||
}
|
||||
.form-body {
|
||||
padding: 20px;
|
||||
.form-main,
|
||||
.form-aside,
|
||||
.form-actions {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
.form-body > .error-box,
|
||||
.form-body > .success-box {
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
.section-heading {
|
||||
margin-left: -20px;
|
||||
margin-right: -20px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
|
|||
|
|
@ -16,15 +16,12 @@
|
|||
<!-- Filters -->
|
||||
<div class="filter-bar">
|
||||
<div class="field" style="margin-bottom: 0; flex: 1;">
|
||||
<input v-model="searchQuery" placeholder="Search events..." />
|
||||
<input v-model="searchQuery" placeholder="Search events..." >
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 0;">
|
||||
<select v-model="typeFilter">
|
||||
<option value="all">All Types</option>
|
||||
<option value="community">Community</option>
|
||||
<option value="workshop">Workshop</option>
|
||||
<option value="social">Social</option>
|
||||
<option value="showcase">Showcase</option>
|
||||
<option v-for="t in EVENT_TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 0;">
|
||||
|
|
@ -71,7 +68,7 @@
|
|||
<td class="col-title">
|
||||
<div class="event-title-cell">
|
||||
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
|
||||
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" />
|
||||
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" >
|
||||
</div>
|
||||
<div>
|
||||
<span class="event-name">{{ event.title }}</span>
|
||||
|
|
@ -89,11 +86,11 @@
|
|||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
|
||||
</td>
|
||||
<td class="col-date">
|
||||
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
||||
<span class="date-main">{{ formatDate(event) }}</span>
|
||||
<span class="date-time">{{ formatTime(event) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||
|
|
@ -128,9 +125,9 @@
|
|||
</td>
|
||||
<td class="col-actions">
|
||||
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
|
||||
<button @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
|
||||
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
|
||||
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
|
||||
<button class="link-btn" title="Edit" @click="editEvent(event)">Edit</button>
|
||||
<button class="link-btn" title="Duplicate" @click="duplicateEvent(event)">Dup</button>
|
||||
<button class="link-btn link-btn-danger" title="Delete" @click="deleteEvent(event)">Del</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -169,7 +166,7 @@
|
|||
<td class="col-title">
|
||||
<div class="event-title-cell">
|
||||
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
|
||||
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" />
|
||||
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" >
|
||||
</div>
|
||||
<div>
|
||||
<span class="event-name">{{ event.title }}</span>
|
||||
|
|
@ -187,11 +184,11 @@
|
|||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
|
||||
</td>
|
||||
<td class="col-date">
|
||||
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
||||
<span class="date-main">{{ formatDate(event) }}</span>
|
||||
<span class="date-time">{{ formatTime(event) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||
|
|
@ -226,9 +223,9 @@
|
|||
</td>
|
||||
<td class="col-actions">
|
||||
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
|
||||
<button @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
|
||||
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
|
||||
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
|
||||
<button class="link-btn" title="Edit" @click="editEvent(event)">Edit</button>
|
||||
<button class="link-btn" title="Duplicate" @click="duplicateEvent(event)">Dup</button>
|
||||
<button class="link-btn link-btn-danger" title="Delete" @click="deleteEvent(event)">Del</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -267,6 +264,8 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { EVENT_TYPES, eventTypeLabel } from '~/config/eventTypes'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
|
|
@ -349,19 +348,23 @@ watch([searchQuery, typeFilter, seriesFilter], () => {
|
|||
pastPage.value = 1
|
||||
})
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
const formatDate = (event) => {
|
||||
if (!event?.startDate) return ''
|
||||
return new Date(event.startDate).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: event.displayTimezone || 'America/Toronto',
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
||||
const formatTime = (event) => {
|
||||
if (!event?.startDate) return ''
|
||||
return new Date(event.startDate).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZone: event.displayTimezone || 'America/Toronto',
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@
|
|||
<span class="item-sub">{{ formatDateTime(event.startDate) }}</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||
<span class="badge" :class="event.eventType">{{ eventTypeLabel(event.eventType) }}</span>
|
||||
<span class="item-date">{{ event.location || 'Online' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -106,6 +106,8 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { eventTypeLabel } from '~/config/eventTypes'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
|
|
|
|||
|
|
@ -63,10 +63,11 @@
|
|||
<div class="field">
|
||||
<label>Status</label>
|
||||
<select v-model="form.status">
|
||||
<option value="pending_payment">pending_payment</option>
|
||||
<option value="active">active</option>
|
||||
<option value="suspended">suspended</option>
|
||||
<option value="cancelled">cancelled</option>
|
||||
<option
|
||||
v-for="(label, value) in STATUS_LABELS"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>{{ label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
|
|
@ -242,6 +243,7 @@
|
|||
|
||||
<script setup>
|
||||
import { formatActivity } from '~/utils/activityText'
|
||||
import { STATUS_LABELS } from '~/config/memberStatus'
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
|
|
|
|||
|
|
@ -41,10 +41,11 @@
|
|||
<div class="field" style="margin-bottom: 0">
|
||||
<select v-model="statusFilter" aria-label="Filter by status">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending_payment">Payment setup incomplete</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option
|
||||
v-for="(label, value) in STATUS_LABELS"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>{{ label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -371,10 +372,11 @@
|
|||
<div class="field">
|
||||
<label>Status</label>
|
||||
<select v-model="editingMember.status">
|
||||
<option value="pending_payment">Payment setup incomplete</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option
|
||||
v-for="(label, value) in STATUS_LABELS"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>{{ label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
|
|
@ -466,6 +468,8 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { STATUS_LABELS, statusLabel } from "~/config/memberStatus";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
middleware: "admin",
|
||||
|
|
@ -486,14 +490,6 @@ const statusFilter = ref("");
|
|||
const sortKey = ref("createdAt");
|
||||
const sortDir = ref("desc");
|
||||
|
||||
const STATUS_LABELS = {
|
||||
active: "Active",
|
||||
pending_payment: "Payment setup incomplete",
|
||||
suspended: "Suspended",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
const statusLabel = (s) => STATUS_LABELS[s] || "Pending";
|
||||
|
||||
const toggleSort = (key) => {
|
||||
if (sortKey.value === key) {
|
||||
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
||||
|
|
@ -843,7 +839,8 @@ const markSlackInvited = async (member) => {
|
|||
body: { slackInvited: true },
|
||||
},
|
||||
);
|
||||
Object.assign(member, res.member);
|
||||
const idx = members.value.findIndex((m) => m._id === member._id);
|
||||
if (idx !== -1) members.value[idx] = { ...members.value[idx], ...res.member };
|
||||
toast.add({ title: "Marked as Slack invited", color: "success" });
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({ layout: false });
|
||||
useHead({ title: "Sign Out — Ghost Guild" });
|
||||
useSiteMeta({ title: "Sign Out", noindex: true });
|
||||
|
||||
// The xsrf token comes from a short-lived httpOnly cookie set by
|
||||
// oidc-provider's logoutSource callback (see server/utils/oidc-provider.ts).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({ layout: false });
|
||||
useHead({ title: "Signed Out — Ghost Guild" });
|
||||
useSiteMeta({ title: "Signed Out", noindex: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({ layout: false });
|
||||
useHead({ title: "Sign-In Error — Ghost Guild" });
|
||||
useSiteMeta({ title: "Sign-In Error", noindex: true });
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
definePageMeta({
|
||||
layout: false,
|
||||
});
|
||||
useSiteMeta({ title: "Wiki Sign In", noindex: true });
|
||||
|
||||
const route = useRoute();
|
||||
const uid = route.query.uid as string;
|
||||
|
|
|
|||
|
|
@ -192,14 +192,10 @@ const loadTags = async () => {
|
|||
cooperativeTags.value = (data.tags || []).filter((t) => t.pool === 'cooperative')
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: 'Board - Ghost Guild',
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Share what you are seeking and offering with the Ghost Guild community.',
|
||||
},
|
||||
],
|
||||
useSiteMeta({
|
||||
title: 'Bulletin Board',
|
||||
description:
|
||||
'The Ghost Guild bulletin board. Members post offers and requests around shared interests and cooperative topics.',
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
`
|
||||
<template>
|
||||
<PageShell title="Community Guidelines" subtitle="What you're agreeing to when you join Ghost Guild">
|
||||
<PageShell
|
||||
title="Community Guidelines"
|
||||
subtitle="What you're agreeing to when you join Ghost Guild"
|
||||
>
|
||||
<div class="guidelines-prose">
|
||||
<section class="guidelines-section">
|
||||
<h2>Welcome</h2>
|
||||
|
|
@ -24,12 +28,12 @@
|
|||
contribute financially.
|
||||
</p>
|
||||
<p>
|
||||
When you join Ghost Guild, you become a Class B member of Baby
|
||||
Ghosts, our parent charity. Class A membership is held by a small
|
||||
group involved in governance, mainly our directors. Class A and
|
||||
Class B have equal access to resources, community, events, and the
|
||||
Solidarity Fund. Voting at the Annual General Meeting is limited
|
||||
to Class A members, as set out in our
|
||||
When you join Ghost Guild, you become a Class B member of Baby Ghosts,
|
||||
our parent charity. Class A membership is held by a small group
|
||||
involved in governance, mainly our directors. Class A and Class B have
|
||||
equal access to resources, community, events, and the Solidarity Fund.
|
||||
Voting at the Annual General Meeting is limited to Class A members, as
|
||||
set out in our
|
||||
<NuxtLink to="/policies/by-laws">by-laws</NuxtLink>.
|
||||
</p>
|
||||
|
||||
|
|
@ -82,7 +86,9 @@
|
|||
Equal access to resources, events, community spaces, and the
|
||||
Solidarity Fund, regardless of circle or contribution level
|
||||
</li>
|
||||
<li>Support from the Solidarity Fund if you face financial barriers</li>
|
||||
<li>
|
||||
Support from the Solidarity Fund if you face financial barriers
|
||||
</li>
|
||||
<li>The ability to move between circles as your journey evolves</li>
|
||||
<li>
|
||||
Privacy protection in line with our
|
||||
|
|
@ -105,8 +111,8 @@
|
|||
at all times
|
||||
</li>
|
||||
<li>
|
||||
Participating within your capacity. This is a community of
|
||||
practice. Show up in whatever way works for you.
|
||||
Participating within your capacity. This is a community of practice.
|
||||
Show up in whatever way works for you.
|
||||
</li>
|
||||
<li>
|
||||
Contributing dues in line with your ability, or working with the
|
||||
|
|
@ -114,7 +120,9 @@
|
|||
</li>
|
||||
<li>
|
||||
Approaching disagreements with openness and using our
|
||||
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>
|
||||
<NuxtLink to="/policies/conflict-resolution"
|
||||
>Conflict Resolution Policy</NuxtLink
|
||||
>
|
||||
when conflicts arise
|
||||
</li>
|
||||
</ol>
|
||||
|
|
@ -126,14 +134,13 @@
|
|||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Don't share screenshots, message content, or other community
|
||||
content externally without the explicit consent of everyone
|
||||
involved
|
||||
Don't share screenshots, message content, or other community content
|
||||
externally without the explicit consent of everyone involved
|
||||
</li>
|
||||
<li>
|
||||
Don't contribute community conversations, messages, or member
|
||||
content to generative AI tools like ChatGPT or Claude. This
|
||||
protects everyone's privacy and contributions.
|
||||
content to generative AI tools like ChatGPT or Claude. This protects
|
||||
everyone's privacy and contributions.
|
||||
</li>
|
||||
<li>
|
||||
Violations of these privacy norms can result in removal from the
|
||||
|
|
@ -149,7 +156,10 @@
|
|||
<a href="https://wiki.ghostguild.org">wiki.ghostguild.org</a> is a
|
||||
knowledge commons. Anything you contribute to it is automatically and
|
||||
irrevocably licensed under the
|
||||
<a href="https://creativecommons.org/licenses/by-sa/4.0/">Creative Commons Attribution-ShareAlike 4.0 International License</a>
|
||||
<a href="https://creativecommons.org/licenses/by-sa/4.0/"
|
||||
>Creative Commons Attribution-ShareAlike 4.0 International
|
||||
License</a
|
||||
>
|
||||
(CC-BY-SA 4.0) at the moment you post it.
|
||||
</p>
|
||||
<p>In plain terms:</p>
|
||||
|
|
@ -162,13 +172,13 @@
|
|||
credit you and release their derivatives under the same license
|
||||
</li>
|
||||
<li>
|
||||
You can't withdraw your contribution from the commons later, even
|
||||
if you leave Ghost Guild
|
||||
You can't withdraw your contribution from the commons later, even if
|
||||
you leave Ghost Guild
|
||||
</li>
|
||||
<li>
|
||||
If wiki material gets republished elsewhere (like on
|
||||
<a href="https://coop.love">coop.love</a>), it stays under
|
||||
CC-BY-SA 4.0 and you stay credited
|
||||
<a href="https://coop.love">coop.love</a>), it stays under CC-BY-SA
|
||||
4.0 and you stay credited
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
|
|
@ -188,8 +198,8 @@
|
|||
<section class="guidelines-section">
|
||||
<h2>Our Privacy Commitments</h2>
|
||||
<p>
|
||||
Your personal information is used to administer your membership and
|
||||
to communicate with you about Ghost Guild.
|
||||
Your personal information is used to administer your membership and to
|
||||
communicate with you about Ghost Guild.
|
||||
</p>
|
||||
<p>
|
||||
We use a small number of third-party services to run the platform
|
||||
|
|
@ -220,8 +230,9 @@
|
|||
You can end your membership at any time by contacting the Membership
|
||||
Committee. In rare cases, membership may be ended for serious
|
||||
violations of these guidelines, following the process in our
|
||||
<NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink>.
|
||||
Dues are not refunded.
|
||||
<NuxtLink to="/policies/conflict-resolution"
|
||||
>Conflict Resolution Policy</NuxtLink
|
||||
>. Dues are not refunded.
|
||||
</p>
|
||||
<p>
|
||||
If you leave, your wiki contributions remain in the commons under
|
||||
|
|
@ -235,8 +246,14 @@
|
|||
<h2>Related Policies</h2>
|
||||
<p>These policies are part of what you agree to by joining:</p>
|
||||
<ul>
|
||||
<li><NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink></li>
|
||||
<li><NuxtLink to="/policies/conflict-resolution">Conflict Resolution Policy</NuxtLink></li>
|
||||
<li>
|
||||
<NuxtLink to="/policies/code-of-conduct">Code of Conduct</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="/policies/conflict-resolution"
|
||||
>Conflict Resolution Policy</NuxtLink
|
||||
>
|
||||
</li>
|
||||
<li><NuxtLink to="/policies/privacy">Privacy Policy</NuxtLink></li>
|
||||
<li><NuxtLink to="/policies/terms">Terms of Service</NuxtLink></li>
|
||||
</ul>
|
||||
|
|
@ -256,9 +273,11 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
useHead({
|
||||
title: 'Community Guidelines · Ghost Guild',
|
||||
})
|
||||
useSiteMeta({
|
||||
title: "Community Guidelines",
|
||||
description:
|
||||
"What you're agreeing to when you join Ghost Guild — community values, member commitments, and the policies that govern participation.",
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -375,3 +394,4 @@ useHead({
|
|||
}
|
||||
}
|
||||
</style>
|
||||
`
|
||||
|
|
|
|||
|
|
@ -22,15 +22,14 @@
|
|||
</div>
|
||||
<div class="event-meta-item">
|
||||
<span class="meta-label">Location</span>
|
||||
{{ event.location }}
|
||||
<span v-if="event.location?.trim().toUpperCase() === 'TBD'">
|
||||
Platform TBD
|
||||
</span>
|
||||
<template v-else>{{ event.location }}</template>
|
||||
</div>
|
||||
<div v-if="event.circle" class="event-meta-item">
|
||||
<CircleBadge :circle="event.circle" />
|
||||
</div>
|
||||
<div v-if="event.maxAttendees" class="event-meta-item">
|
||||
<span class="meta-label">Capacity</span>
|
||||
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -82,7 +81,7 @@
|
|||
<!-- Description -->
|
||||
<div class="section">
|
||||
<h2>About This Event</h2>
|
||||
<p>{{ event.description }}</p>
|
||||
<div class="prose" v-html="renderMarkdown(event.description)" />
|
||||
</div>
|
||||
|
||||
<!-- Series Description -->
|
||||
|
|
@ -91,17 +90,23 @@
|
|||
class="section"
|
||||
>
|
||||
<h2>About the {{ event.series.title }} Series</h2>
|
||||
<p>{{ event.series.description }}</p>
|
||||
<div class="prose" v-html="renderMarkdown(event.series.description)" />
|
||||
</div>
|
||||
|
||||
<!-- Additional Information -->
|
||||
<div v-if="event.content" class="section">
|
||||
<h2>Additional Information</h2>
|
||||
<div class="prose" v-html="renderMarkdown(event.content)" />
|
||||
</div>
|
||||
|
||||
<!-- Agenda -->
|
||||
<div v-if="event.agenda?.length" class="section">
|
||||
<h2>Agenda</h2>
|
||||
<ol class="agenda-list">
|
||||
<ul class="agenda-list">
|
||||
<li v-for="(item, index) in event.agenda" :key="index">
|
||||
{{ item }}
|
||||
</li>
|
||||
</ol>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Speakers -->
|
||||
|
|
@ -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 @@
|
|||
<div class="box-title">Event Details</div>
|
||||
<div v-if="event.eventType" class="detail-row">
|
||||
<span class="detail-key">Type</span>
|
||||
<span class="detail-val">{{ event.eventType }}</span>
|
||||
<span class="detail-val">{{ eventTypeLabel(event.eventType) }}</span>
|
||||
</div>
|
||||
<div v-if="event.membersOnly" class="detail-row">
|
||||
<span class="detail-key">Members only</span>
|
||||
|
|
@ -165,6 +171,8 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { eventTypeLabel } from "~/config/eventTypes";
|
||||
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
|
||||
|
|
@ -186,6 +194,7 @@ if (error.value?.statusCode === 404) {
|
|||
|
||||
const { memberData, checkMemberStatus } = useAuth();
|
||||
const { trackGoal, isComplete } = useOnboarding();
|
||||
const { render: renderMarkdown } = useMarkdown();
|
||||
|
||||
onMounted(async () => {
|
||||
await checkMemberStatus();
|
||||
|
|
@ -194,21 +203,29 @@ onMounted(async () => {
|
|||
}
|
||||
});
|
||||
|
||||
const eventTimeZone = computed(
|
||||
() => event.value?.displayTimezone || "America/Toronto",
|
||||
);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: eventTimeZone.value,
|
||||
}).format(d);
|
||||
};
|
||||
|
||||
const formatTime = (start, end) => {
|
||||
if (!start || !end) return "";
|
||||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
timeZone: eventTimeZone.value,
|
||||
});
|
||||
return `${fmt.format(new Date(start))} – ${fmt.format(new Date(end))}`;
|
||||
};
|
||||
|
|
@ -220,16 +237,12 @@ const handleTicketError = (err) => {
|
|||
console.error("Ticket purchase failed:", err);
|
||||
};
|
||||
|
||||
useHead(() => ({
|
||||
title: event.value
|
||||
? `${event.value.title} - Ghost Guild Events`
|
||||
: "Event - Ghost Guild",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: event.value?.description || "View event details and register",
|
||||
},
|
||||
],
|
||||
useSiteMeta(() => ({
|
||||
title: event.value ? `${event.value.title} · Events` : "Event",
|
||||
description:
|
||||
event.value?.description || "View event details and register.",
|
||||
type: "article",
|
||||
image: event.value?.slug ? `/og/events/${event.value.slug}.png` : undefined,
|
||||
}));
|
||||
</script>
|
||||
|
||||
|
|
@ -337,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;
|
||||
|
|
@ -355,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 {
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@
|
|||
:class="{ 'is-cancelled': event.isCancelled }"
|
||||
>
|
||||
<div class="event-date-col">
|
||||
<span class="event-date">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="event-time">{{ formatTime(event.startDate) }}</span>
|
||||
<span class="event-date">{{ formatDate(event) }}</span>
|
||||
<span class="event-time">{{ formatTime(event) }}</span>
|
||||
</div>
|
||||
<div class="event-info">
|
||||
<div class="event-title">
|
||||
|
|
@ -45,34 +45,21 @@
|
|||
<span v-if="event.isCancelled" class="cancelled-tag"
|
||||
>cancelled</span
|
||||
>
|
||||
<span v-if="event.isRegistered" class="registered-tag"
|
||||
>Registered</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="event.tagline" class="event-tagline">
|
||||
{{ event.tagline }}
|
||||
</div>
|
||||
<div class="event-sub">
|
||||
<span v-if="event.eventType" class="event-type-tag">{{
|
||||
event.eventType
|
||||
eventTypeLabel(event.eventType)
|
||||
}}</span>
|
||||
<span v-if="event.eventType" class="sep">·</span>
|
||||
<span class="event-location">{{ formatLocation(event) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="event-capacity">
|
||||
<template v-if="event.maxAttendees">
|
||||
<span :class="{ 'seats-warn': isAlmostFull(event) }">
|
||||
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
|
||||
</span>
|
||||
<span v-if="isSoldOut(event)" class="capacity-badge sold-out"
|
||||
>Sold out</span
|
||||
>
|
||||
<span
|
||||
v-else-if="isAlmostFull(event)"
|
||||
class="capacity-badge limited"
|
||||
>Limited tickets</span
|
||||
>
|
||||
</template>
|
||||
<template v-else>Open</template>
|
||||
</span>
|
||||
<div class="event-badges">
|
||||
<span v-if="event.membersOnly" class="members-badge">Members</span>
|
||||
<CircleBadge v-if="event.circle" :circle="event.circle" />
|
||||
|
|
@ -119,15 +106,20 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { EVENT_TYPES, eventTypeLabel } from "~/config/eventTypes";
|
||||
|
||||
useSiteMeta({
|
||||
title: "Events",
|
||||
description:
|
||||
"Workshops, meetups, and gatherings for game developers practicing cooperative models. Some events are open to the public; others are for Ghost Guild members.",
|
||||
});
|
||||
|
||||
const activeFilter = ref("all");
|
||||
const includePastEvents = ref(false);
|
||||
|
||||
const filterOptions = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Workshops", value: "workshop" },
|
||||
{ label: "Community", value: "community" },
|
||||
{ label: "Social", value: "social" },
|
||||
{ label: "Showcase", value: "showcase" },
|
||||
...EVENT_TYPES.map((t) => ({ label: t.label, value: t.value })),
|
||||
];
|
||||
|
||||
const { data: eventsData } = await useFetch("/api/events");
|
||||
|
|
@ -152,18 +144,25 @@ const activeSeries = computed(() => {
|
|||
);
|
||||
});
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
const opts = { month: "short", day: "numeric" };
|
||||
if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric";
|
||||
const formatDate = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
const tz = event.displayTimezone || "America/Toronto";
|
||||
const d = new Date(event.startDate);
|
||||
const opts = { month: "short", day: "numeric", timeZone: tz };
|
||||
const dYear = d.toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
|
||||
const nowYear = new Date().toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
|
||||
if (dYear !== nowYear) opts.year = "numeric";
|
||||
return d.toLocaleDateString("en-US", opts);
|
||||
};
|
||||
|
||||
const formatTime = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
||||
const formatTime = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
return new Date(event.startDate).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
timeZone: event.displayTimezone || "America/Toronto",
|
||||
});
|
||||
};
|
||||
|
||||
const formatLocation = (event) => {
|
||||
|
|
@ -175,16 +174,6 @@ 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;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -215,7 +204,7 @@ const isAlmostFull = (event) => {
|
|||
|
||||
.event-row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr auto auto;
|
||||
grid-template-columns: 90px 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding: 14px 0;
|
||||
|
|
@ -232,8 +221,12 @@ const isAlmostFull = (event) => {
|
|||
.event-row:hover {
|
||||
padding-left: 4px;
|
||||
}
|
||||
.event-row.is-cancelled {
|
||||
opacity: 0.5;
|
||||
.event-row.is-cancelled .event-title a {
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
.event-row.is-cancelled .event-tagline {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.event-date-col {
|
||||
|
|
@ -283,6 +276,16 @@ const isAlmostFull = (event) => {
|
|||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.registered-tag {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--candle);
|
||||
border: 1px solid currentColor;
|
||||
padding: 1px 5px;
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-tagline {
|
||||
font-size: 11px;
|
||||
|
|
@ -311,35 +314,6 @@ const isAlmostFull = (event) => {
|
|||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.event-capacity {
|
||||
font-size: 11px;
|
||||
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;
|
||||
flex-direction: column;
|
||||
|
|
@ -471,7 +445,6 @@ const isAlmostFull = (event) => {
|
|||
grid-template-columns: 70px 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.event-capacity,
|
||||
.event-badges {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@
|
|||
<div v-if="events?.length" class="event-list">
|
||||
<div v-for="event in events" :key="event._id" class="event-item">
|
||||
<div class="block-inset event-item-inner">
|
||||
<span class="event-date">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="event-date">{{ formatDate(event) }}</span>
|
||||
<span class="event-title">
|
||||
<NuxtLink :to="`/events/${event.slug || event._id}`">{{
|
||||
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: () => [],
|
||||
|
|
@ -168,10 +195,13 @@ const circleData = [
|
|||
},
|
||||
];
|
||||
|
||||
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",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -317,13 +317,17 @@
|
|||
<ParchmentInset>
|
||||
<h2>How membership works</h2>
|
||||
<ul>
|
||||
<li>Full access to the knowledge commons, Slack, and peer support</li>
|
||||
<li>Full access to the knowledge commons, events and workshops, and community</li>
|
||||
<li>Free access to all Ghost Guild events</li>
|
||||
<li>Equal access for every member, regardless of contribution</li>
|
||||
<li>Your circle reflects where you are, not rank</li>
|
||||
<li>Pay what you can ($0–$50+/month, separate from circle)</li>
|
||||
<li>Higher contributions create solidarity spots for others</li>
|
||||
</ul>
|
||||
<p>
|
||||
Community connection happens in our Slack workspace, joined in monthly
|
||||
onboarding waves — there may be a short wait after you join.
|
||||
</p>
|
||||
</ParchmentInset>
|
||||
|
||||
<!-- THREE CIRCLES -->
|
||||
|
|
@ -387,6 +391,12 @@ import {
|
|||
getGuidanceLabel,
|
||||
} from "~/config/contributions";
|
||||
|
||||
useSiteMeta({
|
||||
title: "Join",
|
||||
description:
|
||||
"Join Ghost Guild — a membership community for game developers exploring cooperative models. Everyone gets everything. Pay what you can, $0 to $50 per month.",
|
||||
});
|
||||
|
||||
// Auth state
|
||||
const { isAuthenticated, memberData, checkMemberStatus } = useAuth();
|
||||
|
||||
|
|
|
|||
|
|
@ -315,6 +315,9 @@
|
|||
|
||||
<script setup>
|
||||
import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions';
|
||||
import { STATUS_LABELS } from '~/config/memberStatus';
|
||||
|
||||
useSiteMeta({ title: 'Account', noindex: true });
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
|
|
@ -417,13 +420,6 @@ const circleOptions = [
|
|||
},
|
||||
];
|
||||
|
||||
const STATUS_LABELS = {
|
||||
active: "Active",
|
||||
pending_payment: "Setting up payment",
|
||||
suspended: "Paused",
|
||||
cancelled: "Closed",
|
||||
};
|
||||
|
||||
const formatStatus = (s) => STATUS_LABELS[s] || s;
|
||||
|
||||
const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@
|
|||
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
|
||||
</div>
|
||||
<p v-if="showSlackComingNote" class="slack-coming-note">
|
||||
Slack workspace access is part of your membership. Your invitation
|
||||
typically arrives within 2–3 weeks of joining.
|
||||
Slack workspace access is part of your membership. Invitations are
|
||||
sent in monthly onboarding waves — we'll be in touch.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
|
|
@ -60,9 +60,7 @@
|
|||
:to="`/events/${evt.slug || evt._id}`"
|
||||
class="event-item"
|
||||
>
|
||||
<span class="event-date">{{
|
||||
formatEventDate(evt.startDate)
|
||||
}}</span>
|
||||
<span class="event-date">{{ formatEventDate(evt) }}</span>
|
||||
<span class="event-title">{{ evt.title }}</span>
|
||||
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
|
||||
</NuxtLink>
|
||||
|
|
@ -222,6 +220,8 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
useSiteMeta({ title: 'Dashboard', noindex: true });
|
||||
|
||||
const { memberData, checkMemberStatus } = useAuth();
|
||||
const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
|
||||
useMemberStatus();
|
||||
|
|
@ -365,20 +365,22 @@ const getEventImageUrl = (featureImage) => {
|
|||
return "";
|
||||
};
|
||||
|
||||
const formatEventDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
const formatEventDate = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
timeZone: event.displayTimezone || "America/Toronto",
|
||||
}).format(new Date(event.startDate));
|
||||
};
|
||||
|
||||
const formatEventTime = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
const formatEventTime = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
timeZone: event.displayTimezone || "America/Toronto",
|
||||
}).format(new Date(event.startDate));
|
||||
};
|
||||
|
||||
const formatMemberSince = (dateString) => {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@
|
|||
<script setup>
|
||||
definePageMeta({ middleware: 'auth' });
|
||||
|
||||
useSiteMeta({ title: 'Payment Setup', noindex: true });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
|
|
|||
|
|
@ -306,6 +306,8 @@ import { MEMBER_STATUSES } from "~/composables/useMemberStatus";
|
|||
import { TIMEZONE_OPTIONS } from "~/config/timezones";
|
||||
import { formatActivity } from "~/utils/activityText";
|
||||
|
||||
useSiteMeta({ title: "Profile", noindex: true });
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -276,14 +276,10 @@ onUnmounted(() => {
|
|||
pageBreadcrumbTitle.value = "";
|
||||
});
|
||||
|
||||
// Page head
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
member.value
|
||||
? `${member.value.name} — Ghost Guild`
|
||||
: "Member Profile — Ghost Guild",
|
||||
),
|
||||
});
|
||||
useSiteMeta(() => ({
|
||||
title: member.value ? member.value.name : "Member Profile",
|
||||
noindex: true,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -277,16 +277,7 @@ onBeforeUnmount(() => {
|
|||
clearTimeout(searchTimeout)
|
||||
})
|
||||
|
||||
// ---- useHead ----
|
||||
useHead({
|
||||
title: 'Member Directory - Ghost Guild',
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Connect with members of the Ghost Guild community - game developers, founders, and practitioners building solidarity economy studios.',
|
||||
},
|
||||
],
|
||||
})
|
||||
useSiteMeta({ title: 'Member Directory', noindex: true })
|
||||
|
||||
// ---- Init ----
|
||||
onMounted(async () => {
|
||||
|
|
|
|||
|
|
@ -39,8 +39,9 @@ if (!policy) {
|
|||
throw createError({ statusCode: 404, statusMessage: 'Policy not found', fatal: true })
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: `${policy.title} · Ghost Guild`,
|
||||
useSiteMeta({
|
||||
title: policy.title,
|
||||
description: policy.description,
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -231,8 +231,10 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
useHead({
|
||||
title: 'Privacy Policy · Ghost Guild',
|
||||
useSiteMeta({
|
||||
title: 'Privacy Policy',
|
||||
description:
|
||||
'How Ghost Guild handles your data: what we collect, why we collect it, and who has access. No Google Analytics, no advertising pixels, no third-party tracking.',
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -50,8 +50,10 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
useHead({
|
||||
title: 'Refund Policy · Ghost Guild',
|
||||
useSiteMeta({
|
||||
title: 'Refund Policy',
|
||||
description:
|
||||
'How Ghost Guild handles refund requests for membership dues and event tickets. Pay-what-you-can, case-by-case, run as a non-profit program of Baby Ghosts.',
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -250,8 +250,10 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
useHead({
|
||||
title: 'Terms of Service · Ghost Guild',
|
||||
useSiteMeta({
|
||||
title: 'Terms of Service',
|
||||
description:
|
||||
'Terms of service for ghostguild.org and wiki.ghostguild.org, operated by Baby Ghosts. Covers accounts, membership, acceptable use, and what we expect from each other.',
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -117,9 +117,11 @@ const handlePurchaseSuccess = () => {
|
|||
refreshNuxtData()
|
||||
}
|
||||
|
||||
useHead(() => ({
|
||||
title: series.value ? `${series.value.title} - Event Series - Ghost Guild` : 'Event Series - Ghost Guild',
|
||||
meta: [{ name: 'description', content: series.value?.description || 'Multi-event series' }],
|
||||
useSiteMeta(() => ({
|
||||
title: series.value ? `${series.value.title} · Event Series` : 'Event Series',
|
||||
description:
|
||||
series.value?.description ||
|
||||
(series.value?.title ? `${series.value.title} — a Ghost Guild event series.` : undefined),
|
||||
}))
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -72,15 +72,10 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
useHead({
|
||||
title: "Event Series - Ghost Guild",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"Multi-session events on cooperative topics for game developers.",
|
||||
},
|
||||
],
|
||||
useSiteMeta({
|
||||
title: "Event Series",
|
||||
description:
|
||||
"Multi-session event series on cooperative topics — from foundations courses to practitioner cohorts.",
|
||||
});
|
||||
|
||||
const { data: seriesData, pending } = await useFetch("/api/series", {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
<script setup>
|
||||
definePageMeta({ layout: false })
|
||||
useSiteMeta({ title: 'Verifying', noindex: true })
|
||||
|
||||
const state = ref('verifying')
|
||||
const errorMessage = ref('')
|
||||
|
|
|
|||
77
app/utils/timezones.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// Convert a datetime-local string ("YYYY-MM-DDTHH:MM") to a UTC Date,
|
||||
// interpreting the wall-clock time in the given IANA timezone.
|
||||
export function zonedLocalToUTC(localStr, tz) {
|
||||
if (!localStr || !tz) return null;
|
||||
const [datePart, timePart] = String(localStr).split("T");
|
||||
if (!datePart || !timePart) return null;
|
||||
const [y, mo, d] = datePart.split("-").map(Number);
|
||||
const [h, mi] = timePart.split(":").map(Number);
|
||||
if ([y, mo, d, h, mi].some((n) => Number.isNaN(n))) return null;
|
||||
|
||||
// Treat the components as if they are already UTC. The result's wall-clock
|
||||
// in the target TZ will differ from what we want by exactly the TZ offset
|
||||
// for that moment, so we measure that offset and subtract it.
|
||||
const asUTC = new Date(Date.UTC(y, mo - 1, d, h, mi));
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(asUTC);
|
||||
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
|
||||
const observed = Date.UTC(
|
||||
get("year"),
|
||||
get("month") - 1,
|
||||
get("day"),
|
||||
get("hour") % 24,
|
||||
get("minute"),
|
||||
get("second"),
|
||||
);
|
||||
const offsetMs = observed - asUTC.getTime();
|
||||
return new Date(asUTC.getTime() - offsetMs);
|
||||
}
|
||||
|
||||
// Convert a UTC Date (or ISO string) to a datetime-local string
|
||||
// ("YYYY-MM-DDTHH:MM") rendered in the given IANA timezone.
|
||||
export function utcToZonedLocal(utc, tz) {
|
||||
if (!utc || !tz) return "";
|
||||
const d = utc instanceof Date ? utc : new Date(utc);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(d);
|
||||
const get = (type) => parts.find((p) => p.type === type)?.value;
|
||||
const year = get("year");
|
||||
const month = get("month");
|
||||
const day = get("day");
|
||||
let hour = get("hour");
|
||||
const minute = get("minute");
|
||||
if (hour === "24") hour = "00";
|
||||
return `${year}-${month}-${day}T${hour}:${minute}`;
|
||||
}
|
||||
|
||||
// Short timezone label (e.g., "EDT", "PDT") for a Date in a given IANA TZ.
|
||||
export function shortTimezoneName(date, tz) {
|
||||
if (!date || !tz) return "";
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
timeZoneName: "short",
|
||||
}).formatToParts(d);
|
||||
return parts.find((p) => p.type === "timeZoneName")?.value || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
124
docs/BACKLOG.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# Ghost Guild — Open Backlog
|
||||
|
||||
_Last consolidated: 2026-05-18. Single source of truth for every open issue across the codebase. Pulls from `LAUNCH_READINESS.md`, `TODO.md`, the post-launch backlog memory, and a fresh sweep of in-code TODO/FIXME comments._
|
||||
|
||||
Cutover has not happened yet. Deploy steps + Activation + Open decisions live separately in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md). This file is the everything-else.
|
||||
|
||||
**Launch shape (2026-05-18):** site live with events ASAP, applications open immediately, Slack invites delivered in waves. Entire waitlist invited to apply at launch. See `LAUNCH_READINESS.md` for the full shape, the activation steps, and the open product decisions that gate the launch comms.
|
||||
|
||||
---
|
||||
|
||||
## Pre-cutover (do once)
|
||||
|
||||
Operational steps that have to run during cutover. Full details + env-var list in [`LAUNCH_READINESS.md`](./LAUNCH_READINESS.md).
|
||||
|
||||
- [ ] Provision the Dokploy app, set env vars (full list in LAUNCH_READINESS.md), confirm `BASE_URL` exact-matches the public origin and `NODE_ENV=production`.
|
||||
- [ ] Add the daily Dokploy Scheduled Task that POSTs to `/api/internal/reconcile-payments` with `X-Reconcile-Token`.
|
||||
- [ ] **Run `node scripts/migrate-contribution-amount.cjs --apply` against prod Mongo BEFORE the new code serves traffic.**
|
||||
- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` and `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in Dokploy.
|
||||
- [ ] Set `NUXT_RECONCILE_TOKEN` to a 32+ char random string.
|
||||
- [ ] Push local `main` to `origin/main`.
|
||||
- [ ] Deploy.
|
||||
- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic.**
|
||||
- [ ] Audit prod for pre-fix series-pass bypass registrations (registrations on pass-only series children with `registeredAt < 2026-04-20` from non-pass-holders). Decide per case.
|
||||
- [ ] In Helcim dashboard: disable the default payment-confirmation email for plans 50302 + 50303 (we send our own CRA-safe version via Resend).
|
||||
- [ ] Run one real test charge and verify (a) Payment doc in Mongo and (b) exactly one CRA-compliant confirmation email.
|
||||
- [ ] Rotate `HELCIM_API_TOKEN` in the Helcim merchant portal and update the Dokploy env var.
|
||||
- [ ] Trigger the daily reconcile task once manually in Dokploy to confirm it's wired correctly.
|
||||
|
||||
## Pilot smoke walks (before first wave)
|
||||
|
||||
Once cutover lands, before the first Slack onboarding wave goes out:
|
||||
|
||||
- [ ] **Pilot smoke walk for Slack-invited workflow.** One admin manually clicks "Mark as Slack invited" against a real test member in production, confirms the row updates in place, and confirms the dashboard "Slack coming" note disappears for that member. Unit tests cover the pieces; nothing covers the live admin-to-member round-trip.
|
||||
|
||||
---
|
||||
|
||||
## Bylaws-decoupling (waiting on amendment ratification)
|
||||
|
||||
Membership status is being decoupled from payment status. Copy + UI gates already align; behavioral changes below remain.
|
||||
|
||||
- ~~B1 cancel-subscription leaves status `active`.~~ Verified shipped 2026-05-18: `server/api/members/cancel-subscription.post.js:31,50` writes `status: 'active'`. Test coverage in `tests/server/api/cancel-subscription.test.js` (Fix #9 in LAUNCH_READINESS).
|
||||
- ~~B3 cancelled.~~ `pending_payment` stays.
|
||||
- ~~B4 admin "Pending Payment" → "Payment setup incomplete"~~ shipped 2026-04-29 (`59d2be2`).
|
||||
|
||||
---
|
||||
|
||||
## Known gotchas (post-launch)
|
||||
|
||||
- **Admin edit does not sync Helcim `recurringAmount`.** `/admin/members/[id]` PUT writes `contributionAmount` direct to Mongo by design. Admins must PATCH Helcim manually. The admin form already shows an `--ember`-bordered notice (commit `e756170`); a real sync flow is a future enhancement.
|
||||
- **Cadence switch rejected on active subscriptions.** `server/api/members/update-contribution.post.js:206` refuses cadence changes mid-subscription with a TODO comment pointing here. No UI toggle exists on `/member/account`. Adding cadence switch requires a Helcim subscription replacement flow, not a plain update.
|
||||
- **S2 test fixture id/slug mismatch (local dev only).** Seeded S2 series has `id: 'test-s2-drop-in-allowed'` but `slug: 'test-s2-drop-in-allowed-series'`. Doesn't affect prod — fix the seed script if anyone re-runs fixtures.
|
||||
- **`/admin/series-management` "Delete" button doesn't actually delete.** Click handler iterates events to PUT-unlink each from the series, never calls `DELETE /api/admin/series/:id`. For an empty series the button is a no-op; for a series with events it just orphans them. Either rename to "Unlink events" or add the actual DELETE call. Surfaced by `e2e/admin-series.spec.js` (delete test skipped). Flagged 2026-04-30.
|
||||
- **Past-deadline events and sold-out events render identically.** `EventTicketPurchase.vue` falls through to "Event Sold Out" panel for both `tickets.available.reason === 'Registration deadline has passed'` and zero-stock cases. If "Registration closed" is meant to read differently from "Sold out," add a distinct branch. Flagged 2026-04-30 (no e2e written — gated on this UX decision).
|
||||
|
||||
---
|
||||
|
||||
## Accessibility / a11y
|
||||
|
||||
- [ ] **Button minimum target size.** Site-wide `.btn` renders ~35px tall. WCAG AA 2.5.8 (24×24) passes; AAA 2.5.5 (44×44) fails. Bumping padding affects every button — design call, not a drop-in fix. Flagged 2026-04-11.
|
||||
- [ ] **`/board` color-contrast violations (WCAG AA).** `.block-label` ("Offering" tag) and `.slack-handle` use `#746a58` on `#e8dfc8` → 4.01:1; AA needs 4.5:1 for small text. Surfaced by `e2e/a11y.spec.js` (the `/board` route fails; test is intentionally left red until fixed). Likely a single CSS variable adjustment. Flagged 2026-04-30.
|
||||
|
||||
---
|
||||
|
||||
## Deferred features (own session each)
|
||||
|
||||
- [ ] **Email automation system.** Patterned after Tranzac's implementation (separate project, already built). HTML email bodies with template management and drip sequences. Deferred 2026-04-20 — ruled wasted work given the larger system is designed elsewhere. Current transactional email lives in `server/utils/resend.js` + inline in `server/api/auth/login.post.js`, `server/routes/oidc/interaction/login.post.ts`, `server/api/admin/{members,pre-registrants}/invite.post.js`. Copy dump at `docs/email-copy-dump.md`. See memory: `project_email_automation_future`.
|
||||
- [ ] **Receipts for event ticket purchases (Phase 2).** Phase 1 receipts only cover membership payments. Event tickets — especially guest purchases without member accounts — need a receipt flow. Likely an emailed PDF/HTML receipt at purchase time. Build target: June–Oct 2026, live Jan 2027. See memory: `project_receipts`.
|
||||
- [ ] **Series/event waitlist.** Admin can configure `tickets.waitlist.enabled` and `maxSize`; `server/utils/tickets.js` returns `waitlistAvailable: true` when full; `app/components/SeriesPassPurchase.vue:341` and `EventTicketPurchase.vue` have stub `handleJoinWaitlist` that toasts "Waitlist Coming Soon." No server endpoint, no confirmation email, no `event_waitlisted` activity hook. Either implement end-to-end or hide the button by removing the `v-if="availability?.waitlistAvailable"` branches in `EventSeriesTicketCard.vue:175` and `EventTicketCard.vue:73`.
|
||||
- [ ] **ASVS Phase 4.** File-upload validation pipeline, granular RBAC, credential encryption.
|
||||
|
||||
---
|
||||
|
||||
## Wave-Slack pilot follow-ups
|
||||
|
||||
- [ ] **`/api/auth/member` doesn't return `slackInvited`.** Dashboard's Slack-coming note is gated on `memberData.slackInvited`, which is always `undefined` client-side, so the note shows for *every* active member regardless of state. Real bug. Add `slackInvited` (and `slackInvitedAt`) to the auth/member response. Surfaced by wave-slack §7.2 e2e (skipped pending this fix). Flagged 2026-04-30.
|
||||
- [ ] **Admin members list row mutation isn't reactive.** `markSlackInvited` in `app/pages/admin/members/index.vue` does `Object.assign(member, res.member)` on a plain object inside a `useFetch` array; Vue doesn't react, so the "Mark as Slack invited" button stays visible until a manual reload. Fix: `members.value[i] = { ...members.value[i], ...res.member }` or `splice`. Detail page uses the right pattern (covered by §6.6). Surfaced by wave-slack §6.2 e2e (skipped pending this fix). Flagged 2026-04-30.
|
||||
- [ ] **Deprecated `slackInviteStatus` field still serialized.** Removed from UI but still on `Member` documents and the `/api/admin/members` payload. Project it away in the API response and run a one-shot `$unset` cleanup. Surfaced by wave-slack §6.7 e2e. Flagged 2026-04-30.
|
||||
- [ ] **Spec vs shipped-UI mismatch on wave language.** `docs/specs/wave-based-slack-onboarding-tests.md` §7.5 asserts "no wave/cohort/batch language" in the dashboard note, but the shipped welcome-email and dashboard copy say "monthly onboarding waves." Decide which side wins; update the other.
|
||||
- [ ] **E2E coverage for `e2e/wave-slack-onboarding.spec.js`.** 9 of 16 scaffolded tests now passing (admin Slack-invited button + non-trivial dashboard cases). 7 remain skipped pending the bugs above (7.2, 6.2), seeding gaps (7.4 — no dev endpoint to mint members of arbitrary status), Open Questions (7.8, 6.9), or spec-vs-UI conflicts (7.5, 6.7).
|
||||
- [ ] **Pilot exit decision (~8 weeks post-launch).** Either restore `server/_archive/utils/checkSlackJoins.js` + its plugin if polling is needed, or delete the archive permanently. Driven by whether the manual-invite cadence is sustainable post-pilot.
|
||||
- [ ] **`slack_invite_failed` enum slug cleanup.** Detector and alert removed in `d15458b`, but the slug remains in `server/models/adminAlertDismissal.js` enum so historical dismissal rows continue to validate. Full removal needs a one-shot cleanup of stale dismissal rows in the DB. Roll into a future schema-tidy pass.
|
||||
|
||||
---
|
||||
|
||||
## Simplify-pass follow-ups (still open)
|
||||
|
||||
Items surfaced during the 2026-04-29 /simplify review. The 2026-04-30 small-wins batch shipped 3 items (STATUS_LABELS dedup, ImageUpload focus, signupBridge rename). Remaining:
|
||||
|
||||
- [ ] **Extract `.tint-candle` / `.tint-ember` utility classes.** The `color-mix(in srgb, var(--candle) 15%, transparent)` + matching border pattern is now inlined as `style=""` in ~9 sites across `EventSeriesTicketCard.vue`, `SeriesPassPurchase.vue`, `NaturalDateInput.vue`, `ImageUpload.vue`. Promote to utility classes in `app/assets/css/main.css` so future tints don't keep multiplying inline styles (and so `:hover` / `:focus` variants are reachable).
|
||||
- [ ] **Audit `member &&` truthy checks in sibling ticket/subscription routes.** Commit `f66455e` fixed `server/api/events/[id]/tickets/available.get.js:115` to use `hasMemberAccess(member)`. Same anti-pattern likely exists in adjacent routes (`tickets/purchase.post.js`, subscription endpoints). Guests/suspended/cancelled members would currently look like full members for any feature gated on truthiness alone.
|
||||
- [ ] **STATUS_LABELS dedup — verify.** The 2026-04-30 small-wins batch claimed STATUS_LABELS dedup, but `e2e/admin-members.spec.js` expansion found an inline copy still at `app/pages/admin/members/index.vue:491` and another at `app/pages/member/account.vue:420`. Either the previous dedup was partial or a new copy was reintroduced — confirm and finish dedup into a shared constants module.
|
||||
- [ ] **`app/pages/admin/members/[id].vue` status select still hand-written.** Commit `441a5f5` aligned the index page's status `<select>` to `STATUS_LABELS`, but the detail page (`[id].vue`) still hand-codes raw status options. Refactor to drive from the same constant.
|
||||
|
||||
---
|
||||
|
||||
## Optional / low-priority
|
||||
|
||||
- [ ] **Welcome-email Slack-timing mention.** Currently the welcome email doesn't mention Slack timing — the dashboard carries that note. Could add a one-line "Slack invitation comes in monthly waves — there may be a short wait" if the dashboard turns out not to be enough signal.
|
||||
|
||||
---
|
||||
|
||||
## E2e infrastructure gaps
|
||||
|
||||
Surfaced during the 2026-04-30 e2e expansion. None block a green suite, but each blocks specific coverage from being added.
|
||||
|
||||
- [ ] **Other email routes still send real emails in dev mode.** The `ALLOW_DEV_TEST_ENDPOINTS` short-circuit was added to `server/api/admin/pre-registrants/invite.post.js` (which calls `new Resend(...)` directly), but the five wrapper functions in `server/utils/resend.js` (event registration, cancellation, waitlist, series pass, welcome) still dispatch live. Either add the same gate to each wrapper, or refactor the wrappers into a single `sendEmail({ from, to, subject, text, html })` helper holding the gate centrally — would also dedupe ~5 near-identical try/catch blocks.
|
||||
- [ ] **No dev endpoint to seed members of arbitrary status.** Wave-slack §7.4 (note hidden for suspended/cancelled/guest) is gated on this. `/api/dev/test-login` only mints an `active` admin. A minimal `/api/dev/members.post` accepting `{ email, status, slackInvited, ... }` would unblock many more dashboard-state e2e tests.
|
||||
- [ ] **SSR `useFetch` blocks `page.route` mocking.** Page-level fetches in `[slug].vue` files run during SSR and can't be intercepted client-side. Affects: hidden-event 404 e2e, any test that needs a mocked event payload before client hydration. Either expose a client-side fetch alternative, add a server-side test mock layer, or accept that DB seeding is required for these cases.
|
||||
- [ ] **Self-cancel block on paid event registrations not e2e-tested.** Requires seeding a logged-in member with a paid registration row. Out of scope for this round.
|
||||
- [ ] **Visual snapshot for `join — desktop` is stale.** 12,676px diff (2% of image) from layout drift. Regenerate via `npx playwright test --update-snapshots e2e/visual/pages.spec.js` once a designer eyeballs the diff.
|
||||
- [ ] **E2e cross-file races on admin specs.** With `fullyParallel: false` + `workers: 4` + `retries: 1`, ~1 admin CRUD test still fails per full-suite run (rotates between `admin-events` CRUD, `board` page-loads, and wave-slack §6.4). Each passes 100% in isolation. Root cause: tests anchor on "first row" / "any visible button" rather than uniquely-identified data, so they race when other admin specs mutate the shared dev DB. Proper fix is per-test data isolation: each test creates its own scoped record with a `Date.now()` suffix and queries by that exact identifier. Out of scope for the e2e expansion.
|
||||
|
||||
---
|
||||
|
||||
## Deeplink memories
|
||||
|
||||
- `project_post_launch_backlog.md` — high-level digest of this file
|
||||
- `project_launch_readiness.md` — cutover status (NOT YET happened)
|
||||
- `project_launch_flow_map.md` — onboarding flow + Slack wave model
|
||||
- `project_pre_registrants.md` — invitation system + pre-reg lifecycle
|
||||
- `project_helcim_plan_model.md` — cadence-keyed plan model
|
||||
- `project_contribution_amount_redesign.md` — arbitrary $ amount + guidance presets
|
||||
- `project_receipts.md` — Phase 1 done, Phase 2 pending
|
||||
- `project_email_automation_future.md` — Tranzac reference for full system
|
||||
|
|
@ -1,18 +1,30 @@
|
|||
# Launch Readiness
|
||||
|
||||
**Status as of 2026-04-20.** Target launch: before 2026-05-01.
|
||||
**Status as of 2026-05-18. Cutover has not happened yet.** Code is on local `main`; deploy steps below still need to execute.
|
||||
|
||||
Single source of truth for work remaining before cutover. P0 blocks launch; P1 is strongly preferred but survivable. Completed items are archived — see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_launch_readiness_archive.md`. Post-launch backlog lives in `docs/TODO.md`.
|
||||
Pre-cutover deploy checklist is the live content on this page. Everything else (post-launch work, bylaws decoupling, deferred features, simplify follow-ups, a11y) lives in [`BACKLOG.md`](./BACKLOG.md). Completed launch-blocker items are archived — see `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_launch_readiness_archive.md`.
|
||||
|
||||
---
|
||||
|
||||
## Launch shape (2026-05-18)
|
||||
|
||||
The launch decision: **site live with events ASAP, applications open immediately, Slack invitations sent later in waves.**
|
||||
|
||||
- Anyone can hit the site, see events, buy a ticket (members and guests both supported on `main`).
|
||||
- Anyone can join — `/join` (anonymous) and `/accept-invite` (waitlist pre-registrants) both render the same `SignupFlowOverlay` and call the same Helcim signup path. New members become `active` immediately on payment; `slackInvited=false` until an admin marks them in a wave.
|
||||
- The entire waitlist is invited to apply at launch via the pre-registrant invitation tool. They go through the same flow as anonymous signups, just with email pre-filled and a token-bound pre-reg.
|
||||
|
||||
Open decisions that gate the launch comms — see [Open decisions](#open-decisions-before-launch-comms) below.
|
||||
|
||||
---
|
||||
|
||||
## Current state
|
||||
|
||||
- Vitest snapshot 2026-04-25 ~18:23 local: **703 passing / 8 failing / 2 skipped (713 total)**. The previously-flagged 6 helcim-payment failures are now green. The 8 current failures are in `tests/server/api/auth-verify.test.js` and `tests/server/api/cancel-subscription.smoke.test.js`, both belonging to in-flight Phase 5 fixes (#10 and #9) being landed by parallel impl subagents — they will resolve as those branches merge.
|
||||
- All launch code is on local `main`: Helcim plan consolidation, contribution-amount redesign, cadence UX unification, and receipts Phase 1. Not pushed — site is not on Netlify yet.
|
||||
- All launch code is on local `main`: Helcim plan consolidation, contribution-amount redesign + migration script, cadence UX unification, receipts Phase 1, and `feature/guest-event-accounts` (merged in `e96d493`). Not pushed — site is not deployed yet.
|
||||
- Helcim plan consolidation migration **ran against prod 2026-04-18** (Monthly plan id `50302`, Annual plan id `50303`).
|
||||
- Contribution-amount migration has **NOT** yet been run against prod.
|
||||
- Receipts Phase 1 code is shipped; remaining work is deploy-time only (see Deploy checklist).
|
||||
- `cancel-subscription` correctly keeps status `active` per ratified bylaws (Fix #9 in this doc; the stale B1 entry in BACKLOG was marked done 2026-05-18).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -59,6 +71,36 @@ Applies when the app is deployed to **Dokploy on Hetzner**. Build is via the in-
|
|||
- [ ] **Rotate HELCIM_API_TOKEN** in the Helcim merchant portal and update the Dokploy env var. The token was previously exposed in `window.__NUXT__` payload until commit `208638e`.
|
||||
- [ ] **Trigger the daily reconcile task once manually** in Dokploy to confirm scheduled task + token are wired correctly. Expect a `[reconcile] done {...}` log line.
|
||||
|
||||
### Activation (after Cutover passes)
|
||||
|
||||
The site is deployed but not yet public. These are the steps that flip the switch.
|
||||
|
||||
- [ ] **Disable the coming-soon gate.** Set `NUXT_PUBLIC_COMING_SOON=false` (or remove the var) in Dokploy and redeploy. The gate lives in `app/middleware/coming-soon.global.js:4` and is purely env-driven. Verify `/`, `/about`, `/events`, `/board` all render without a redirect when logged out.
|
||||
- [ ] **Publish first event(s).** Confirm at least one event or series is live and visible publicly. Walk through the guest ticket-purchase flow end-to-end (anonymous → buy ticket → registered → confirmation email).
|
||||
- [ ] **Pre-flight real-money signup test on prod.** Have one trusted person (ideally outside the immediate build team) go through `/join` from scratch: choose a small contribution, pay, receive welcome email, land on dashboard, see "Slack coming" note. This catches end-to-end issues that no internal test reproduces.
|
||||
- [ ] **Send waitlist invitation batch** via the pre-registrant admin tool. Decide cadence first (see [Open decisions](#open-decisions-before-launch-comms)). Smoke-test by inviting yourself or one friend first; only fan out once that round-trip is clean.
|
||||
|
||||
### Open decisions before launch comms
|
||||
|
||||
These do not block deploy but need answers before the waitlist invite goes out. Each carries a small amount of work depending on the answer.
|
||||
|
||||
- [ ] **Apply-framing decision.** Today's CTAs say "Join Ghost Guild" / "Become a member"; there is no "Apply" copy in the codebase. Both `/join` and `/accept-invite` use the same `SignupFlowOverlay`, so the mechanical flow is single-source. Pick one:
|
||||
- **A (no code work).** Keep "Join" everywhere on-site; use "apply" only in external comms (waitlist email, social, etc.).
|
||||
- **B (small code work).** Rename to "Apply" across CTAs + page copy. Touches `app/pages/index.vue:11`, `app/pages/about.vue:86`, `app/pages/join.vue:5,109,111,301`, `app/components/LoginModal.vue:66`, and at least the waitlist invite + welcome email copy. Likely ~30 min of search-and-replace + screenshot review.
|
||||
- [ ] **First Slack wave date.** A publicly-stated date or cadence rule (e.g. "end of each month"). Used in three places: waitlist invite email, welcome email, dashboard "Slack coming" note. Without this, every new member emails support asking when Slack is coming.
|
||||
- [ ] **Non-member event CTA — ticket-first or membership-first?** Event pages render to anonymous visitors with both paths viable. Pick which one is primary: "Buy ticket" lowers friction, "Apply for membership" protects the funnel. Write the CTA copy once and use consistently across events.
|
||||
- [ ] **Receipts for guest ticket purchases.** Phase 1 receipts cover membership payments only. Guest ticket buyers will get no CRA-compliant receipt at launch. Options: (a) ship a basic transactional receipt for tickets pre-launch, (b) accept the gap until Phase 2 (build June–Oct 2026, live Jan 2027).
|
||||
- [ ] **Waitlist invite cadence.** Single blast vs staggered (e.g., 50/day over 4 days). Trade-off is Day-1 support load — a stagger gives you time to catch real issues from early batches before the rest of the list hits.
|
||||
|
||||
### Pre-launch code cleanup (recommended, not blocking)
|
||||
|
||||
Items from [`BACKLOG.md`](./BACKLOG.md) that materially affect the launch-window experience. None are deploy blockers, but each shows up to real users:
|
||||
|
||||
- [ ] **`/api/auth/member` returns `slackInvited`.** Without this, the dashboard "Slack coming" note shows for every active member regardless of state. Highest-priority of the wave-Slack bugs because every new member sees the broken case.
|
||||
- [ ] **Admin members-list row reactivity** on "Mark as Slack invited" — admin has to manually reload after clicking. Hits operators, not members, but operators are us.
|
||||
- [ ] **`/board` color-contrast fix** (`.block-label`, `.slack-handle` — `#746a58` on `#e8dfc8` → 4.01:1, needs ≥4.5:1). Single CSS-var change, currently the only red item in `e2e/a11y.spec.js`.
|
||||
- [ ] **Spec vs UI mismatch on wave language.** `docs/specs/wave-based-slack-onboarding-tests.md` §7.5 says "no wave/cohort/batch language" but shipped copy uses "monthly onboarding waves." Pick a side and align before launch comms go out.
|
||||
|
||||
**Env vars required in Dokploy (reference):**
|
||||
- `NODE_ENV=production`
|
||||
- `BASE_URL` (exact public origin, no trailing slash)
|
||||
|
|
@ -106,60 +148,7 @@ None outstanding. All launch-blocking flows verified via local dev or cloudflare
|
|||
|
||||
---
|
||||
|
||||
## Bylaws decoupling — follow-ups (added 2026-04-18)
|
||||
## Post-launch & deferred work
|
||||
|
||||
Context: bylaws are being amended to remove automatic termination for nonpayment. Membership status will be fully decoupled from payment status; failed payments trigger committee outreach, not status change. Copy + UI access gates already aligned in `useMemberStatus.js` and `account.vue` (2026-04-18). Server-side status gating shipped as B2 (see archive). The behavioral changes below remain.
|
||||
|
||||
Not blocking launch — the amendment hasn't passed yet, and the user-visible copy/UI is already consistent. Pick up once the amendment is ratified.
|
||||
|
||||
### B1. `cancel-subscription` flips status to `pending_payment`
|
||||
- `server/api/members/cancel-subscription.post.js:31,48`
|
||||
- When a member cancels their paid subscription, status is set to `pending_payment` and contribution amount to `0`. Under the new model, cancelling a payment plan moves the member to the $0 contribution — status should stay `active`.
|
||||
- **Fix:** change `status: 'pending_payment'` → `status: 'active'` in both the `findByIdAndUpdate` payload (line 31) and the response (line 48). Comment at line 26 also needs updating ("(not cancelled) so member can re-subscribe" → reflect new framing).
|
||||
- Add coverage in `tests/server/api/cancel-subscription.test.js` if it doesn't already exist.
|
||||
|
||||
### B3. Vestigial `pending_payment` status
|
||||
- Once payment is fully decoupled, `pending_payment` no longer gates anything and is functionally equivalent to `active`. Consider removing it from the enum (`server/models/member.js:38`, `server/utils/schemas.js:299`) and treating new signups as `active` from the moment of account creation.
|
||||
- Touches: signup flow (`helcim/customer.post.js:34`, `invite/accept.post.js:48`), admin filter UI (`app/pages/admin/members/index.vue:45,382,499,1145`, `[id].vue:69,286`), admin alerts (`server/utils/adminAlerts.js:22,100-116`, `server/models/adminAlertDismissal.js:6`), and a data migration to flip existing `pending_payment` rows to `active`.
|
||||
- Larger refactor — break out into its own ticket once B1 lands.
|
||||
|
||||
### B4. Admin "Pending Payment" filter label (cosmetic)
|
||||
- `app/pages/admin/members/index.vue:45,499`, `[id].vue:69` show `pending_payment` as "Pending Payment". If B3 removes the status entirely, this disappears too. If we keep `pending_payment` for now, rename in admin UI to "Payment setup incomplete" so admins also stop conflating it with membership state.
|
||||
|
||||
---
|
||||
|
||||
## Post-launch backlog
|
||||
|
||||
See `docs/TODO.md` for:
|
||||
- Button minimum target size (WCAG AAA 2.5.5).
|
||||
- ~~`/oidc/interaction/[uid]` routing quirk~~ — fixed 2026-04-29 (commit `23154ff`); root cause was `oidc-provider`'s `devInteractions` overriding our custom `interactions.url`.
|
||||
- ~~Admin layout migration from `guild-*` tokens to zine spec~~ — verified clean 2026-04-29; grep for `guild-[0-9]|candlelight-[0-9]|ember-[0-9]` across `app/layouts/`, `app/pages/admin/`, `app/components/admin/` returns zero matches. All tokens already converted.
|
||||
- ~~Admin dashboard quick-action button contrast~~ — verified stale 2026-04-29.
|
||||
- ~~Members table NAME column clipping~~ — verified stale 2026-04-29.
|
||||
- OWASP ASVS L1 Phase 4 (file-upload validation pipeline, granular RBAC, credential encryption).
|
||||
- ~~`tickets/available.get.js:115` `memberSavings` block reports `$0 saved` for inactive members~~ — fixed 2026-04-29 (commit `f66455e`); `memberSavings` now gated on `hasMemberAccess(member)`.
|
||||
- Simplify-pass follow-ups (2026-04-25): SHIPPED 2026-04-27 on branch `chore/simplify-pass-follow-ups` (pending merge). See `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_simplify_pass_2026_04_25.md`.
|
||||
- ~~Reconcile `customerCode` bug~~ — fixed on `main` in commit `3c38333` ("pass customerCode (not helcimCustomerId) to Helcim transactions API"). Verified in `server/api/internal/reconcile-payments.post.js:97`.
|
||||
- ~~Drive-by from 2026-04-29 phantom-Tailwind sweep: `app/components/EventSeriesBadge.vue` has zero usages~~ — deleted 2026-04-29 (commit `f85f284`); 81 lines removed.
|
||||
- Simplify-pass follow-ups (2026-04-29): smallest wins shipped in commit `26791cc`; deferred items (rename `setPaymentBridgeCookie`, dedup admin `STATUS_LABELS`, extract `.tint-candle`/`.tint-ember` utilities, audit `member &&` truthy checks in sibling routes, restore `ImageUpload` alt-text input focus styling) tracked in `docs/TODO.md` § _Simplify-pass follow-ups — 2026-04-29_.
|
||||
|
||||
### Known gotchas worth addressing post-launch
|
||||
|
||||
- **Admin edit does not sync Helcim `recurringAmount`.** `/admin/members/[id]` PUT writes `contributionAmount` direct to Mongo by design. Admins must PATCH Helcim manually. Worth surfacing in admin UI or docs.
|
||||
- **Cadence switch rejected on active subscriptions.** `update-contribution.post.js:184-189` refuses cadence changes mid-subscription; no UI toggle exists on `/member/account`. Adding cadence switch would require a Helcim subscription replacement flow, not a plain update.
|
||||
- **S2 test fixture `id`/`slug` inconsistency.** (Local dev only.) Seeded S2 series has `id: 'test-s2-drop-in-allowed'` but `slug: 'test-s2-drop-in-allowed-series'`. Doesn't affect prod — fix the seed script if anyone re-runs fixtures and is confused why `id`-based Mongo queries return empty.
|
||||
|
||||
### Events-surface visual audit — deferred items (2026-04-21)
|
||||
|
||||
Context: Phase 4 audit against `docs/specs/events-visual-audit-findings.md` fixed all critical phantom-palette, rounded-corner, CTA-mismatch, and input-styling issues across `EventTicketCard`, `EventTicketPurchase`, `EventSeriesTicketCard`, `SeriesPassPurchase`. Items below were explicitly deferred or out of reach.
|
||||
|
||||
- ~~**Success-state color convention (4 instances).**~~ Resolved 2026-04-29: gold (`--candle`) chosen as zine-consistent. Phantom-Tailwind cleanup shipped in `dc2becf` (`EventSeriesTicketCard.vue` + `SeriesPassPurchase.vue` member-benefit notice).
|
||||
- ~~**Sidebar breakpoint unverified.**~~ Verified clean 2026-04-29 — `.events-mini` hides at ≤1024px cleanly across 1023/1024/1025/1100. Actual rule lives in `EventsMiniSidebar.vue:129` + `ColumnsLayout.vue:83` (audit doc cited the wrong line).
|
||||
- ~~**`EventTicketPurchase.vue:469` magic padding.**~~ Fixed 2026-04-29 (commit `7e44809`); consent block now uses a grid approach.
|
||||
- ~~**`.section-label` extraction candidate.**~~ Verified 2026-04-29 — utility already exists at `main.css:128` and is used in 30+ places. Two scoped overrides intentionally diverge.
|
||||
- ~~**Past-events toggle component.**~~ Audited 2026-04-29 — consistent with the design system (dashed-border button, gold active state, valid `aria-pressed` toggle). Added missing `:focus-visible` outline in commit `dadec1a`; no other changes warranted.
|
||||
|
||||
### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior)
|
||||
|
||||
SHIPPED 2026-04-29 in commit `955217a` (admin column header, dropdown labels, handler rename, log message).
|
||||
Bylaws decoupling, post-launch a11y, ASVS Phase 4, deferred features, simplify-pass follow-ups, known gotchas, wave-Slack pilot follow-ups — **everything that isn't a deploy step has moved to [`BACKLOG.md`](./BACKLOG.md).**
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 154 KiB |
|
|
@ -7,16 +7,20 @@ const publicPages = [
|
|||
{ name: "Join", path: "/join" },
|
||||
{ name: "Events", path: "/events" },
|
||||
{ name: "Coming Soon", path: "/coming-soon" },
|
||||
{ name: "Accept Invite", path: "/accept-invite" },
|
||||
];
|
||||
|
||||
const memberPages = [
|
||||
{ name: "Member Dashboard", path: "/member/dashboard" },
|
||||
{ name: "Member Profile", path: "/member/profile" },
|
||||
{ name: "Member Account", path: "/member/account" },
|
||||
{ name: "Board", path: "/board" },
|
||||
];
|
||||
|
||||
const adminPages = [
|
||||
{ name: "Admin Members", path: "/admin/members" },
|
||||
{ name: "Admin Events Create", path: "/admin/events/create" },
|
||||
{ name: "Admin Pre-Registrants", path: "/admin/pre-registrants" },
|
||||
];
|
||||
|
||||
test.describe("accessibility — public pages", () => {
|
||||
|
|
|
|||
170
e2e/accept-invite.spec.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const FAKE_TOKEN = 'fake-invite-token-for-e2e'
|
||||
const FAKE_PREREG_ID = '000000000000000000000001'
|
||||
|
||||
async function mockVerifyOk(page, overrides = {}) {
|
||||
await page.route('**/api/invite/verify', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
preRegistrationId: FAKE_PREREG_ID,
|
||||
name: overrides.name ?? 'Pre Registered User',
|
||||
email: overrides.email ?? `prereg-${Date.now()}@example.com`,
|
||||
city: overrides.city ?? 'Vancouver, BC',
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function mockAcceptFree(page) {
|
||||
await page.route('**/api/invite/accept', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
requiresPayment: false,
|
||||
redirectUrl: '/member/dashboard',
|
||||
member: {
|
||||
id: 'mem-1',
|
||||
email: 'prereg@example.com',
|
||||
name: 'Pre Registered User',
|
||||
circle: 'community',
|
||||
contributionAmount: 0,
|
||||
status: 'active',
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.route('**/api/auth/status', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
authenticated: true,
|
||||
member: { id: 'mem-1', name: 'Pre Registered User', status: 'active' },
|
||||
status: 'active',
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function gotoAcceptInvite(page) {
|
||||
await page.goto(`/accept-invite#${FAKE_TOKEN}`)
|
||||
}
|
||||
|
||||
test.describe('Accept Invite — pre-registrant signup', () => {
|
||||
test('verifies invitation and shows form fields', async ({ page }) => {
|
||||
await mockVerifyOk(page, { name: 'Ada Lovelace', email: 'ada@example.com' })
|
||||
await gotoAcceptInvite(page)
|
||||
|
||||
await expect(page.locator('#accept-name')).toBeVisible()
|
||||
await expect(page.locator('#accept-name')).toHaveValue('Ada Lovelace')
|
||||
await expect(page.locator('#accept-email')).toHaveValue('ada@example.com')
|
||||
await expect(page.locator('#circle-community')).toBeAttached()
|
||||
await expect(page.locator('#circle-founder')).toBeAttached()
|
||||
await expect(page.locator('#circle-practitioner')).toBeAttached()
|
||||
await expect(page.locator('#accept-cadence-monthly')).toBeAttached()
|
||||
await expect(page.locator('#accept-cadence-annual')).toBeAttached()
|
||||
await expect(page.locator('#accept-contribution')).toBeVisible()
|
||||
await expect(page.locator('.contribution-preset-chip').first()).toBeVisible()
|
||||
await expect(page.locator('.form-submit')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows error when no token in URL hash', async ({ page }) => {
|
||||
await page.goto('/accept-invite')
|
||||
await expect(page.getByRole('heading', { name: 'Invitation Error' })).toBeVisible()
|
||||
await expect(page.locator('.error-box')).toContainText(/No invitation token/)
|
||||
})
|
||||
|
||||
test('shows error when token verification fails', async ({ page }) => {
|
||||
await page.route('**/api/invite/verify', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ statusCode: 401, statusMessage: 'Invalid or expired invitation link' }),
|
||||
})
|
||||
})
|
||||
await gotoAcceptInvite(page)
|
||||
await expect(page.getByRole('heading', { name: 'Invitation Error' })).toBeVisible()
|
||||
await expect(page.locator('.error-box')).toContainText(/Invalid or expired/)
|
||||
})
|
||||
|
||||
test('submit disabled until name + agreement filled', async ({ page }) => {
|
||||
await mockVerifyOk(page, { name: '' })
|
||||
await gotoAcceptInvite(page)
|
||||
|
||||
await expect(page.locator('#accept-name')).toBeVisible()
|
||||
await expect(page.locator('.form-submit')).toBeDisabled()
|
||||
|
||||
await page.locator('#accept-name').fill('New Member')
|
||||
await expect(page.locator('.form-submit')).toBeDisabled()
|
||||
|
||||
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||
await expect(page.locator('.form-submit')).toBeEnabled()
|
||||
})
|
||||
|
||||
test('cadence toggle updates billing summary total', async ({ page }) => {
|
||||
await mockVerifyOk(page)
|
||||
await gotoAcceptInvite(page)
|
||||
|
||||
await expect(page.locator('#accept-contribution')).toBeVisible()
|
||||
await page.locator('#accept-contribution').fill('10')
|
||||
|
||||
await page.locator('label[for="accept-cadence-monthly"]').click()
|
||||
await expect(page.locator('.billing-summary')).toContainText('$10 today')
|
||||
|
||||
await page.locator('label[for="accept-cadence-annual"]').click()
|
||||
await expect(page.locator('.billing-summary')).toContainText('$120 today')
|
||||
await expect(page.locator('.billing-summary')).toContainText('$10/month')
|
||||
})
|
||||
|
||||
test('preset chip sets contribution amount', async ({ page }) => {
|
||||
await mockVerifyOk(page)
|
||||
await gotoAcceptInvite(page)
|
||||
|
||||
await expect(page.locator('.contribution-preset-chip').first()).toBeVisible()
|
||||
const chip = page.locator('.contribution-preset-chip').nth(1)
|
||||
const chipText = await chip.textContent()
|
||||
const expected = chipText.replace(/[^0-9]/g, '')
|
||||
|
||||
await chip.click()
|
||||
await expect(page.locator('#accept-contribution')).toHaveValue(expected)
|
||||
})
|
||||
|
||||
test('free tier happy path shows welcome state', async ({ page }) => {
|
||||
await mockVerifyOk(page, { name: 'Free Tester', email: `free-${Date.now()}@example.com` })
|
||||
await mockAcceptFree(page)
|
||||
await gotoAcceptInvite(page)
|
||||
|
||||
await expect(page.locator('#accept-name')).toHaveValue('Free Tester')
|
||||
await page.locator('#circle-community').check({ force: true })
|
||||
await page.locator('#accept-contribution').fill('0')
|
||||
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||
|
||||
await expect(page.locator('.form-submit')).toBeEnabled()
|
||||
await expect(page.locator('.form-submit')).toContainText(/Accept Invitation/)
|
||||
|
||||
await page.locator('.form-submit').click()
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('paid tier submit button copy switches to Continue to Payment', async ({ page }) => {
|
||||
await mockVerifyOk(page)
|
||||
await gotoAcceptInvite(page)
|
||||
|
||||
await page.locator('#accept-contribution').fill('10')
|
||||
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||
await expect(page.locator('.form-submit')).toContainText(/Continue to Payment/)
|
||||
})
|
||||
|
||||
// Skipped: full paid-tier submission requires intercepting HelcimPay.js modal
|
||||
// (external script loads an iframe and posts a message back to verifyPayment).
|
||||
// Feasible but out of scope for this initial coverage pass.
|
||||
test.skip('paid tier full flow with mocked HelcimPay', async () => {})
|
||||
})
|
||||
|
|
@ -53,3 +53,116 @@ test.describe('Admin events access control', () => {
|
|||
expect(page.url()).not.toContain('/admin/events')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Admin events CRUD', () => {
|
||||
test('create, edit, and delete an event', async ({ adminPage }) => {
|
||||
const suffix = Date.now().toString().slice(-6)
|
||||
const title = `e2e-event-${suffix}`
|
||||
const editedTitle = `e2e-event-${suffix}-edited`
|
||||
|
||||
// Re-prime the auth cookie immediately before this multi-step flow.
|
||||
// The shared test-admin account's tokenVersion is bumped whenever
|
||||
// auth.spec.js's logout test runs in parallel, which would otherwise
|
||||
// surface mid-flow as "Session has been revoked" on the first POST.
|
||||
const loginRes = await adminPage.context().request.get('/api/dev/test-login', { maxRedirects: 0 })
|
||||
if (loginRes.status() !== 302) {
|
||||
throw new Error(`Failed to refresh admin session: ${loginRes.status()}`)
|
||||
}
|
||||
|
||||
// --- Create ---
|
||||
await adminPage.goto('/admin/events/create')
|
||||
await expect(adminPage.locator('h1')).toContainText('Create Event')
|
||||
// Ensure Vue has hydrated (initial $fetch for series/tags has resolved)
|
||||
// before interacting — under cross-file load, hydration can lag and a
|
||||
// pre-hydration submit will native-POST against an empty form.
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
|
||||
await adminPage
|
||||
.getByPlaceholder('Enter a clear, descriptive event title')
|
||||
.fill(title)
|
||||
|
||||
await adminPage
|
||||
.getByPlaceholder(
|
||||
'Provide a clear description of what attendees can expect from this event'
|
||||
)
|
||||
.fill('e2e test event description')
|
||||
|
||||
await adminPage
|
||||
.getByPlaceholder('e.g., https://zoom.us/j/123... or #channel-name')
|
||||
.fill('https://example.com/zoom')
|
||||
|
||||
const startInput = adminPage.getByPlaceholder(
|
||||
"e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
|
||||
)
|
||||
await startInput.fill('next Tuesday at 3pm')
|
||||
await startInput.blur()
|
||||
|
||||
const endInput = adminPage.getByPlaceholder(
|
||||
"e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
|
||||
)
|
||||
await endInput.fill('next Tuesday at 5pm')
|
||||
await endInput.blur()
|
||||
|
||||
await adminPage.getByRole('button', { name: 'Create Event' }).click()
|
||||
|
||||
// The form posts via $fetch and then auto-redirects after a 1.5s setTimeout.
|
||||
// Under cross-file load that auto-redirect can race against waitForURL.
|
||||
// Wait for the surfaced success/error state, fail fast on error, then
|
||||
// navigate explicitly so subsequent assertions are deterministic.
|
||||
await expect(
|
||||
adminPage.locator('.success-box').or(adminPage.locator('.error-box'))
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
await expect(adminPage.locator('.success-box')).toBeVisible()
|
||||
await adminPage.goto('/admin/events')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
|
||||
// Filter to just our event — orphan rows from prior failed runs can push
|
||||
// the new row off page 1 of the paginated list.
|
||||
await adminPage.getByPlaceholder('Search events...').fill(title)
|
||||
const row = adminPage.locator('tr', { hasText: title })
|
||||
await expect(row).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// --- Edit ---
|
||||
// Find the event ID from the row's "View" link (href is /events/<slug-or-id>),
|
||||
// and use the row's Edit button. Pair the click with waitForURL so we don't
|
||||
// miss the navigation event under load.
|
||||
await Promise.all([
|
||||
adminPage.waitForURL(/\/admin\/events\/create\?edit=/, { timeout: 15000 }),
|
||||
row.getByRole('button', { name: 'Edit' }).click(),
|
||||
])
|
||||
await expect(adminPage.locator('h1')).toContainText('Edit Event')
|
||||
|
||||
const titleInput = adminPage.getByPlaceholder(
|
||||
'Enter a clear, descriptive event title'
|
||||
)
|
||||
await titleInput.fill(editedTitle)
|
||||
|
||||
await adminPage.getByRole('button', { name: 'Update Event' }).click()
|
||||
|
||||
await expect(
|
||||
adminPage.locator('.success-box').or(adminPage.locator('.error-box'))
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
await expect(adminPage.locator('.success-box')).toBeVisible()
|
||||
await adminPage.goto('/admin/events')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
|
||||
// Filter to the edited event's unique title for the same pagination reason.
|
||||
await adminPage.getByPlaceholder('Search events...').fill(editedTitle)
|
||||
const editedRow = adminPage.locator('tr', { hasText: editedTitle })
|
||||
await expect(editedRow).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// --- Delete (custom modal, not browser dialog) ---
|
||||
await editedRow.getByRole('button', { name: 'Del' }).click()
|
||||
await expect(
|
||||
adminPage.getByRole('heading', { name: 'Delete Event' })
|
||||
).toBeVisible()
|
||||
await adminPage
|
||||
.locator('.modal')
|
||||
.getByRole('button', { name: 'Delete' })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
adminPage.locator('tr', { hasText: editedTitle })
|
||||
).toHaveCount(0, { timeout: 10000 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -66,4 +66,68 @@ test.describe("Admin members page", () => {
|
|||
adminPage.getByPlaceholder("email@example.com"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("create member, status select reflects STATUS_LABELS, change persists, detail page renders", async ({ adminPage }) => {
|
||||
const stamp = Date.now();
|
||||
const memberName = `E2E Member ${stamp}`;
|
||||
const memberEmail = `e2e-member-${stamp}@example.test`;
|
||||
|
||||
await adminPage.goto("/admin/members");
|
||||
await adminPage.waitForLoadState("networkidle");
|
||||
await expect(adminPage.locator("h1")).toHaveText("Members");
|
||||
|
||||
await adminPage.getByRole("button", { name: "Add Member" }).click();
|
||||
await adminPage.getByPlaceholder("Full name").fill(memberName);
|
||||
await adminPage.getByPlaceholder("email@example.com").fill(memberEmail);
|
||||
await adminPage.getByRole("button", { name: "Create Member" }).click();
|
||||
|
||||
// Verify the new member shows up via search
|
||||
const searchInput = adminPage.getByPlaceholder("Search members...");
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
await searchInput.fill(memberEmail);
|
||||
|
||||
const memberRow = adminPage.locator("tr", { hasText: memberEmail });
|
||||
await expect(memberRow).toBeVisible({ timeout: 10000 });
|
||||
await expect(memberRow.getByText(memberName)).toBeVisible();
|
||||
|
||||
// Open the edit modal for this member, where the STATUS_LABELS-driven <select> lives
|
||||
await memberRow.getByRole("button", { name: "Edit" }).click();
|
||||
|
||||
const statusSelect = adminPage.locator(".modal select").filter({ hasText: "Active" });
|
||||
await expect(statusSelect).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// STATUS_LABELS keys (values) and the rendered labels
|
||||
const expectedOptions = [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "pending_payment", label: "Payment setup incomplete" },
|
||||
{ value: "suspended", label: "Paused" },
|
||||
{ value: "cancelled", label: "Closed" },
|
||||
];
|
||||
for (const { value, label } of expectedOptions) {
|
||||
const opt = statusSelect.locator(`option[value="${value}"]`);
|
||||
await expect(opt).toHaveCount(1);
|
||||
await expect(opt).toHaveText(label);
|
||||
}
|
||||
|
||||
// Change status to suspended and save
|
||||
await statusSelect.selectOption("suspended");
|
||||
await adminPage.getByRole("button", { name: "Save Changes" }).click();
|
||||
|
||||
// Modal closes; verify the row badge reflects the new status
|
||||
await expect(adminPage.locator(".modal")).toHaveCount(0, { timeout: 10000 });
|
||||
await expect(memberRow.getByText("Paused")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Reload to confirm persistence
|
||||
await adminPage.reload();
|
||||
await adminPage.waitForLoadState("networkidle");
|
||||
await adminPage.getByPlaceholder("Search members...").fill(memberEmail);
|
||||
const reloadedRow = adminPage.locator("tr", { hasText: memberEmail });
|
||||
await expect(reloadedRow.getByText("Paused")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click the member name (link to detail page) and verify URL + heading
|
||||
await reloadedRow.getByRole("link", { name: memberName }).click();
|
||||
await adminPage.waitForURL(/\/admin\/members\/[a-f0-9]{24}$/, { timeout: 10000 });
|
||||
await expect(adminPage.locator("h1")).toHaveText(memberName);
|
||||
await expect(adminPage.locator(".member-email")).toHaveText(memberEmail);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
111
e2e/admin-pre-registrants.spec.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { test, expect } from './helpers/fixtures.js'
|
||||
|
||||
test.describe('Admin pre-registrants page', () => {
|
||||
test('page loads for admin', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/pre-registrants')
|
||||
|
||||
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
await expect(
|
||||
adminPage.locator('table').or(adminPage.getByText('No pre-registrants found matching your criteria')),
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('header action buttons render', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/pre-registrants')
|
||||
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
await expect(adminPage.getByRole('button', { name: /^Mark as Selected/ })).toBeVisible()
|
||||
await expect(adminPage.getByRole('button', { name: /^Send Invites/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test('search input filters list without crashing', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/pre-registrants')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
|
||||
const search = adminPage.getByPlaceholder('Search by name, email, city, role...')
|
||||
await expect(search).toBeVisible({ timeout: 15000 })
|
||||
|
||||
await search.fill(`nonexistent-prereg-${Date.now()}`)
|
||||
|
||||
await expect(
|
||||
adminPage.getByText('No pre-registrants found matching your criteria'),
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('status filter changes selection', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/pre-registrants')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
|
||||
const statusFilter = adminPage.getByLabel('Filter by status')
|
||||
await expect(statusFilter).toBeVisible({ timeout: 15000 })
|
||||
|
||||
await statusFilter.selectOption('expired')
|
||||
await expect(statusFilter).toHaveValue('expired')
|
||||
|
||||
await expect(
|
||||
adminPage.locator('table').or(adminPage.getByText('No pre-registrants found matching your criteria')),
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
|
||||
await statusFilter.selectOption('')
|
||||
await expect(statusFilter).toHaveValue('')
|
||||
})
|
||||
|
||||
test('Send Invites button is disabled with no selection', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/pre-registrants')
|
||||
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
await expect(adminPage.getByRole('button', { name: 'Send Invites (0)' })).toBeDisabled()
|
||||
await expect(adminPage.getByRole('button', { name: 'Mark as Selected (0)' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('send invite action', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/pre-registrants')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
await expect(adminPage.getByRole('heading', { name: 'Pre-Registrants' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// Filter to invitable statuses; pick the first row if available.
|
||||
const statusFilter = adminPage.getByLabel('Filter by status')
|
||||
await statusFilter.selectOption('pending')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
|
||||
const firstRow = adminPage.locator('tbody tr').first()
|
||||
if (await firstRow.count() === 0) {
|
||||
test.skip(true, 'No pending pre-registrants in dev DB to invite')
|
||||
return
|
||||
}
|
||||
|
||||
await firstRow.locator('.col-name').click()
|
||||
const sendButton = adminPage.getByRole('button', { name: /^Send Invites \(\d+\)/ })
|
||||
await expect(sendButton).toBeEnabled()
|
||||
await sendButton.click()
|
||||
|
||||
await expect(adminPage.getByRole('heading', { name: 'Send Invitation Emails' })).toBeVisible()
|
||||
|
||||
const submitButton = adminPage.getByRole('button', { name: /^Send \d+ invitation/ })
|
||||
await submitButton.click()
|
||||
|
||||
// ALLOW_DEV_TEST_ENDPOINTS=true short-circuits the Resend call; result still reports sent.
|
||||
await expect(adminPage.getByText(/^\d+ sent$/)).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('non-admin redirect', async ({ browser }) => {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
|
||||
await page.goto('/admin/pre-registrants')
|
||||
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
|
||||
expect(page.url()).not.toContain('/admin/pre-registrants')
|
||||
|
||||
await context.close()
|
||||
})
|
||||
})
|
||||
65
e2e/admin-series.spec.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { test, expect } from './helpers/fixtures.js'
|
||||
|
||||
test.describe('Admin series management page', () => {
|
||||
test('series list loads for admin', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/series-management')
|
||||
await expect(adminPage.getByRole('heading', { name: 'Series', level: 1 })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await expect(adminPage.getByRole('link', { name: 'Create Series' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Admin series access control', () => {
|
||||
test('non-admin redirect', async ({ page }) => {
|
||||
await page.goto('/admin/series-management')
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
|
||||
expect(page.url()).not.toContain('/admin/series-management')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Admin series CRUD', () => {
|
||||
test('create and edit a series', async ({ adminPage }) => {
|
||||
const suffix = Date.now().toString().slice(-6)
|
||||
const title = `e2e-series-${suffix}`
|
||||
const description = 'e2e test series description'
|
||||
const editedDescription = 'e2e test series description edited'
|
||||
|
||||
// --- Create ---
|
||||
await adminPage.goto('/admin/series/create')
|
||||
await expect(adminPage.locator('h1')).toContainText('Create New Series')
|
||||
|
||||
await adminPage
|
||||
.getByPlaceholder('e.g., Cooperative Game Development Fundamentals')
|
||||
.fill(title)
|
||||
|
||||
await adminPage
|
||||
.getByPlaceholder('Describe what the series covers and its goals')
|
||||
.fill(description)
|
||||
|
||||
await adminPage.getByRole('button', { name: 'Create Series' }).click()
|
||||
|
||||
await adminPage.waitForURL('**/admin/series-management', { timeout: 15000 })
|
||||
|
||||
const card = adminPage.locator('.series-card', { hasText: title })
|
||||
await expect(card).toBeVisible({ timeout: 10000 })
|
||||
await expect(card).toContainText(description)
|
||||
|
||||
// --- Edit (in-page modal) ---
|
||||
await card.getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(adminPage.getByRole('heading', { name: 'Edit Series' })).toBeVisible()
|
||||
|
||||
const descInput = adminPage.locator('textarea[placeholder="Brief description of this series"]')
|
||||
await descInput.fill(editedDescription)
|
||||
await adminPage.getByRole('button', { name: 'Save Changes' }).click()
|
||||
|
||||
const editedCard = adminPage.locator('.series-card', { hasText: title })
|
||||
await expect(editedCard).toContainText(editedDescription, { timeout: 10000 })
|
||||
})
|
||||
|
||||
// Delete is skipped: the series-management page's "Delete" button only
|
||||
// unlinks events from the series via PUT /api/admin/events/:id; it does
|
||||
// not call DELETE /api/admin/series/:id, so the series record remains.
|
||||
// No UI affordance currently exists to remove an empty series.
|
||||
test.skip('delete a series', async () => {})
|
||||
})
|
||||
85
e2e/admin-site-content.spec.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { test, expect } from './helpers/fixtures.js'
|
||||
|
||||
const WHITELISTED_KEYS = ['homepage.wiki_feature']
|
||||
|
||||
test.describe('Admin site content page', () => {
|
||||
test('page loads for admin', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/site-content')
|
||||
await expect(adminPage.getByRole('heading', { name: 'Site Content' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
})
|
||||
|
||||
test('renders one block per whitelisted key', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/site-content')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
await expect(adminPage.getByRole('heading', { name: 'Site Content' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
const blocks = adminPage.locator('.content-block')
|
||||
await expect(blocks).toHaveCount(WHITELISTED_KEYS.length)
|
||||
|
||||
for (const key of WHITELISTED_KEYS) {
|
||||
await expect(adminPage.locator('.block-key', { hasText: key })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('edit, save, persist, and reflect on public page', async ({ adminPage }) => {
|
||||
const key = 'homepage.wiki_feature'
|
||||
|
||||
await adminPage.goto('/admin/site-content')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
await expect(adminPage.getByRole('heading', { name: 'Site Content' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
const original = await adminPage.evaluate(
|
||||
async (k) => await (await fetch(`/api/site-content/${k}`)).json(),
|
||||
key,
|
||||
)
|
||||
const originalTitle = original.title || ''
|
||||
const originalBody = original.body || ''
|
||||
|
||||
const stamp = Date.now()
|
||||
const newTitle = `e2e title ${stamp}`
|
||||
const newBody = `e2e body paragraph ${stamp}`
|
||||
|
||||
const block = adminPage.locator('.content-block', {
|
||||
has: adminPage.locator('.block-key', { hasText: key }),
|
||||
})
|
||||
await expect(block).toBeVisible()
|
||||
|
||||
const titleInput = block.locator('input[type="text"]')
|
||||
const bodyTextarea = block.locator('textarea')
|
||||
|
||||
await titleInput.fill(newTitle)
|
||||
await bodyTextarea.fill(newBody)
|
||||
await block.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(block.locator('.block-meta')).toContainText('Updated', { timeout: 10000 })
|
||||
|
||||
await adminPage.reload()
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
const reloadedBlock = adminPage.locator('.content-block', {
|
||||
has: adminPage.locator('.block-key', { hasText: key }),
|
||||
})
|
||||
await expect(reloadedBlock.locator('input[type="text"]')).toHaveValue(newTitle)
|
||||
await expect(reloadedBlock.locator('textarea')).toHaveValue(newBody)
|
||||
|
||||
await adminPage.goto('/')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
await expect(adminPage.getByText(newBody)).toBeVisible({ timeout: 15000 })
|
||||
|
||||
await adminPage.evaluate(
|
||||
async ({ k, t, b }) => {
|
||||
await fetch(`/api/admin/site-content/${k}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: t, body: b }),
|
||||
})
|
||||
},
|
||||
{ k: key, t: originalTitle, b: originalBody },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,13 +1,34 @@
|
|||
import { test, expect } from './helpers/fixtures.js'
|
||||
import { loginAsMember } from './helpers/auth.js'
|
||||
|
||||
// The default `memberPage` fixture authenticates as test-admin@ghostguild.dev,
|
||||
// the same account auth.spec.js's logout test revokes mid-suite. Bypass the
|
||||
// fixture and use a seeded, non-shared member instead so cross-file logout
|
||||
// can't strand this file mid-flow.
|
||||
const SEEDED_MEMBER_EMAIL = 'riley.johnson@cooperativedev.org'
|
||||
|
||||
const newMemberPage = async (browser) => {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
await loginAsMember(page, SEEDED_MEMBER_EMAIL)
|
||||
return { context, page }
|
||||
}
|
||||
|
||||
test.describe('Board page', () => {
|
||||
test('page loads for authenticated member', async ({ memberPage }) => {
|
||||
test('page loads for authenticated member', async ({ browser }) => {
|
||||
const { context, page: memberPage } = await newMemberPage(browser)
|
||||
try {
|
||||
await memberPage.goto('/board')
|
||||
await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 })
|
||||
await expect(memberPage.getByRole('heading', { name: 'Bulletin Board' })).toBeVisible({ timeout: 15000 })
|
||||
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible()
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('clicking New Post reveals the form', async ({ memberPage }) => {
|
||||
test('clicking New Post reveals the form', async ({ browser }) => {
|
||||
const { context, page: memberPage } = await newMemberPage(browser)
|
||||
try {
|
||||
await memberPage.goto('/board')
|
||||
await memberPage.waitForLoadState('networkidle')
|
||||
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
|
||||
|
|
@ -19,11 +40,16 @@ test.describe('Board page', () => {
|
|||
await expect(memberPage.getByRole('heading', { name: 'New post' })).toBeVisible()
|
||||
await expect(memberPage.locator('#post-title')).toBeVisible()
|
||||
await expect(memberPage.locator('#post-seeking')).toBeVisible()
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('tags drawer toggles open and closed', async ({ memberPage }) => {
|
||||
test('tags drawer toggles open and closed', async ({ browser }) => {
|
||||
const { context, page: memberPage } = await newMemberPage(browser)
|
||||
try {
|
||||
await memberPage.goto('/board')
|
||||
await expect(memberPage.getByRole('heading', { name: 'Board' })).toBeVisible({ timeout: 15000 })
|
||||
await expect(memberPage.getByRole('heading', { name: 'Bulletin Board' })).toBeVisible({ timeout: 15000 })
|
||||
|
||||
const drawerToggle = memberPage.getByRole('button', { name: /^Tags\.\.\./ })
|
||||
// Drawer toggle only appears if cooperative tags exist — skip quietly if not
|
||||
|
|
@ -37,9 +63,14 @@ test.describe('Board page', () => {
|
|||
|
||||
await drawerToggle.click()
|
||||
await expect(memberPage.getByText('Filter:')).not.toBeVisible()
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('create, edit, and delete own post', async ({ memberPage }) => {
|
||||
test('create, edit, and delete own post', async ({ browser }) => {
|
||||
const { context, page: memberPage } = await newMemberPage(browser)
|
||||
try {
|
||||
await memberPage.goto('/board')
|
||||
await memberPage.waitForLoadState('networkidle')
|
||||
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
|
||||
|
|
@ -85,5 +116,8 @@ test.describe('Board page', () => {
|
|||
await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -67,3 +67,128 @@ test.describe('Events list page', () => {
|
|||
await expect(page.locator('h1')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
async function navigateToFirstEventDetail(page) {
|
||||
await page.goto('/events')
|
||||
await page.locator('.past-toggle').click()
|
||||
await page.waitForLoadState('networkidle')
|
||||
const eventLinks = page.locator('.event-row a')
|
||||
const count = await eventLinks.count()
|
||||
if (count === 0) return null
|
||||
const href = await eventLinks.first().getAttribute('href')
|
||||
return href
|
||||
}
|
||||
|
||||
test.describe('Event detail — ticket gating', () => {
|
||||
test('series-pass-required shows pass-required notice instead of buy button', async ({ page }) => {
|
||||
const href = await navigateToFirstEventDetail(page)
|
||||
test.skip(!href, 'No events in dev DB to navigate against')
|
||||
|
||||
await page.route('**/api/events/*/tickets/available**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
available: false,
|
||||
reason: 'series_pass_required',
|
||||
requiresSeriesPass: true,
|
||||
series: { id: 'series-stub', slug: 'series-stub', title: 'Stub Series' }
|
||||
})
|
||||
})
|
||||
})
|
||||
await page.route('**/api/events/*/check-series-access**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ requiresSeriesPass: false })
|
||||
})
|
||||
})
|
||||
|
||||
await page.locator(`.event-row a[href="${href}"]`).first().click()
|
||||
await page.waitForURL(`**${href}`)
|
||||
|
||||
const ticketPanel = page.locator('.event-ticket-purchase')
|
||||
await expect(ticketPanel.locator('.ticket-status', { hasText: 'Series Pass Required' })).toBeVisible()
|
||||
await expect(ticketPanel.locator('button', { hasText: /Pay |Register for this event|Complete Registration/ })).toHaveCount(0)
|
||||
await expect(ticketPanel.locator('a[href="/series/series-stub"] button')).toBeVisible()
|
||||
})
|
||||
|
||||
test('memberSavings line is hidden for anonymous viewers', async ({ page }) => {
|
||||
const href = await navigateToFirstEventDetail(page)
|
||||
test.skip(!href, 'No events in dev DB to navigate against')
|
||||
|
||||
await page.route('**/api/events/*/tickets/available**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
available: true,
|
||||
alreadyRegistered: false,
|
||||
isFree: false,
|
||||
isMember: false,
|
||||
name: 'General Admission',
|
||||
formattedPrice: '$25.00',
|
||||
remaining: 10,
|
||||
memberSavings: 0,
|
||||
publicTicket: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
await page.locator(`.event-row a[href="${href}"]`).first().click()
|
||||
await page.waitForURL(`**${href}`)
|
||||
|
||||
const ticketCard = page.locator('.ticket-card')
|
||||
await expect(ticketCard).toBeVisible()
|
||||
await expect(page.locator('.ticket-savings')).toHaveCount(0)
|
||||
await expect(page.locator('text=/save .* as a member/i')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('memberSavings line is shown when API reports savings', async ({ page }) => {
|
||||
const href = await navigateToFirstEventDetail(page)
|
||||
test.skip(!href, 'No events in dev DB to navigate against')
|
||||
|
||||
await page.route('**/api/events/*/tickets/available**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
available: true,
|
||||
alreadyRegistered: false,
|
||||
isFree: false,
|
||||
isMember: true,
|
||||
name: 'Member Ticket',
|
||||
formattedPrice: '$10.00',
|
||||
remaining: 10,
|
||||
memberSavings: 15,
|
||||
publicTicket: { formattedPrice: '$25.00' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
await page.locator(`.event-row a[href="${href}"]`).first().click()
|
||||
await page.waitForURL(`**${href}`)
|
||||
|
||||
const savings = page.locator('.ticket-savings')
|
||||
await expect(savings).toBeVisible()
|
||||
await expect(savings).toContainText(/save/i)
|
||||
})
|
||||
|
||||
test.skip('hidden event returns 404', async () => {
|
||||
// Skipped: hidden-event gating happens during SSR useFetch in [slug].vue,
|
||||
// which page.route cannot intercept. Verifying this gate requires either
|
||||
// seeding a hidden event in the dev DB or a server-side mock layer.
|
||||
})
|
||||
|
||||
test.skip('past-deadline event shows registration-closed copy', async () => {
|
||||
// Skipped: when the available endpoint returns reason
|
||||
// "Registration deadline has passed", the current UI surfaces it as the
|
||||
// generic "Event Sold Out" panel — there is no distinct "Registration
|
||||
// closed" string to assert against without changing the component.
|
||||
})
|
||||
|
||||
test.skip('member with paid registration cannot self-cancel', async () => {
|
||||
// Skipped: requires seeding an authed member with a paid registration in
|
||||
// the DB, which is out of scope for API-level mocking.
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,36 +1,32 @@
|
|||
/**
|
||||
* Login helpers using dev endpoints.
|
||||
* These set real httpOnly JWT cookies so all middleware works naturally.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Login as admin via the dev test-login endpoint.
|
||||
* Creates a test admin user if none exists and sets the auth cookie.
|
||||
* Waits for networkidle so the client-side auth check (admin middleware +
|
||||
* auth-init plugin) completes before the test navigates anywhere.
|
||||
*
|
||||
* Implementation note: hits the dev endpoints via the APIRequestContext
|
||||
* (no page navigation). The Set-Cookie response writes auth-token to the
|
||||
* BrowserContext's cookie jar, so any subsequent page.goto() is authed.
|
||||
* Avoids the Nuxt-dev networkidle race that made page.goto-based login flaky.
|
||||
*/
|
||||
export async function loginAsAdmin(page) {
|
||||
await page.goto('/api/dev/test-login', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// The endpoint sets the cookie and redirects to /admin.
|
||||
// waitForURL fires as soon as the URL changes — not when JS finishes.
|
||||
// waitForLoadState('networkidle') ensures the auth-init plugin and admin
|
||||
// middleware have both completed their checkMemberStatus() calls before
|
||||
// the test proceeds.
|
||||
try {
|
||||
await page.waitForURL(/\/admin/, { timeout: 15000 })
|
||||
await page.waitForLoadState('networkidle')
|
||||
} catch {
|
||||
// Cookie should be set even if redirect failed — navigate manually
|
||||
await page.goto('/admin', { waitUntil: 'networkidle' })
|
||||
await page.waitForURL(/\/admin/)
|
||||
const res = await page.context().request.get('/api/dev/test-login', { maxRedirects: 0 })
|
||||
if (res.status() !== 302) {
|
||||
throw new Error(`/api/dev/test-login returned ${res.status()}; expected 302`)
|
||||
}
|
||||
const cookies = await page.context().cookies()
|
||||
if (!cookies.find((c) => c.name === 'auth-token')) {
|
||||
throw new Error('/api/dev/test-login did not set auth-token cookie')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login as a specific member by email via the dev member-login endpoint.
|
||||
*/
|
||||
export async function loginAsMember(page, email) {
|
||||
await page.goto(`/api/dev/member-login?email=${encodeURIComponent(email)}`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForURL(/\/member\//)
|
||||
const res = await page.context().request.get(
|
||||
`/api/dev/member-login?email=${encodeURIComponent(email)}`,
|
||||
{ maxRedirects: 0 }
|
||||
)
|
||||
if (res.status() !== 302) {
|
||||
throw new Error(`/api/dev/member-login returned ${res.status()}; expected 302`)
|
||||
}
|
||||
const cookies = await page.context().cookies()
|
||||
if (!cookies.find((c) => c.name === 'auth-token')) {
|
||||
throw new Error('/api/dev/member-login did not set auth-token cookie')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,104 @@ test.describe('Join page — member signup flow', () => {
|
|||
).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('cadence toggle updates billing summary to annual ×12', async ({ page }) => {
|
||||
await page.goto('/join')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await page.locator('#join-contribution').fill('10')
|
||||
await page.locator('label[for="cadence-annual"]').click()
|
||||
|
||||
const summary = page.locator('.billing-summary')
|
||||
await expect(summary).toBeVisible()
|
||||
await expect(summary).toContainText('$120 today')
|
||||
await expect(summary).toContainText('$10/month × 12')
|
||||
await expect(summary).toContainText('$120 every year')
|
||||
|
||||
await page.locator('label[for="cadence-monthly"]').click()
|
||||
await expect(summary).toContainText('$10 today')
|
||||
await expect(summary).toContainText('$10 every month')
|
||||
})
|
||||
|
||||
test('contribution guidance label changes with amount tier', async ({ page }) => {
|
||||
await page.goto('/join')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const guidance = page.locator('.contribution-guidance')
|
||||
|
||||
await page.locator('#join-contribution').fill('5')
|
||||
await expect(guidance).toHaveText(/I can contribute/)
|
||||
|
||||
await page.locator('#join-contribution').fill('30')
|
||||
await expect(guidance).toHaveText(/I can support others too/)
|
||||
})
|
||||
|
||||
test('paid tier flow reaches success state with HelcimPay stubbed', async ({ page }) => {
|
||||
const uniqueEmail = `test-e2e-paid-${Date.now()}@example.com`
|
||||
|
||||
// Stub HelcimPay window globals before the page loads so the composable's
|
||||
// script-load path is bypassed and we resolve verifyPayment synchronously.
|
||||
await page.addInitScript(() => {
|
||||
window.appendHelcimPayIframe = (checkoutToken) => {
|
||||
const eventName = 'helcim-pay-js-' + checkoutToken
|
||||
setTimeout(() => {
|
||||
window.postMessage({
|
||||
eventName,
|
||||
eventStatus: 'SUCCESS',
|
||||
eventMessage: JSON.stringify({
|
||||
data: {
|
||||
data: {
|
||||
transactionId: 'stub-txn-1',
|
||||
cardToken: 'stub-card-token-1',
|
||||
cardNumber: '4111111111111234',
|
||||
cardType: 'visa'
|
||||
}
|
||||
}
|
||||
})
|
||||
}, '*')
|
||||
}, 50)
|
||||
}
|
||||
window.removeHelcimPayIframe = () => {}
|
||||
})
|
||||
|
||||
await page.goto('/join')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await mockHelcimAPIs(page)
|
||||
|
||||
await page.route('**/api/helcim/initialize-payment', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
checkoutToken: 'stub-checkout-token',
|
||||
secretToken: 'stub-secret-token'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/helcim/verify-payment', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true })
|
||||
})
|
||||
})
|
||||
|
||||
await page.locator('#join-name').fill('Paid E2E User')
|
||||
await page.locator('#join-email').fill(uniqueEmail)
|
||||
await page.locator('#circle-community').check({ force: true })
|
||||
await page.locator('#join-contribution').fill('15')
|
||||
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||
|
||||
await expect(page.locator('.form-submit')).toBeEnabled()
|
||||
await page.locator('.form-submit').click()
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('duplicate email shows error', async ({ page }) => {
|
||||
const duplicateEmail = `test-e2e-dup-${Date.now()}@example.com`
|
||||
|
||||
|
|
|
|||
|
|
@ -1,180 +0,0 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { loginAsAdmin } from '../helpers/auth.js'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
const viewports = {
|
||||
desktop: { width: 1280, height: 720 },
|
||||
mobile: { width: 375, height: 667 },
|
||||
}
|
||||
|
||||
const publicPages = [
|
||||
{ name: 'home', path: '/' },
|
||||
{ name: 'join', path: '/join' },
|
||||
{ name: 'events', path: '/events' },
|
||||
{ name: 'coming-soon', path: '/coming-soon' },
|
||||
// about and members have no auth middleware — accessible publicly
|
||||
{ name: 'about', path: '/about' },
|
||||
{ name: 'members', path: '/members' },
|
||||
]
|
||||
|
||||
const authenticatedPages = [
|
||||
{ name: 'member-dashboard', path: '/member/dashboard' },
|
||||
{ name: 'member-profile', path: '/member/profile' },
|
||||
{ name: 'admin-members', path: '/admin/members' },
|
||||
{ name: 'admin-events-create', path: '/admin/events/create' },
|
||||
// New authenticated pages
|
||||
{ name: 'member-account', path: '/member/account' },
|
||||
{ name: 'connections', path: '/connections' },
|
||||
{ name: 'admin-dashboard', path: '/admin' },
|
||||
]
|
||||
|
||||
// Pages that need mobile coverage captured while authenticated.
|
||||
// These cover column-collapse breakpoints critical for the page-shell refactor.
|
||||
// Snapshots use the -mobile-auth suffix to distinguish from the public mobile loop
|
||||
// (which also captures about-mobile unauthenticated, so names must not collide).
|
||||
const authenticatedMobilePages = [
|
||||
{ name: 'about', path: '/about' },
|
||||
{ name: 'member-dashboard', path: '/member/dashboard' },
|
||||
{ name: 'member-profile', path: '/member/profile' },
|
||||
{ name: 'member-account', path: '/member/account' },
|
||||
{ name: 'connections', path: '/connections' },
|
||||
]
|
||||
|
||||
// Path where the saved admin auth state (cookies) will be stored within a run.
|
||||
const authStatePath = path.resolve('e2e/.auth/admin.json')
|
||||
|
||||
// Wait for fonts and images to load before taking screenshots
|
||||
async function waitForStable(page) {
|
||||
await page.waitForLoadState('networkidle')
|
||||
// Wait for web fonts to load
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
}
|
||||
|
||||
// Common mask selectors for dynamic content
|
||||
function commonMasks(page) {
|
||||
return [
|
||||
// Dates and times throughout the app
|
||||
page.locator('.event-date'),
|
||||
page.locator('.event-count'),
|
||||
page.locator('time'),
|
||||
page.locator('.member-since'),
|
||||
// Activity log timestamps
|
||||
page.locator('.tl-time'),
|
||||
// Admin dashboard stat values (member counts, revenue, etc.)
|
||||
page.locator('.stat-val'),
|
||||
// Recent member join dates in admin dashboard
|
||||
page.locator('.item-date'),
|
||||
// Member avatars (ghost images may not load deterministically)
|
||||
page.locator('.mc-avatar'),
|
||||
page.locator('.cc-avatar'),
|
||||
page.locator('.profile-avatar'),
|
||||
// Member count text in members page filter bar
|
||||
page.locator('.filter-count'),
|
||||
// Connections page: filter bar and suggestions vary based on tag/topic
|
||||
// state and async fetch ordering. Mask them to keep the structural
|
||||
// (PageShell + page-level) regression coverage stable.
|
||||
page.locator('.filter-bar'),
|
||||
page.locator('.skills-bar'),
|
||||
page.locator('.connections-section'),
|
||||
page.locator('.loading-state'),
|
||||
]
|
||||
}
|
||||
|
||||
// All visual tests run serially in a single top-level describe block.
|
||||
//
|
||||
// Auth is handled with a beforeAll that saves the cookie to disk once. All
|
||||
// authenticated sub-describes load from that saved state, avoiding repeated
|
||||
// /api/dev/test-login calls that exhaust the dev server's MongoDB connections.
|
||||
test.describe('visual regression', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
// Log in once before all tests and save the auth cookie.
|
||||
// serial mode guarantees this runs before any test in this describe tree.
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
fs.mkdirSync(path.dirname(authStatePath), { recursive: true })
|
||||
const page = await browser.newPage()
|
||||
await loginAsAdmin(page)
|
||||
await page.context().storageState({ path: authStatePath })
|
||||
await page.close()
|
||||
})
|
||||
|
||||
// ── Public pages (desktop + mobile) ──────────────────────────────────────
|
||||
test.describe('public pages', () => {
|
||||
for (const { name, path } of publicPages) {
|
||||
for (const [viewportName, viewport] of Object.entries(viewports)) {
|
||||
test(`${name} — ${viewportName}`, async ({ page }) => {
|
||||
await page.setViewportSize(viewport)
|
||||
await page.goto(path)
|
||||
await waitForStable(page)
|
||||
|
||||
await expect(page).toHaveScreenshot(`${name}-${viewportName}.png`, {
|
||||
maxDiffPixelRatio: 0.01,
|
||||
mask: commonMasks(page),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Authenticated pages (desktop) ─────────────────────────────────────────
|
||||
// Loads saved auth cookie — no repeated /api/dev/test-login calls.
|
||||
test.describe('authenticated pages', () => {
|
||||
test.use({ storageState: authStatePath })
|
||||
|
||||
for (const { name, path } of authenticatedPages) {
|
||||
test(`${name} — desktop`, async ({ page }) => {
|
||||
await page.setViewportSize(viewports.desktop)
|
||||
await page.goto(path)
|
||||
await waitForStable(page)
|
||||
|
||||
await expect(page).toHaveScreenshot(`${name}-desktop.png`, {
|
||||
maxDiffPixelRatio: 0.01,
|
||||
mask: commonMasks(page),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// members-detail: navigate to the test admin's own profile page.
|
||||
// The test admin is created by /api/dev/test-login (email: test-admin@ghostguild.dev,
|
||||
// status: active). We fetch their _id from /api/auth/member using the saved cookie.
|
||||
// Even if showInDirectory is false, the page renders a stable error or profile shell.
|
||||
test('members-detail — desktop', async ({ page }) => {
|
||||
await page.setViewportSize(viewports.desktop)
|
||||
const response = await page.request.get('/api/auth/member')
|
||||
// /api/auth/member returns the member object directly (not nested under a 'member' key)
|
||||
const authData = response.ok() ? await response.json() : null
|
||||
const memberId = authData?._id || authData?.id
|
||||
if (!memberId) {
|
||||
// Skip gracefully if we can't retrieve the member ID
|
||||
test.skip(true, 'Could not retrieve test admin member ID from /api/auth/member')
|
||||
return
|
||||
}
|
||||
await page.goto(`/members/${memberId}`)
|
||||
await waitForStable(page)
|
||||
await expect(page).toHaveScreenshot('members-detail-desktop.png', {
|
||||
maxDiffPixelRatio: 0.01,
|
||||
mask: commonMasks(page),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── Authenticated pages (mobile — column-collapse coverage) ───────────────
|
||||
// Loads saved auth cookie — no repeated /api/dev/test-login calls.
|
||||
test.describe('authenticated pages (mobile)', () => {
|
||||
test.use({ storageState: authStatePath })
|
||||
|
||||
for (const { name, path } of authenticatedMobilePages) {
|
||||
test(`${name} — mobile`, async ({ page }) => {
|
||||
await page.setViewportSize(viewports.mobile)
|
||||
await page.goto(path)
|
||||
await waitForStable(page)
|
||||
|
||||
await expect(page).toHaveScreenshot(`${name}-mobile-auth.png`, {
|
||||
maxDiffPixelRatio: 0.01,
|
||||
mask: commonMasks(page),
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,103 +1,222 @@
|
|||
// Spec: docs/specs/wave-based-slack-onboarding.md
|
||||
// Test plan: docs/specs/wave-based-slack-onboarding-tests.md §6 + §7
|
||||
//
|
||||
// SCAFFOLD: every test is `.skip`ed and contains a TODO. As the UI lands,
|
||||
// unskip and fill in selectors / fixtures.
|
||||
//
|
||||
// These cover the rendered behavior that unit tests can't: dashboard line
|
||||
// visibility under different member statuses, and the admin-list "Mark as
|
||||
// Slack invited" button + status display.
|
||||
|
||||
import { test, expect } from './helpers/fixtures.js'
|
||||
import { loginAsMember } from './helpers/auth.js'
|
||||
|
||||
const SLACK_NOTE_RE = /Slack workspace access is part of your membership/i
|
||||
|
||||
test.describe('Member dashboard — Slack-coming note (§7)', () => {
|
||||
test.skip('shows note for active member without Slack (7.1)', async () => {
|
||||
// TODO: seed a member { status: 'active', slackInvited: false }, sign in,
|
||||
// navigate to /member/dashboard, assert the one-liner is visible:
|
||||
// await expect(page.getByText(/within 2.3 weeks/i)).toBeVisible()
|
||||
test('shows note for active member without Slack (7.1)', async ({ browser }) => {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
await loginAsMember(page, 'riley.johnson@cooperativedev.org')
|
||||
await page.goto('/member/dashboard')
|
||||
await expect(page.getByRole('heading', { name: /Welcome.*Riley/i })).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.getByText(SLACK_NOTE_RE)).toBeVisible()
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test.skip('hides note once slackInvited:true (7.2)', async () => {
|
||||
// TODO: same as 7.1 but with slackInvited:true; assert text not present.
|
||||
// BUG: /api/auth/member does not return slackInvited, so memberData.slackInvited
|
||||
// is always undefined on the client. The dashboard condition
|
||||
// (status==="active" && !slackInvited) currently shows the note for ALL
|
||||
// active members regardless of slackInvited. Fix the API to expose the
|
||||
// field before unskipping.
|
||||
})
|
||||
|
||||
test.skip('hides note for pending_payment member (7.3)', async () => {
|
||||
// TODO: pending_payment + slackInvited:false; assert text not present.
|
||||
test('hides note for pending_payment member (7.3)', async ({ browser }) => {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
await loginAsMember(page, 'pending-payment-test@example.test')
|
||||
await page.goto('/member/dashboard')
|
||||
await expect(page.getByRole('heading', { name: /Welcome.*Pending Payment Tester/i })).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.getByText(SLACK_NOTE_RE)).toHaveCount(0)
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test.skip('hides note for suspended/cancelled/guest (7.4)', async () => {
|
||||
// TODO: parameterize across statuses { suspended, cancelled, guest }.
|
||||
// No suspended/cancelled/guest members exist in the dev DB and there is
|
||||
// no dev endpoint to seed members with arbitrary status. Implementing
|
||||
// this would require a new server-side helper (out of scope).
|
||||
})
|
||||
|
||||
test.skip('copy contains no wave/cohort/batch language (7.5)', async ({ adminPage }) => {
|
||||
await adminPage.goto('/member/dashboard')
|
||||
const html = await adminPage.content()
|
||||
expect(html).not.toMatch(/\bwave\b/i)
|
||||
expect(html).not.toMatch(/\bcohort\b/i)
|
||||
expect(html).not.toMatch(/\bbatch\b/i)
|
||||
test.skip('copy contains no wave/cohort/batch language (7.5)', async () => {
|
||||
// The shipped UI uses the phrase "monthly onboarding waves" — this test's
|
||||
// \bwave\b assertion contradicts the current copy. Resolve the spec/UI
|
||||
// divergence before unskipping.
|
||||
})
|
||||
|
||||
test.skip('renders as plain text — no banner / modal / callout styling (7.6)', async () => {
|
||||
// TODO: assert the note's container is not a UAlert / modal / heavy callout
|
||||
// (e.g. no .alert, no role="dialog" wrapper).
|
||||
test('renders as plain text — no banner / modal / callout styling (7.6)', async ({ browser }) => {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
await loginAsMember(page, 'riley.johnson@cooperativedev.org')
|
||||
await page.goto('/member/dashboard')
|
||||
await expect(page.getByRole('heading', { name: /Welcome.*Riley/i })).toBeVisible({ timeout: 15000 })
|
||||
const note = page.getByText(SLACK_NOTE_RE)
|
||||
await expect(note).toBeVisible()
|
||||
const tag = await note.evaluate((el) => el.tagName.toLowerCase())
|
||||
expect(tag).toBe('p')
|
||||
const inDialog = await note.evaluate((el) => !!el.closest('[role="dialog"]'))
|
||||
expect(inDialog).toBe(false)
|
||||
const inAlert = await note.evaluate((el) => !!el.closest('[role="alert"], .alert'))
|
||||
expect(inAlert).toBe(false)
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test.skip('SSR renders without auth — note absent (7.7)', async ({ browser }) => {
|
||||
test('SSR renders without auth — note absent (7.7)', async ({ browser }) => {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
const response = await page.goto('/member/dashboard')
|
||||
const ssrHtml = await response.text()
|
||||
expect(ssrHtml).not.toMatch(/within 2.3 weeks/i)
|
||||
expect(ssrHtml).not.toMatch(SLACK_NOTE_RE)
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test.skip('copy matches approved wording (7.8)', async () => {
|
||||
// TODO: replace with the final approved string once the Open Question is resolved.
|
||||
// Awaiting resolution of the Open Question on the final approved string.
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Admin members — Slack-invited control (§6)', () => {
|
||||
test.skip('shows "Mark as Slack invited" for slackInvited:false (6.1)', async ({ adminPage }) => {
|
||||
test('shows "Mark as Slack invited" for slackInvited:false (6.1)', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/members')
|
||||
// TODO: locate a row for a member with slackInvited:false and assert the
|
||||
// button is visible.
|
||||
// await expect(adminPage.getByRole('button', { name: /Mark as Slack invited/i })).toBeVisible()
|
||||
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
|
||||
await expect(
|
||||
adminPage.getByRole('button', { name: /Mark as Slack invited/i }).first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test.skip('replaces button with "Invited <date>" once flipped (6.2)', async () => {
|
||||
// TODO: click the button on a row; assert button is gone, date string visible.
|
||||
// BUG: in admin/members/index.vue, markSlackInvited does
|
||||
// Object.assign(member, res.member) on a plain object inside the
|
||||
// useFetch array — Vue does not pick up the per-item mutation, so the
|
||||
// row UI does not refresh until the page reloads. The same control on
|
||||
// the detail page (which reassigns member.value) does work — see 6.6.
|
||||
})
|
||||
|
||||
test.skip('click triggers single PATCH and updates row in place (6.4)', async ({ adminPage }) => {
|
||||
// TODO: spy on network for /api/admin/members/*/slack-status; click button;
|
||||
// assert single PATCH, success, no full-page reload.
|
||||
})
|
||||
test('click triggers single PATCH and updates row in place (6.4)', async ({ adminPage }) => {
|
||||
// Re-prime the auth cookie. The shared test-admin account's tokenVersion
|
||||
// is bumped whenever auth.spec.js's logout test runs in parallel, which
|
||||
// would otherwise surface mid-flow as a silent 401 on the create POST.
|
||||
const loginRes = await adminPage.context().request.get('/api/dev/test-login', { maxRedirects: 0 })
|
||||
if (loginRes.status() !== 302) {
|
||||
throw new Error(`Failed to refresh admin session: ${loginRes.status()}`)
|
||||
}
|
||||
|
||||
// Create a dedicated test member so the row we operate on is uniquely
|
||||
// identifiable by email and can't be displaced by parallel test mutations.
|
||||
// We use the admin UI flow (vs API) because the POST endpoint is
|
||||
// CSRF-protected and the modal is the documented happy path.
|
||||
const stamp = Date.now()
|
||||
const memberEmail = `e2e-slack-6-4-${stamp}@example.test`
|
||||
const memberName = `E2E Slack 6.4 ${stamp}`
|
||||
|
||||
test.skip('status labels read "Not yet invited" / "Invited" — not "Pending" (6.5)', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/members')
|
||||
// TODO:
|
||||
// await expect(adminPage.getByText(/Not yet invited/i).first()).toBeVisible()
|
||||
// const html = await adminPage.content()
|
||||
// expect(html).not.toMatch(/Slack:\s*Pending/i)
|
||||
})
|
||||
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
await adminPage.getByRole('button', { name: 'Add Member' }).click()
|
||||
await adminPage.getByPlaceholder('Full name').fill(memberName)
|
||||
await adminPage.getByPlaceholder('email@example.com').fill(memberEmail)
|
||||
await adminPage.getByRole('button', { name: 'Create Member' }).click()
|
||||
// Modal closes after successful create
|
||||
await expect(adminPage.getByPlaceholder('Full name')).toHaveCount(0, { timeout: 10000 })
|
||||
|
||||
test.skip('member detail page mirrors list controls (6.6)', async () => {
|
||||
// TODO: navigate to /admin/members/<id>; assert button + date display.
|
||||
const patchRequests = []
|
||||
await adminPage.route('**/api/admin/members/*/slack-status', async (route) => {
|
||||
const req = route.request()
|
||||
patchRequests.push({ method: req.method(), url: req.url() })
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
member: {
|
||||
slackInvited: true,
|
||||
slackInvitedAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
test.skip('no UI references slackInviteStatus (6.7)', async ({ adminPage }) => {
|
||||
// Static assertion of rendered HTML — no leftover badge labels keyed off the dropped field.
|
||||
await adminPage.goto('/admin/members')
|
||||
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
|
||||
// Wait for hydration so v-model bindings on the search input are wired up
|
||||
// and the click on the row's button reaches the Vue handler.
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
|
||||
// Filter the list down to our specific member so the row anchor is unambiguous.
|
||||
const searchInput = adminPage.getByPlaceholder('Search members...')
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 })
|
||||
await searchInput.fill(memberEmail)
|
||||
|
||||
const targetRow = adminPage.locator('tbody tr', { hasText: memberEmail })
|
||||
await expect(targetRow).toBeVisible({ timeout: 10000 })
|
||||
// Wait until the table has filtered down to only our row — confirms the
|
||||
// search v-model has been processed.
|
||||
await expect(adminPage.locator('tbody tr')).toHaveCount(1, { timeout: 10000 })
|
||||
await targetRow.getByRole('button', { name: /Mark as Slack invited/i }).click()
|
||||
await expect.poll(() => patchRequests.length, { timeout: 5000 }).toBe(1)
|
||||
|
||||
expect(patchRequests[0].method).toBe('PATCH')
|
||||
expect(patchRequests[0].url).toMatch(/\/api\/admin\/members\/[^/]+\/slack-status$/)
|
||||
|
||||
await adminPage.waitForTimeout(500)
|
||||
expect(patchRequests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('status labels read "Not yet invited" / "Invited" — not "Pending" (6.5)', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/members')
|
||||
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
|
||||
await expect(adminPage.getByText('Not yet invited').first()).toBeVisible()
|
||||
const html = await adminPage.content()
|
||||
expect(html).not.toMatch(/slackInviteStatus/)
|
||||
expect(html).not.toMatch(/Slack:\s*Pending/i)
|
||||
})
|
||||
|
||||
test.skip('UI rolls back on PATCH error — no false "Invited" badge (6.8)', async () => {
|
||||
// TODO: mock the endpoint to return 500; assert the row stays in
|
||||
// "Not yet invited" state.
|
||||
test('member detail page mirrors list controls (6.6)', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/members')
|
||||
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
|
||||
const row = adminPage.locator('tr', {
|
||||
has: adminPage.getByRole('button', { name: /Mark as Slack invited/i }),
|
||||
}).first()
|
||||
const href = await row.locator('a.member-name-link').getAttribute('href')
|
||||
expect(href).toMatch(/\/admin\/members\/[a-f0-9]+/)
|
||||
|
||||
await adminPage.goto(href)
|
||||
await expect(adminPage.getByText('Slack invite', { exact: true })).toBeVisible()
|
||||
await expect(adminPage.getByText('Not yet invited').first()).toBeVisible()
|
||||
await expect(adminPage.getByRole('button', { name: /Mark as Slack invited/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test.skip('no UI references slackInviteStatus (6.7)', async () => {
|
||||
// The deprecated slackInviteStatus field still lives on Member documents
|
||||
// and is serialized into the /api/admin/members payload (visible in the
|
||||
// SSR Nuxt state). The admin UI itself does not reference the field, but
|
||||
// a content() check against the rendered HTML matches the JSON payload.
|
||||
// Cleaning up the DB field is out of scope for this test pass.
|
||||
})
|
||||
|
||||
test('UI rolls back on PATCH error — no false "Invited" badge (6.8)', async ({ adminPage }) => {
|
||||
await adminPage.route('**/api/admin/members/*/slack-status', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ statusMessage: 'Server error' }),
|
||||
})
|
||||
})
|
||||
await adminPage.goto('/admin/members')
|
||||
await expect(adminPage.getByRole('heading', { name: 'Members' })).toBeVisible()
|
||||
|
||||
const row = adminPage.locator('tr', {
|
||||
has: adminPage.getByRole('button', { name: /Mark as Slack invited/i }),
|
||||
}).first()
|
||||
await row.getByRole('button', { name: /Mark as Slack invited/i }).click()
|
||||
|
||||
await expect(row.getByText('Not yet invited')).toBeVisible()
|
||||
await expect(row.getByText(/^Invited\s+\d/)).toHaveCount(0)
|
||||
await expect(row.getByRole('button', { name: /Mark as Slack invited/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test.skip('proposed: sortable on slackInvitedAt + filter "no Slack yet" (6.9)', async () => {
|
||||
// TODO: dependent on Open Question — wire up if implemented.
|
||||
// Dependent on Open Question — wire up if implemented.
|
||||
})
|
||||
})
|
||||
|
|
|
|||
709
package-lock.json
generated
|
|
@ -15,6 +15,7 @@
|
|||
"@nuxt/eslint": "^1.9.0",
|
||||
"@nuxt/ui": "^4.0.0",
|
||||
"@nuxtjs/plausible": "^3.0.1",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@slack/web-api": "^7.10.0",
|
||||
"chrono-node": "^2.8.4",
|
||||
"cloudinary": "^2.7.0",
|
||||
|
|
@ -28,6 +29,7 @@
|
|||
"oidc-provider": "^9.6.1",
|
||||
"rate-limiter-flexible": "^9.1.1",
|
||||
"resend": "^6.0.1",
|
||||
"satori": "^0.26.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vue": "^3.5.20",
|
||||
"vue-cal": "^5.0.1-rc.28",
|
||||
|
|
@ -42,6 +44,7 @@
|
|||
"@types/oidc-provider": "^9.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^28.1.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
},
|
||||
|
|
@ -4648,6 +4651,221 @@
|
|||
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@resvg/resvg-js": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
|
||||
"integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==",
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@resvg/resvg-js-android-arm-eabi": "2.6.2",
|
||||
"@resvg/resvg-js-android-arm64": "2.6.2",
|
||||
"@resvg/resvg-js-darwin-arm64": "2.6.2",
|
||||
"@resvg/resvg-js-darwin-x64": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm64-gnu": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm64-musl": "2.6.2",
|
||||
"@resvg/resvg-js-linux-x64-gnu": "2.6.2",
|
||||
"@resvg/resvg-js-linux-x64-musl": "2.6.2",
|
||||
"@resvg/resvg-js-win32-arm64-msvc": "2.6.2",
|
||||
"@resvg/resvg-js-win32-ia32-msvc": "2.6.2",
|
||||
"@resvg/resvg-js-win32-x64-msvc": "2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-android-arm-eabi": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz",
|
||||
"integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-android-arm64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz",
|
||||
"integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-darwin-arm64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
|
||||
"integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-darwin-x64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz",
|
||||
"integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm-gnueabihf": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz",
|
||||
"integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm64-gnu": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz",
|
||||
"integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm64-musl": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
|
||||
"integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-x64-gnu": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz",
|
||||
"integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-x64-musl": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz",
|
||||
"integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-arm64-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-ia32-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-x64-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
||||
|
|
@ -5153,6 +5371,22 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@shuding/opentype.js": {
|
||||
"version": "1.4.0-beta.0",
|
||||
"resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz",
|
||||
"integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fflate": "^0.7.3",
|
||||
"string.prototype.codepointat": "^0.2.1"
|
||||
},
|
||||
"bin": {
|
||||
"ot": "bin/ot"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/base62": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz",
|
||||
|
|
@ -7599,6 +7833,13 @@
|
|||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
|
||||
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
|
||||
|
|
@ -8373,6 +8614,25 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"get-intrinsic": "^1.3.0",
|
||||
"set-function-length": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
|
|
@ -8386,6 +8646,23 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
|
|
@ -8395,6 +8672,15 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelize": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
||||
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-api": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz",
|
||||
|
|
@ -8837,6 +9123,27 @@
|
|||
"uncrypto": "^0.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/css-background-parser": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz",
|
||||
"integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-box-shadow": {
|
||||
"version": "1.0.0-3",
|
||||
"resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz",
|
||||
"integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-color-keywords": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
|
||||
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/css-declaration-sorter": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz",
|
||||
|
|
@ -8849,6 +9156,15 @@
|
|||
"postcss": "^8.0.9"
|
||||
}
|
||||
},
|
||||
"node_modules/css-gradient-parser": {
|
||||
"version": "0.0.17",
|
||||
"resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz",
|
||||
"integrity": "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
|
|
@ -8865,6 +9181,17 @@
|
|||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-to-react-native": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
|
||||
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelize": "^1.0.0",
|
||||
"css-color-keywords": "^1.0.0",
|
||||
"postcss-value-parser": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
||||
|
|
@ -9195,6 +9522,24 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/define-lazy-prop": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
|
||||
|
|
@ -9527,6 +9872,15 @@
|
|||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex-xs": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz",
|
||||
"integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
|
|
@ -10497,6 +10851,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz",
|
||||
"integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
|
|
@ -10555,6 +10915,16 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/find-yarn-workspace-root": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
|
||||
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"micromatch": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
|
|
@ -10720,6 +11090,21 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -11007,6 +11392,19 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
|
|
@ -11046,6 +11444,18 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hex-rgb": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz",
|
||||
"integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/hey-listen": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz",
|
||||
|
|
@ -11817,12 +12227,39 @@
|
|||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.4",
|
||||
"isarray": "^2.0.5",
|
||||
"jsonify": "^0.0.1",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify/node_modules/isarray": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
|
|
@ -11835,6 +12272,29 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonify": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
|
||||
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
|
||||
"dev": true,
|
||||
"license": "Public Domain",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
|
|
@ -11908,6 +12368,16 @@
|
|||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/klaw-sync": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
|
||||
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.11"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
|
|
@ -12349,6 +12819,25 @@
|
|||
"url": "https://github.com/sponsors/antonk52"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
|
|
@ -12794,6 +13283,16 @@
|
|||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
|
|
@ -13597,6 +14096,16 @@
|
|||
"integrity": "sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
|
|
@ -13966,6 +14475,12 @@
|
|||
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
|
@ -13978,6 +14493,16 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-css-color": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz",
|
||||
"integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^1.1.4",
|
||||
"hex-rgb": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-imports-exports": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz",
|
||||
|
|
@ -14026,6 +14551,108 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
|
||||
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@yarnpkg/lockfile": "^1.1.0",
|
||||
"chalk": "^4.1.2",
|
||||
"ci-info": "^3.7.0",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"find-yarn-workspace-root": "^2.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"json-stable-stringify": "^1.0.2",
|
||||
"klaw-sync": "^6.0.0",
|
||||
"minimist": "^1.2.6",
|
||||
"open": "^7.4.2",
|
||||
"semver": "^7.5.3",
|
||||
"slash": "^2.0.0",
|
||||
"tmp": "^0.2.4",
|
||||
"yaml": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"patch-package": "index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">5"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/ci-info": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
|
||||
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/sibiraj-s"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/is-docker": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
|
||||
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/open": {
|
||||
"version": "7.4.2",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
|
||||
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^2.0.0",
|
||||
"is-wsl": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/slash": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
|
||||
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
|
|
@ -15528,6 +16155,28 @@
|
|||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/satori": {
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/satori/-/satori-0.26.0.tgz",
|
||||
"integrity": "sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"@shuding/opentype.js": "1.4.0-beta.0",
|
||||
"css-background-parser": "^0.1.0",
|
||||
"css-box-shadow": "1.0.0-3",
|
||||
"css-gradient-parser": "^0.0.17",
|
||||
"css-to-react-native": "^3.0.0",
|
||||
"emoji-regex-xs": "^2.0.1",
|
||||
"escape-html": "^1.0.3",
|
||||
"linebreak": "^1.1.0",
|
||||
"parse-css-color": "^0.2.1",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
"yoga-layout": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
|
||||
|
|
@ -15678,6 +16327,24 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"gopd": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
|
|
@ -15983,6 +16650,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string.prototype.codepointat": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz",
|
||||
"integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
|
|
@ -16396,6 +17069,16 @@
|
|||
"integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
@ -16680,6 +17363,16 @@
|
|||
"integrity": "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
|
||||
|
|
@ -16780,6 +17473,16 @@
|
|||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
|
@ -18453,6 +19156,12 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yoga-layout": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/youch": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"dev": " nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"postinstall": "patch-package && nuxt prepare",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:e2e": "npx playwright test",
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
"@nuxt/eslint": "^1.9.0",
|
||||
"@nuxt/ui": "^4.0.0",
|
||||
"@nuxtjs/plausible": "^3.0.1",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@slack/web-api": "^7.10.0",
|
||||
"chrono-node": "^2.8.4",
|
||||
"cloudinary": "^2.7.0",
|
||||
|
|
@ -41,6 +42,7 @@
|
|||
"oidc-provider": "^9.6.1",
|
||||
"rate-limiter-flexible": "^9.1.1",
|
||||
"resend": "^6.0.1",
|
||||
"satori": "^0.26.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vue": "^3.5.20",
|
||||
"vue-cal": "^5.0.1-rc.28",
|
||||
|
|
@ -58,6 +60,7 @@
|
|||
"@types/oidc-provider": "^9.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^28.1.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
patches/@shuding+opentype.js+1.4.0-beta.0.patch
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
diff --git a/node_modules/@shuding/opentype.js/dist/opentype.js b/node_modules/@shuding/opentype.js/dist/opentype.js
|
||||
index d72d1b7..28f572e 100644
|
||||
--- a/node_modules/@shuding/opentype.js/dist/opentype.js
|
||||
+++ b/node_modules/@shuding/opentype.js/dist/opentype.js
|
||||
@@ -11502,7 +11502,7 @@
|
||||
break;
|
||||
case 'ltag':
|
||||
table = uncompressTable(data, tableEntry);
|
||||
- ltagTable = ltag.parse(table.data, table.offset);
|
||||
+ var ltagTable = ltag.parse(table.data, table.offset);
|
||||
break;
|
||||
case 'maxp':
|
||||
table = uncompressTable(data, tableEntry);
|
||||
|
|
@ -6,11 +6,10 @@ const BASE_URL = `http://localhost:${PORT}`;
|
|||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
outputDir: "e2e/test-results",
|
||||
snapshotDir: "e2e/__screenshots__",
|
||||
fullyParallel: true,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
retries: process.env.CI ? 1 : 1,
|
||||
workers: process.env.CI ? 1 : 4,
|
||||
reporter: "html",
|
||||
timeout: 60000,
|
||||
use: {
|
||||
|
|
@ -27,7 +26,7 @@ export default defineConfig({
|
|||
webServer: {
|
||||
command: `PORT=${PORT} npm run build && PORT=${PORT} NODE_ENV=development npm run preview`,
|
||||
url: BASE_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
reuseExistingServer: true,
|
||||
env: {
|
||||
NUXT_PUBLIC_COMING_SOON: "false",
|
||||
NODE_ENV: "development",
|
||||
|
|
|
|||
BIN
public/og/default.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
72
scripts/create-admin-and-invite.cjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
require('dotenv').config()
|
||||
const mongoose = require('mongoose')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { randomUUID } = require('crypto')
|
||||
|
||||
const EMAIL = process.argv[2]
|
||||
const NAME = process.argv[3]
|
||||
|
||||
if (!EMAIL || !NAME) {
|
||||
console.error('Usage: node scripts/create-admin-and-invite.cjs <email> <name>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const secret = process.env.NUXT_JWT_SECRET || process.env.JWT_SECRET
|
||||
if (!secret) {
|
||||
console.error('Missing NUXT_JWT_SECRET / JWT_SECRET in .env')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const baseUrl = (process.env.BASE_URL || '').replace(/\/$/, '')
|
||||
if (!baseUrl) {
|
||||
console.error('Missing BASE_URL in .env (e.g. https://ghostguild.org)')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!process.env.MONGODB_URI) {
|
||||
console.error('Missing MONGODB_URI in .env')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
await mongoose.connect(process.env.MONGODB_URI)
|
||||
const members = mongoose.connection.db.collection('members')
|
||||
|
||||
const email = EMAIL.toLowerCase()
|
||||
const jti = randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
const res = await members.findOneAndUpdate(
|
||||
{ email },
|
||||
{
|
||||
$setOnInsert: {
|
||||
email,
|
||||
name: NAME,
|
||||
circle: 'founder',
|
||||
contributionAmount: 0,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
},
|
||||
$set: {
|
||||
magicLinkJti: jti,
|
||||
magicLinkJtiUsed: false,
|
||||
},
|
||||
},
|
||||
{ upsert: true, returnDocument: 'after' },
|
||||
)
|
||||
|
||||
const member = res.value || (await members.findOne({ email }))
|
||||
|
||||
const token = jwt.sign({ memberId: member._id, jti }, secret, { expiresIn: '15m' })
|
||||
const link = `${baseUrl}/verify#${token}`
|
||||
|
||||
console.log('\nAdmin:', member.email, '(role:', member.role + ', status:', member.status + ')')
|
||||
console.log('\nMagic link (expires in 15 min):\n')
|
||||
console.log(link, '\n')
|
||||
|
||||
await mongoose.disconnect()
|
||||
})().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -32,7 +32,7 @@ const sampleEvents = [
|
|||
content: 'This informal meetup is perfect for connecting with other developers interested in cooperative business models. We\'ll have brief presentations, open discussions, and time for networking.\n\nAgenda:\n- Welcome & introductions\n- Member spotlight presentations\n- Open discussion on cooperative challenges and successes\n- Networking and social time',
|
||||
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7, 19, 0),
|
||||
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7, 21, 0),
|
||||
eventType: 'community',
|
||||
eventType: 'community-meetup',
|
||||
location: '#general',
|
||||
isOnline: true,
|
||||
membersOnly: false,
|
||||
|
|
@ -107,7 +107,7 @@ const sampleEvents = [
|
|||
content: 'Our quarterly showcase featuring presentations from Ghost Guild member studios. Learn about ongoing projects, cooperative development processes, and the unique challenges and benefits of collaborative game creation.\n\nFeatured presentations:\n- "Collaborative Level Design in Practice"\n- "Democratic Decision Making in Creative Projects"\n- "Balancing Individual Creativity with Group Consensus"\n- Q&A with presenting studios',
|
||||
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 21, 18, 30),
|
||||
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 21, 21, 0),
|
||||
eventType: 'showcase',
|
||||
eventType: 'skills-share',
|
||||
location: '#showcase',
|
||||
isOnline: true,
|
||||
membersOnly: true,
|
||||
|
|
@ -134,7 +134,7 @@ const sampleEvents = [
|
|||
content: 'Join us for a casual evening of celebration, networking, and community building. Perfect for new members to meet the community and for existing members to catch up.\n\nActivities:\n- Welcome reception\n- Casual networking\n- Community achievements celebration\n- Light refreshments provided\n- Optional lightning talks (5 min, informal)',
|
||||
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 3, 18, 0),
|
||||
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 3, 21, 0),
|
||||
eventType: 'social',
|
||||
eventType: 'community-meetup',
|
||||
location: '#social',
|
||||
isOnline: true,
|
||||
membersOnly: true,
|
||||
|
|
@ -234,7 +234,7 @@ const sampleEvents = [
|
|||
content: 'Our February meetup has been cancelled but will be rescheduled soon. Stay tuned for updates!',
|
||||
startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 42, 19, 0),
|
||||
endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 42, 21, 0),
|
||||
eventType: 'community',
|
||||
eventType: 'community-meetup',
|
||||
location: '#general',
|
||||
isOnline: true,
|
||||
membersOnly: false,
|
||||
|
|
|
|||
|
|
@ -274,6 +274,18 @@ const sampleMembers = [
|
|||
createdAt: new Date('2025-06-01'),
|
||||
lastLogin: new Date('2026-04-04'),
|
||||
},
|
||||
{
|
||||
email: 'pending-payment-test@example.test',
|
||||
name: 'Pending Payment Tester',
|
||||
circle: 'community',
|
||||
contributionAmount: 5,
|
||||
status: 'pending_payment',
|
||||
slackInvited: false,
|
||||
craftTags: [],
|
||||
board: {},
|
||||
createdAt: new Date('2026-04-25'),
|
||||
lastLogin: new Date('2026-04-29'),
|
||||
},
|
||||
]
|
||||
|
||||
const TEST_ADMIN_BOARD = {
|
||||
|
|
|
|||
72
scripts/seed-pre-registrants.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import mongoose from 'mongoose'
|
||||
import PreRegistration from '../server/models/preRegistration.js'
|
||||
import { connectDB } from '../server/utils/mongoose.js'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
// 30 mock pre-registrants with realistic game dev / co-op roles and cities
|
||||
const samplePreRegistrants = [
|
||||
{ email: 'lina.okoro@gmail.com', name: 'Lina Okoro', city: 'Lagos, Nigeria', role: 'Game designer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-02') },
|
||||
{ email: 'marco.bianchi@proton.me', name: 'Marco Bianchi', city: 'Milan, Italy', role: 'Narrative designer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-05') },
|
||||
{ email: 'priya.nair@outlook.com', name: 'Priya Nair', city: 'Bangalore, India', role: 'Unity developer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-11-08') },
|
||||
{ email: 'elke.hoffmann@posteo.de', name: 'Elke Hoffmann', city: 'Berlin, Germany', role: 'Producer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-12') },
|
||||
{ email: 'tomoko.sato@icloud.com', name: 'Tomoko Sato', city: 'Tokyo, Japan', role: 'Pixel artist', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-15') },
|
||||
{ email: 'jamie.callahan@fastmail.com', name: 'Jamie Callahan', city: 'Vancouver, BC', role: 'Co-op founder', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-18') },
|
||||
{ email: 'yusuf.demir@gmail.com', name: 'Yusuf Demir', city: 'Istanbul, Turkey', role: 'Sound designer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-11-20') },
|
||||
{ email: 'saoirse.murphy@proton.me', name: 'Saoirse Murphy', city: 'Dublin, Ireland', role: 'QA lead', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-22') },
|
||||
{ email: 'ren.watanabe@gmail.com', name: 'Ren Watanabe', city: 'Osaka, Japan', role: 'Godot developer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-11-25') },
|
||||
{ email: 'astrid.lindgren@tuta.io', name: 'Astrid Lindgren', city: 'Stockholm, Sweden', role: '3D artist', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-12-01') },
|
||||
{ email: 'carlos.reyes@gmail.com', name: 'Carlos Reyes', city: 'Mexico City, Mexico', role: 'Programmer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-04') },
|
||||
{ email: 'noor.hassan@outlook.com', name: 'Noor Hassan', city: 'Amman, Jordan', role: 'UX researcher', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-07') },
|
||||
{ email: 'freya.johansson@pm.me', name: 'Freya Johansson', city: 'Copenhagen, Denmark', role: 'Studio co-founder', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-10') },
|
||||
{ email: 'kwame.asante@gmail.com', name: 'Kwame Asante', city: 'Accra, Ghana', role: 'Game developer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-12-13') },
|
||||
{ email: 'mila.petrov@proton.me', name: 'Mila Petrov', city: 'Belgrade, Serbia', role: 'Animator', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-16') },
|
||||
{ email: 'odin.haugen@fastmail.com', name: 'Odin Haugen', city: 'Oslo, Norway', role: 'Cooperative advisor', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-19') },
|
||||
{ email: 'chen.wei@icloud.com', name: 'Chen Wei', city: 'Taipei, Taiwan', role: 'Indie developer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2025-12-22') },
|
||||
{ email: 'lucia.romano@gmail.com', name: 'Lucia Romano', city: 'Buenos Aires, Argentina', role: 'Level designer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2025-12-28') },
|
||||
{ email: 'imani.williams@proton.me', name: 'Imani Williams', city: 'Toronto, ON', role: 'Community manager', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-03') },
|
||||
{ email: 'felix.dubois@pm.me', name: 'Felix Dubois', city: 'Montreal, QC', role: 'Technical artist', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-06') },
|
||||
{ email: 'anika.schuster@posteo.de', name: 'Anika Schuster', city: 'Vienna, Austria', role: 'Writer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-10') },
|
||||
{ email: 'rohan.kapoor@gmail.com', name: 'Rohan Kapoor', city: 'Mumbai, India', role: 'Studio founder', status: 'pending', newsletterOptIn: false, createdAt: new Date('2026-01-14') },
|
||||
{ email: 'emeka.obi@outlook.com', name: 'Emeka Obi', city: 'Nairobi, Kenya', role: 'Mobile game dev', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-18') },
|
||||
{ email: 'sofie.bakker@tuta.io', name: 'Sofie Bakker', city: 'Amsterdam, Netherlands', role: 'Cooperative organizer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-22') },
|
||||
{ email: 'mateo.silva@gmail.com', name: 'Mateo Silva', city: 'Bogota, Colombia', role: 'Concept artist', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-01-26') },
|
||||
{ email: 'hana.kim@proton.me', name: 'Hana Kim', city: 'Seoul, South Korea', role: 'Unreal developer', status: 'pending', newsletterOptIn: false, createdAt: new Date('2026-02-01') },
|
||||
{ email: 'zara.thompson@fastmail.com', name: 'Zara Thompson', city: 'London, UK', role: 'Producer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-02-05') },
|
||||
{ email: 'leo.moreau@pm.me', name: 'Leo Moreau', city: 'Lyon, France', role: 'Gameplay programmer', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-02-10') },
|
||||
{ email: 'cleo.nguyen@gmail.com', name: 'Cleo Nguyen', city: 'Ho Chi Minh City, Vietnam', role: 'Environment artist', status: 'pending', newsletterOptIn: true, createdAt: new Date('2026-02-15') },
|
||||
{ email: 'kai.eriksson@icloud.com', name: 'Kai Eriksson', city: 'Helsinki, Finland', role: 'Cooperative consultant', status: 'pending', newsletterOptIn: false, createdAt: new Date('2026-02-20') },
|
||||
]
|
||||
|
||||
async function seedPreRegistrants() {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
await PreRegistration.deleteMany({})
|
||||
console.log('Cleared existing pre-registrants')
|
||||
|
||||
await PreRegistration.insertMany(samplePreRegistrants)
|
||||
console.log(`Added ${samplePreRegistrants.length} sample pre-registrants`)
|
||||
|
||||
const count = await PreRegistration.countDocuments()
|
||||
console.log(`Total pre-registrants in database: ${count}`)
|
||||
|
||||
const statusBreakdown = await PreRegistration.aggregate([
|
||||
{ $group: { _id: '$status', count: { $sum: 1 } } },
|
||||
{ $sort: { _id: 1 } }
|
||||
])
|
||||
|
||||
console.log('\nBreakdown by status:')
|
||||
statusBreakdown.forEach(s => {
|
||||
console.log(` ${s._id}: ${s.count}`)
|
||||
})
|
||||
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
console.error('Error seeding pre-registrants:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
seedPreRegistrants()
|
||||
|
|
@ -149,7 +149,7 @@ async function seedSeriesEvents() {
|
|||
"Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.",
|
||||
startDate: new Date("2024-10-12T18:00:00.000Z"),
|
||||
endDate: new Date("2024-10-12T20:00:00.000Z"),
|
||||
eventType: "community",
|
||||
eventType: "community-meetup",
|
||||
location: "#community-meetup",
|
||||
isOnline: true,
|
||||
membersOnly: false,
|
||||
|
|
@ -176,7 +176,7 @@ async function seedSeriesEvents() {
|
|||
"Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.",
|
||||
startDate: new Date("2024-11-09T18:00:00.000Z"),
|
||||
endDate: new Date("2024-11-09T20:00:00.000Z"),
|
||||
eventType: "community",
|
||||
eventType: "community-meetup",
|
||||
location: "#community-meetup",
|
||||
isOnline: true,
|
||||
membersOnly: false,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ export default defineEventHandler(async (event) => {
|
|||
let channelName = body.name
|
||||
|
||||
if (!slackChannelId) {
|
||||
if (process.env.ALLOW_DEV_TEST_ENDPOINTS === 'true') {
|
||||
// Match the Slack channel ID format (^[A-Z0-9]+$) so the value
|
||||
// round-trips through boardChannelUpdateSchema on subsequent edits.
|
||||
slackChannelId = `CDEV${Date.now().toString(36).toUpperCase()}`
|
||||
console.log('[slack] DEV MODE — skipping createChannel', { name: body.name, slackChannelId })
|
||||
} else {
|
||||
const slack = getSlackAdminService()
|
||||
if (!slack) {
|
||||
throw createError({
|
||||
|
|
@ -42,6 +48,7 @@ export default defineEventHandler(async (event) => {
|
|||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const channel = await BoardChannel.create({
|
||||
|
|
|
|||