Skip to content
This repository was archived by the owner on Oct 24, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/data/decks/hajnalig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type {QuestionSet} from '~components/Play'

import hajnaligQuestions from '~pages/events/hajnalig/_data/hajnalig-questions.json'
import type {Deck} from './types'

/**
* The hajnalig event deck. Its questions are NOT a subset of the global pool —
* the event has its own curated set with hu/en text that differs from the
* library — so the deck carries them inline.
*
* Language order matters: the first entry ('hu') is the default.
*/
export const hajnaligDeck: Deck = {
slug: 'hajnalig',
title: 'Hajnalig',
description:
'WhoCards help us create conversations that encourage honest self-expression, active listening and deeper human connections.',
source: {kind: 'inline', questions: hajnaligQuestions as QuestionSet},
languages: ['hu', 'en'],
languageStorageKey: 'hajnalig-language',
userIdStorageKey: 'hajnalig-user-id',
tracking: {eventId: 1, endpoint: '/api/events/question-tracking'},
}
55 changes: 55 additions & 0 deletions src/data/decks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type {QuestionSet} from '~components/Play'
import globalQuestions from '~data/questions.json'

import {hajnaligDeck} from './hajnalig'
import {libraryDeck} from './library'
import type {Deck, ResolvedDeck} from './types'

export type {Deck, DeckPresentation, DeckSource, ResolvedDeck} from './types'

/** The slug of the default deck served at `/play` (no path segment). */
export const DEFAULT_DECK_SLUG = libraryDeck.slug

/**
* All registered decks, keyed by slug. Literal keys (not computed from
* `deck.slug`) so `DeckSlug` is a string-literal union and `getDeck` can narrow.
*/
const decks = {
library: libraryDeck,
hajnalig: hajnaligDeck,
} satisfies Record<string, Deck>

export type DeckSlug = keyof typeof decks

/** Resolve a deck's authored source into a concrete, ordered QuestionSet. */
const resolveQuestions = (deck: Deck): {questions: QuestionSet; questionIds: string[]} => {
if (deck.source.kind === 'inline') {
const questions = deck.source.questions
return {questions, questionIds: Object.keys(questions)}
}

// library source: reference ids into the global pool (no text duplication).
const pool = globalQuestions as QuestionSet
const ids = deck.source.ids ?? Object.keys(pool)
const questions: QuestionSet = {}
for (const id of ids) {
if (pool[id]) questions[id] = pool[id]
}
return {questions, questionIds: Object.keys(questions)}
}

const resolve = (deck: Deck): ResolvedDeck => ({...deck, ...resolveQuestions(deck)})

/** Is `slug` a registered deck? */
export const isDeckSlug = (slug: string): slug is DeckSlug => slug in decks

/**
* Resolve a deck by slug into a {@link ResolvedDeck} (metadata + presentation +
* a concrete {@link QuestionSet}). Returns `undefined` for an unknown slug so
* pages can 404 / fall back as they see fit.
*/
export const getDeck = (slug: string): ResolvedDeck | undefined =>
isDeckSlug(slug) ? resolve(decks[slug]) : undefined

/** Every registered deck, resolved (useful for static path generation). */
export const getAllDecks = (): ResolvedDeck[] => Object.values(decks).map(resolve)
23 changes: 23 additions & 0 deletions src/data/decks/library.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {DEFAULT_LANGUAGE, LANG_KEYS} from '~utils'

import type {Deck} from './types'

// default language first, the rest available in the in-screen language control.
const libraryLanguages = [DEFAULT_LANGUAGE, ...LANG_KEYS.filter((l) => l !== DEFAULT_LANGUAGE)]

/**
* The full WhoCards library — every question in the global pool
* (`src/data/questions.json`). This is the default `/play` experience, so it
* keeps the legacy hero background and white question text.
*/
export const libraryDeck: Deck = {
slug: 'library',
title: 'WhoCards',
description:
'WhoCards help us create conversations that encourage honest self-expression, active listening and deeper human connections.',
source: {kind: 'library'}, // no ids => all questions, in pool order
languages: libraryLanguages,
questionClassName: 'text-white',
oldBg: true,
languageStorageKey: 'who-language',
}
69 changes: 69 additions & 0 deletions src/data/decks/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type {QuestionSet, TrackingConfig} from '~components/Play'

/**
* Presentation + config hints that a deck hands straight to <Play> / Layout.
* These mirror the props those components already accept, so wiring a deck up
* is a matter of spreading these through.
*/
export type DeckPresentation = {
/** ordered list of available language codes; first entry is the default */
languages: string[]
/** text-colour utility for the question (adapts to the deck background) */
questionClassName?: string
/** render on the legacy hero background (the /play look) */
oldBg?: boolean
/** localStorage key used to persist the chosen language */
languageStorageKey?: string
/** localStorage key used to persist an anonymous user id (tracking only) */
userIdStorageKey?: string
/** optional analytics / db tracking. When omitted, no tracking happens. */
tracking?: TrackingConfig
}

/**
* A deck source describes WHERE a deck's questions come from. A deck either:
* - references ids in the global pool (`src/data/questions.json`) — no text is
* duplicated; this is how the library and curated sub-decks work; or
* - carries its own inline `QuestionSet` — for decks (e.g. events) whose
* questions don't all live in the global pool.
*/
export type DeckSource =
| {
kind: 'library'
/**
* Ordered ids into the global pool. When omitted, the deck is "all
* questions" (the full library), in pool order.
*/
ids?: string[]
}
| {
kind: 'inline'
/** the deck's own question id -> { lang: text } map */
questions: QuestionSet
}

/**
* The authored shape of a deck (what lives in the registry). The resolver turns
* this into a {@link ResolvedDeck} with a concrete {@link QuestionSet}.
*/
export type Deck = DeckPresentation & {
/** url-safe identifier; also the `/play/[deck]` path segment */
slug: string
/** human title (used for OG / share card and page metadata) */
title: string
/** short description (OG / share card) */
description: string
/** where this deck's questions come from */
source: DeckSource
}

/**
* A deck whose questions have been resolved into a concrete {@link QuestionSet}.
* This is what pages consume.
*/
export type ResolvedDeck = Deck & {
/** the deck's questions, resolved to id -> { lang: text } */
questions: QuestionSet
/** ordered question ids (the keys of `questions`, in deck order) */
questionIds: string[]
}
19 changes: 11 additions & 8 deletions src/pages/events/hajnalig/_components/play.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import {Play, type QuestionSet} from '~components/Play'
import questions from '../_data/hajnalig-questions.json'
import {getDeck} from '~data/decks'

// hajnalig deck: language order matters — first entry is the default ('hu')
const hajnaligLanguages = ['hu', 'en']
// the hajnalig event is now expressed as a deck; resolve it through the registry
// instead of importing the questions json directly. Resolution is pure data, so
// it is safe at module scope in this client island.
const deck = getDeck('hajnalig')!

export const SimplePlay = () => (
<Play
questions={questions as QuestionSet}
languages={hajnaligLanguages}
languageStorageKey='hajnalig-language'
userIdStorageKey='hajnalig-user-id'
tracking={{eventId: 1, endpoint: '/api/events/question-tracking'}}
questions={deck.questions as QuestionSet}
languages={deck.languages}
languageStorageKey={deck.languageStorageKey}
userIdStorageKey={deck.userIdStorageKey}
tracking={deck.tracking}
questionClassName={deck.questionClassName}
/>
)

Expand Down
27 changes: 15 additions & 12 deletions src/pages/play.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,34 @@
export const prerender = false

import {Play, type QuestionSet} from '~components/Play'
import questions from '~data/questions.json'
import {DEFAULT_LANGUAGE, LANG_KEYS} from '~utils'
import {DEFAULT_DECK_SLUG, getDeck} from '~data/decks'
import {LANG_KEYS} from '~utils'
import Layout from '~layouts/Layout.astro'

// default language first, the rest available in the in-screen language control.
// the <Play> component reads ?lang= from the url to pick the actual starting language.
const languages = [DEFAULT_LANGUAGE, ...LANG_KEYS.filter((l) => l !== DEFAULT_LANGUAGE)]
// the default /play screen is the full library deck. Everything (questions,
// languages, presentation) now resolves through the deck registry rather than
// importing questions.json directly. getDeck(DEFAULT_DECK_SLUG) is always defined.
const deck = getDeck(DEFAULT_DECK_SLUG)!

// SSR (prerender=false) so social crawlers — which don't run JS and never see the
// client-side ?q=/?lang= — still get the per-question metadata. Resolve lang/id from
// the query params and hand them to <Head> (via Layout), which then derives the
// title, og:locale, and og:image (/og/{lang}/{id}.png). With no valid question it
// falls back to the generic title + social image.
const langParam = Astro.url.searchParams.get('lang')
const lang = langParam && LANG_KEYS.includes(langParam) ? langParam : DEFAULT_LANGUAGE
const lang = langParam && LANG_KEYS.includes(langParam) ? langParam : deck.languages[0]
const qParam = Astro.url.searchParams.get('q')
const questionId = qParam && qParam in questions ? qParam : undefined
const questionId = qParam && qParam in deck.questions ? qParam : undefined
---

<Layout lang={lang} oldBg noFooter id={questionId}>
<Layout lang={lang} oldBg={deck.oldBg} noFooter id={questionId}>
<Play
client:only='react'
questions={questions as QuestionSet}
languages={languages}
languageStorageKey='who-language'
questionClassName='text-white'
questions={deck.questions as QuestionSet}
languages={deck.languages}
tracking={deck.tracking}
languageStorageKey={deck.languageStorageKey}
userIdStorageKey={deck.userIdStorageKey}
questionClassName={deck.questionClassName}
/>
</Layout>
48 changes: 48 additions & 0 deletions src/pages/play/[deck].astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
export const prerender = false

import {Play, type QuestionSet} from '~components/Play'
import {DEFAULT_DECK_SLUG, getDeck} from '~data/decks'
import {LANG_KEYS} from '~utils'
import Layout from '~layouts/Layout.astro'

// `/play/[deck]` — a deck served at its own stable, SEO-friendly path. SSR
// (prerender=false) so crawlers still get per-question OG without running JS.
const slug = Astro.params.deck!

// the library deck lives at /play (back-compat); don't also serve it here.
if (slug === DEFAULT_DECK_SLUG) {
return Astro.redirect(`/play${Astro.url.search}`, 301)
}

const deck = getDeck(slug)
if (!deck) {
return Astro.redirect('/play', 302)
}

// resolve lang/id from the query params (same mechanism as /play) so the per-
// question OG card resolves. lang defaults to the deck's first language.
const langParam = Astro.url.searchParams.get('lang')
const lang = langParam && LANG_KEYS.includes(langParam) ? langParam : deck.languages[0]
const qParam = Astro.url.searchParams.get('q')
const questionId = qParam && qParam in deck.questions ? qParam : undefined
---

<Layout
lang={lang}
oldBg={deck.oldBg}
noFooter
id={questionId}
title={deck.title}
description={deck.description}
>
<Play
client:only='react'
questions={deck.questions as QuestionSet}
languages={deck.languages}
tracking={deck.tracking}
languageStorageKey={deck.languageStorageKey}
userIdStorageKey={deck.userIdStorageKey}
questionClassName={deck.questionClassName}
/>
</Layout>
Loading