diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md index 36a12e8..0f427c8 100644 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -1,3 +1,7 @@ +--- +model: claude-haiku-4-5-20251001 +--- + # Commit Create a git commit following conventional commit syntax and best practices. @@ -41,8 +45,7 @@ Create a git commit following conventional commit syntax and best practices. - `` is optional but encouraged — use the affected module, file, or area (e.g. `api`, `auth`, `build`) - `` is lowercase, imperative mood, no period at end (e.g. `add retry logic`, not `Added retry logic.`) - Keep the subject line under 72 characters -- Body lines wrap at 72 characters -- Use the body to explain *why*, not *what* — the diff shows what +- **Default to subject-line only — no body.** Only add a body for a genuinely important or non-obvious commit (e.g. a breaking change, a non-obvious workaround, a fix whose cause isn't visible in the diff), and keep it to one short line, not a multi-paragraph explanation. The diff already shows *what* changed — don't restate it. - Breaking changes: add `!` after type/scope (e.g. `feat(api)!:`) AND a `BREAKING CHANGE:` footer **Examples:** diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..156baac --- /dev/null +++ b/.env.production @@ -0,0 +1,7 @@ +NPM_FLAGS=--no-optional + +WP_REST_URL=https://bubec-wordpress-beta.jakubferenc.cz +WP_GRAPHQL_URL=https://bubec-wordpress-beta.jakubferenc.cz/wordpress/graphql + +SENTRY_DSN=https://5c970e152985be7b91255ee405917532@o275554.ingest.us.sentry.io/4511484951658496 +SENTRY_ENVIRONMENT=production diff --git a/.env.staging b/.env.staging new file mode 100644 index 0000000..f2de8ca --- /dev/null +++ b/.env.staging @@ -0,0 +1,7 @@ +NPM_FLAGS=--no-optional + +WP_REST_URL=https://bubec-wordpress-beta.jakubferenc.cz +WP_GRAPHQL_URL=https://bubec-wordpress-beta.jakubferenc.cz/wordpress/graphql + +SENTRY_DSN=https://5c970e152985be7b91255ee405917532@o275554.ingest.us.sentry.io/4511484951658496 +SENTRY_ENVIRONMENT=staging diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..08db292 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,188 @@ +name: Deploy to beta + +on: + push: + branches: [feature/redesign-2026] + workflow_dispatch: {} + +concurrency: + group: deploy-beta + cancel-in-progress: true + +env: + DEPLOY_PATH: /www/hosting/jakubferenc.cz/bubec/nuxt + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Enable corepack + run: corepack enable + + - name: Install dependencies + run: yarn install --immutable + + - name: Use production env file + run: cp .env.production .env + + - name: Build + run: yarn build:ci + + - name: Upload build output + uses: actions/upload-artifact@v4 + with: + name: build-output + path: .output/ + retention-days: 1 + include-hidden-files: true + + test-unit: + needs: build + if: vars.SKIP_TESTS != '1' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Enable corepack + run: corepack enable + + - name: Install dependencies + run: yarn install --immutable + + - name: Prepare Nuxt types + run: yarn nuxt prepare + + - name: Unit tests + run: yarn test:unit + + test-component: + needs: build + if: vars.SKIP_TESTS != '1' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Enable corepack + run: corepack enable + + - name: Install dependencies + run: yarn install --immutable + + - name: Prepare Nuxt types + run: yarn nuxt prepare + + - name: Component tests + run: yarn test:component + + test-e2e: + needs: build + if: vars.SKIP_TESTS != '1' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Enable corepack + run: corepack enable + + - name: Install dependencies + run: yarn install --immutable + + - name: Prepare Nuxt types + run: yarn nuxt prepare + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Download build output + uses: actions/download-artifact@v4 + with: + name: build-output + path: .output/ + + - name: E2E tests + run: yarn test:e2e + + deploy: + needs: [test-unit, test-component, test-e2e] + if: | + always() && + ( + vars.SKIP_TESTS == '1' || + ( + needs.test-unit.result == 'success' && + needs.test-component.result == 'success' && + needs.test-e2e.result == 'success' + ) + ) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download build output + uses: actions/download-artifact@v4 + with: + name: build-output + path: .output/ + + - name: Install sshpass + run: sudo apt-get update && sudo apt-get install -y sshpass + + - name: Ensure deploy path exists + env: + SSHPASS: ${{ secrets.DEPLOY_SSH_PASSWORD }} + run: | + sshpass -e ssh -o StrictHostKeyChecking=no \ + "${{ secrets.DEPLOY_SSH_USER }}@${{ secrets.DEPLOY_SSH_HOST }}" \ + "mkdir -p ${{ env.DEPLOY_PATH }}" + + - name: Rsync build output + env: + SSHPASS: ${{ secrets.DEPLOY_SSH_PASSWORD }} + run: | + sshpass -e rsync -avr --delete \ + -e "ssh -o StrictHostKeyChecking=no" \ + .output/ "${{ secrets.DEPLOY_SSH_USER }}@${{ secrets.DEPLOY_SSH_HOST }}:${{ env.DEPLOY_PATH }}/.output/" + + - name: Rsync deploy config + env: + SSHPASS: ${{ secrets.DEPLOY_SSH_PASSWORD }} + run: | + sshpass -e rsync -av \ + -e "ssh -o StrictHostKeyChecking=no" \ + Dockerfile.deploy docker-compose.deploy.yml \ + "${{ secrets.DEPLOY_SSH_USER }}@${{ secrets.DEPLOY_SSH_HOST }}:${{ env.DEPLOY_PATH }}/" + + - name: Restart container on server + env: + SSHPASS: ${{ secrets.DEPLOY_SSH_PASSWORD }} + run: | + sshpass -e ssh -o StrictHostKeyChecking=no \ + "${{ secrets.DEPLOY_SSH_USER }}@${{ secrets.DEPLOY_SSH_HOST }}" \ + "cd ${{ env.DEPLOY_PATH }} && docker compose -f docker-compose.deploy.yml up -d --build" diff --git a/.gitignore b/.gitignore index 0f66a18..aa6ceba 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ yarn-error.log* .yarn/build-state.yml .yarn/install-state.gz +# Design +resources/design + # Runtime data pids *.pid @@ -107,3 +110,10 @@ sw.* # Resources +resources +.output +test-results +playwright-report +.nuxtrc + +# graphql +*.generated.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..73916d9 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + // Recommended when opening this workspace. ESLint is required for the + // fix-on-save setup above; Volar powers Vue/TS in .vue files. + "recommendations": [ + "dbaeumer.vscode-eslint", + "Vue.volar" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e2040a6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + // ESLint owns formatting in this project (@nuxt/eslint-config `stylistic`), so we + // don't use a separate formatter/Prettier. Instead of "format on save" we + // run ESLint's autofix on save — this applies stylistic formatting AND any + // other auto-fixable rules in a single pass. + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + + // Don't run a second formatter on top of ESLint (avoids fighting rules). + "editor.formatOnSave": false, + + // ESLint flat config lives at ./eslint.config.mjs (default in recent + // versions of the extension, set explicitly to be safe). + "eslint.useFlatConfig": true, + + // Lint/fix these languages, including Vue SFCs. + "eslint.validate": [ + "javascript", + "typescript", + "vue" + ] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dab7257 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,66 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Working agreements + +- **Never `git commit` automatically.** Only commit when the user explicitly tells you to in that moment (e.g. "commit this", or invoking `/commit`). Completing a task is not implicit permission, and a prior commit request does not authorize future ones. Make and stage edits, then stop and wait for explicit authorization. +- **Keep commit messages minimal.** Default to just the `(): ` line — no body. Only add a short, one-line body for genuinely important or non-obvious commits; never write a multi-paragraph explanation. +- **Package manager is Yarn 4** (pinned via `packageManager`); use `yarn`, not npm/pnpm. +- **Prefer `@/` for source-root imports** (e.g. `@/stores/main`, `@/utils/factories`, `@/components/...`). `@` is pinned to the source root in `nuxt.config.ts` (`alias`) and resolves via the generated TS paths. Note there is no `src/` folder — `srcDir` is `.`, so `@` (and the still-valid `~`) map to the repo root. +- **Colocate `.graphql` files with their primary consumers.** Place `.graphql` files in the same directory as the component or page that primarily consumes them, using the same base name (e.g. `components/Footer.vue` + `components/Footer.graphql`, or `pages/aktuality/[slug].vue` + `pages/aktuality/[slug].graphql`). Use the **Czech (canonical) slug** for the directory. After adding/editing a `.graphql` operation, run `yarn codegen` to regenerate types. +- **Do not use Gutenberg blocks.** This project does not author content as Gutenberg blocks (no `` block markup in `post_content` or ACF wysiwyg fields). Write content as plain HTML with the final Tailwind classes applied directly. Page structure is expressed through the ACF flexible-content **sections** model (`pages/[slug].vue`), and rich text as plain HTML — not the block editor. When reproducing a block-based layout (e.g. the old `render_block`-filtered "O nás" look), copy its **rendered** HTML/classes, not the block source. +- **Colocate component tests with the component (or page) they test.** Place `*.spec.{js,ts}` files in the same directory as the `.vue` file they mount, using the same base name (e.g. `components/Footer.vue` + `components/Footer.spec.ts`, or `pages/newsletter.vue` + `pages/newsletter.spec.ts`). There is no more `tests/component/` — `vitest.config.ts`'s `include` globs `components/**/*.spec.{js,ts}` and `pages/**/*.spec.{js,ts}` directly, matching the existing `utils/**/*.spec.ts` colocation. Pure, no-single-file-subject tests still live in `tests/unit/`. + +## What this is + +Server-side-rendered (SSR) marketing/events site for Studio Bubec. **Nuxt 4.4 / Vue 3 / Pinia**, run as a long-lived Node server (Nitro `node-server` preset). Content is pulled from a **headless WordPress WPGraphQL API** (`apiGraphqlUrl`, default `https://bubec-wordpress.assemblage.cz/graphql`, overridable via `WP_GRAPHQL_URL`) **on demand** at request time; performance and WP-API load are handled by Nitro route caching (`routeRules` stale-while-revalidate), not a static build. Deployed as a Docker container running `node .output/server/index.mjs`. The WordPress backend lives in the separate **`bubec-wordpress`** repo (see its own `CLAUDE.md`). + +> Migrated from Nuxt 2.18 / Vue 2 / Vuex (static `nuxt generate`), and subsequently from the WordPress **REST** API to **WPGraphQL**. There is no `nuxt generate`, no build-time route enumeration, no `dist/` output, and no more axios/REST factories. + +## Commands + +Package manager is **Yarn 4** (pinned via `packageManager`); use `yarn`, not npm/pnpm. Node 24. + +- `yarn dev` — dev server on :3000 (or `docker-compose up --build` for the Docker env) +- `yarn dev:mock` — dev server backed by local `mock/*.json` instead of the live API (`MOCK_DATA=1`) +- `yarn build` — compile the SSR server output into `.output/` +- `yarn start` — run the built server (`node .output/server/index.mjs`) on :3000 +- `yarn codegen` — regenerate `models/graphql.generated.ts` from the `.graphql` operations against the live WPGraphQL schema (reads `.env` for `WP_GRAPHQL_URL`) +- `yarn lint` / `yarn lint:fix` — ESLint (standalone flat config built with `@nuxt/eslint-config`, includes stylistic formatting; not generated from `.nuxt/`) +- `yarn typecheck` — `nuxt typecheck` (vue-tsc) +- `yarn test` / `yarn test:watch` — Vitest unit + component tests (`yarn test:unit` / `yarn test:component` run each subset alone) +- `yarn test:e2e` — Playwright e2e against the built server; run `yarn build` first (needs `npx playwright install chromium` once) +- `yarn fetch-mock` — one-off: repopulate `mock/*.json` from the live WP REST API (`scripts/fetch-mock-data.mjs`) + +**macOS dev caveat:** the `dev` script is `TMPDIR=/tmp nuxt dev`. Nuxt 4's dev server connects to its vite-node worker over a unix socket in `$TMPDIR`; macOS's long `/var/folders/…` TMPDIR pushes the socket path past the OS 104-char limit (`connect EINVAL`), so a short TMPDIR is required. The `test`/`test:e2e` scripts set it too. + +## Architecture + +**Data flow (all content is WordPress, via WPGraphQL).** `utils/graphql.ts` exports the single access helper `gqlFetch($config, query, variables)` — a thin `$fetch` POST to `apiGraphqlUrl` that unwraps `data` and throws on GraphQL `errors`. There is no client library and no axios. Queries are authored as **colocated `.graphql` files** imported raw (`import q from "./x.graphql?raw"`) and passed to `gqlFetch`, typically inside `useAsyncData`. Example (`pages/aktuality/index.vue`): + +```ts +import { gqlFetch } from "@/utils/graphql"; +import newsListQuery from "@/pages/aktuality/index.graphql?raw"; +const config = useRuntimeConfig().public; +const { data } = await useAsyncData("newsList", + () => gqlFetch(config, newsListQuery, { language: language.value }), + { watch: [language] }); +``` + +- **Types are generated, not hand-written.** `graphql-codegen` (`codegen.ts`) reads every `.graphql` under `stores/`, `pages/`, `components/` and emits operation + schema types to `models/graphql.generated.ts` (committed). `models/index.ts` re-exports those plus `models/wp.ts`, so consumers import query result types (`GlobalDataQuery`, etc.) and `WpConfig` from `@/models`. Run `yarn codegen` after changing any operation. +- **Global data** (menus, ACF options, featured posts) is the one store: `stores/main.ts` (Pinia, option-store style) has a single guarded `fetchGlobalData()` action that runs `stores/main.graphql`. `app.vue` calls `store.fetchGlobalData()` inside `useAsyncData("global", …)` for every route (the old `nuxtServerInit`). Per-page/detail data is fetched directly with `gqlFetch` in the page's own `useAsyncData`. +- **Mock mode.** `mock/*.json` holds curated, data-only content; `yarn dev:mock` / `yarn build:mock` set `MOCK_DATA=1` (surfaced as `runtimeConfig.public.mockData`) to serve from it instead of the live API. Regenerate the JSON with `yarn fetch-mock`. +- **No build-time `getRoutes()`/payload.** SSR renders each route on demand. `routeRules` in `nuxt.config.ts` cache rendered HTML with stale-while-revalidate (`/**` 60s, `/newsletter/**` 1h) — this is what keeps WP load low while staying fresh. + +**Modules.** `@pinia/nuxt`, `@nuxtjs/i18n`, `@nuxtjs/sitemap` (`@nuxtjs/sitemap` + `site.url`/`trailingSlash` drive `/sitemap.xml`). ESLint is *not* a Nuxt module here — `eslint.config.mjs` builds a standalone flat config via `@nuxt/eslint-config`'s `createConfigForNuxt` (hand-maintained `dirs`), so `yarn lint` works without any `.nuxt/` prepare step. + +**i18n & routing.** Bilingual via `@nuxtjs/i18n` **v10**: `cs` (default) and `en`, locale files in `i18n/locales/`, Vue-I18n runtime options in `i18n/i18n.config.ts` (`legacy: false`, `globalInjection: true` so templates keep `$t`/`$i18n`; in ` diff --git a/assets/css/main.css b/assets/css/main.css index b51f4c8..754247e 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -39,6 +39,10 @@ background: linear-gradient(180deg, rgba(255,255,255,1) 0%, rgba(230,230,230,1) 25%, rgba(230,230,230,1) 100%); } +.has-background { + padding-top: max(2rem, min(10vh, 6rem)); +} + @media screen and (max-width: 783px) { .program-filters__nav { gap: 0.5rem; @@ -127,6 +131,42 @@ margin-bottom: 1rem; } +/* Gutenberg column/alignment classes used in WordPress content (kept as-is + rather than converted to Tailwind). Columns sit side by side from 782px and + stack below, matching WordPress defaults. Auto columns share the row equally; + columns with an inline flex-basis keep that exact width. */ +.wp-block-columns.is-layout-flex { + display: flex; + flex-wrap: wrap; +} +@media screen and (min-width: 782px) { + .wp-block-columns.is-layout-flex { + flex-wrap: nowrap; + } +} +.wp-block-column { + flex-basis: 0; + flex-grow: 1; + min-width: 0; +} +.wp-block-column[style*="flex-basis"] { + flex-grow: 0; +} +@media screen and (max-width: 781px) { + .wp-block-column { + flex-basis: 100% !important; + } +} +.has-text-align-center { + text-align: center; +} +.has-text-align-left { + text-align: left; +} +.has-text-align-right { + text-align: right; +} + @media screen and (max-width: 1536px) { .wp-block-columns { gap: 3rem; @@ -233,5 +273,9 @@ to { transform: translateX(-50%); } } +.page-detail { + padding-top: calc(clamp(2rem, 10vh, 6rem) + 4rem) !important; +} + @tailwind components; @tailwind utilities; diff --git a/assets/scss/_variables.scss b/assets/scss/_variables.scss index ef4a668..d47796a 100644 --- a/assets/scss/_variables.scss +++ b/assets/scss/_variables.scss @@ -103,3 +103,45 @@ $breakpoints: ( } } } + +// +// LARGE-TYPE PROSE (shared by LabeledSection + typographic Wysiwyg blocks) +//–––––––––––––––––––––––––––––––––––––––––––––––––– +// Responsive paragraph scale matching production: xs 1rem → xl 2rem, max 70ch. +@mixin large-type-prose { + :deep(p), + :deep(li) { + font-size: 1rem; + line-height: 1.25rem; + max-width: 70ch; + margin-bottom: 0.5rem; + } + + @media (min-width: 768px) { + :deep(p), + :deep(li) { + font-size: 1.25rem; + line-height: 1.75rem; + } + } + + @media (min-width: 1024px) { + :deep(p), + :deep(li) { + font-size: 1.5rem; + line-height: 2rem; + } + } + + @media (min-width: 1280px) { + :deep(p), + :deep(li) { + font-size: 2rem; + line-height: 2.375rem; + } + } + + :deep(a) { + text-decoration: underline; + } +} diff --git a/bubec-fullstack.code-workspace b/bubec-fullstack.code-workspace new file mode 100644 index 0000000..5627295 --- /dev/null +++ b/bubec-fullstack.code-workspace @@ -0,0 +1,17 @@ +{ + "folders": [ + { + "path": "." + }, + { + "path": "../bubec-wordpress" + } + ], + "settings": { + "cSpell.words": [ + "bubec", + "codegen", + "nuxtjs" + ] + } +} diff --git a/codegen.ts b/codegen.ts new file mode 100644 index 0000000..eb64628 --- /dev/null +++ b/codegen.ts @@ -0,0 +1,35 @@ +import type { CodegenConfig } from "@graphql-codegen/cli"; + +const schema + = process.env.WP_GRAPHQL_URL ?? "https://bubec-wordpress.assemblage.cz/graphql"; + +const config: CodegenConfig = { + schema, + documents: ["stores/**/*.graphql", "pages/**/*.graphql", "components/**/*.graphql"], + ignoreNoDocuments: true, + generates: { + "./models/graphql.generated.ts": { + plugins: [ + // Generated output — not hand-authored. Skip type-checking the file + // itself (the WPGraphQL schema emits a couple of internally-conflicting + // declarations, e.g. LanguageCodeFilterEnum as both enum and union); + // importers still get full types from its exported declarations. + { add: { content: "// @ts-nocheck" } }, + "typescript", + "typescript-operations", + ], + config: { + avoidOptionals: false, + maybeValue: "T | null | undefined", + scalars: { + ID: "string", + }, + // Emit exact operation result shapes (not just schema types) + withHooks: false, + withRefetchFunctions: false, + }, + }, + }, +}; + +export default config; diff --git a/components/BannerImage.vue b/components/BannerImage.vue new file mode 100644 index 0000000..bce3334 --- /dev/null +++ b/components/BannerImage.vue @@ -0,0 +1,19 @@ + + + diff --git a/components/Contacts.spec.ts b/components/Contacts.spec.ts new file mode 100644 index 0000000..0c13b26 --- /dev/null +++ b/components/Contacts.spec.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import Contacts from "@/components/Contacts.vue"; + +const rows = [{ role: "2025", contact: "Praha 4 a Praha 5" }]; + +describe("Contacts", () => { + it("renders an intro block when intro is provided", async () => { + const wrapper = await mountSuspended(Contacts, { + props: { heading: "Dárci", intro: "

Děkujeme dárcům.

", rows }, + }); + const intro = wrapper.find(".contacts-intro"); + expect(intro.exists()).toBe(true); + expect(intro.html()).toContain("Děkujeme dárcům."); + }); + + it("renders no intro block when intro is absent", async () => { + const wrapper = await mountSuspended(Contacts, { + props: { heading: "Herci a asistenti", rows }, + }); + expect(wrapper.find(".contacts-intro").exists()).toBe(false); + }); + + it("still renders the rows table", async () => { + const wrapper = await mountSuspended(Contacts, { props: { rows } }); + expect(wrapper.findAll("tbody tr").length).toBe(1); + expect(wrapper.text()).toContain("2025"); + }); +}); diff --git a/components/Contacts.vue b/components/Contacts.vue new file mode 100644 index 0000000..93c60b6 --- /dev/null +++ b/components/Contacts.vue @@ -0,0 +1,68 @@ + + + diff --git a/components/CookiePolicyBar.vue b/components/CookiePolicyBar.vue index ff78dc7..4b09475 100644 --- a/components/CookiePolicyBar.vue +++ b/components/CookiePolicyBar.vue @@ -1,55 +1,8 @@ - diff --git a/components/CourseThumb.spec.ts b/components/CourseThumb.spec.ts new file mode 100644 index 0000000..9b65e29 --- /dev/null +++ b/components/CourseThumb.spec.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import CourseThumb from "@/components/CourseThumb.vue"; + +describe("CourseThumb", () => { + it("links to the course detail and renders the title with a line break", async () => { + const wrapper = await mountSuspended(CourseThumb, { + props: { item: { slug: "kyanotypie-a-experiment", title: "Kyanotypie–experiment", excerpt: "" } }, + }); + const link = wrapper.find("a"); + expect(link.attributes("href")).toBe("/kurz/kyanotypie-a-experiment/"); + expect(wrapper.html()).toContain("Kyanotypie
experiment"); + }); + + it("renders the excerpt when provided", async () => { + const wrapper = await mountSuspended(CourseThumb, { + props: { item: { slug: "x", title: "X", excerpt: "

Popis kurzu

" } }, + }); + expect(wrapper.html()).toContain("Popis kurzu"); + }); +}); diff --git a/components/CourseThumb.vue b/components/CourseThumb.vue new file mode 100644 index 0000000..dad07ba --- /dev/null +++ b/components/CourseThumb.vue @@ -0,0 +1,26 @@ + + + diff --git a/components/Courses.vue b/components/Courses.vue new file mode 100644 index 0000000..03bd0b6 --- /dev/null +++ b/components/Courses.vue @@ -0,0 +1,36 @@ + + + diff --git a/components/Downloads.spec.ts b/components/Downloads.spec.ts new file mode 100644 index 0000000..0110c65 --- /dev/null +++ b/components/Downloads.spec.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import Downloads from "@/components/Downloads.vue"; + +describe("Downloads", () => { + it("renders a link per file with title and extension", async () => { + const wrapper = await mountSuspended(Downloads, { + props: { items: [{ title: "Plakát", file: "https://x/plakat.pdf" }] }, + }); + const a = wrapper.find("a"); + expect(a.attributes("href")).toBe("https://x/plakat.pdf"); + expect(wrapper.text()).toContain("Plakát"); + expect(wrapper.text().toLowerCase()).toContain("pdf"); + }); + it("renders nothing when empty", async () => { + const wrapper = await mountSuspended(Downloads, { props: { items: [] } }); + expect(wrapper.find("a").exists()).toBe(false); + }); +}); diff --git a/components/Downloads.vue b/components/Downloads.vue new file mode 100644 index 0000000..6a2ee35 --- /dev/null +++ b/components/Downloads.vue @@ -0,0 +1,74 @@ + + + diff --git a/components/Footer.graphql b/components/Footer.graphql new file mode 100644 index 0000000..ea91ec5 --- /dev/null +++ b/components/Footer.graphql @@ -0,0 +1,21 @@ +query FooterLogos { + footerLogos(first: 50, where: { orderby: { field: MENU_ORDER, order: ASC } }) { + nodes { + title + footerLogoFields { + url + altText + extraClass + cssClass + htmlId + heightPx + widthPx + image { + node { + sourceUrl + } + } + } + } + } +} diff --git a/components/Footer.spec.ts b/components/Footer.spec.ts new file mode 100644 index 0000000..0fc0a72 --- /dev/null +++ b/components/Footer.spec.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi } from "vitest"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import Footer from "@/components/Footer.vue"; + +vi.mock("@/utils/graphql", () => ({ + gqlFetch: vi.fn(async () => ({ + footerLogos: { + nodes: [ + { + title: "EU", + footerLogoFields: { + url: "https://x/eu", + altText: "EU logo", + extraClass: "", + image: { node: { sourceUrl: "/images/eu-logo.png" } }, + }, + }, + { + title: "P2", + footerLogoFields: { + url: "", + altText: "Praha 2", + extraClass: "logo-p2", + image: { node: { sourceUrl: "/images/logo-p2.jpeg" } }, + }, + }, + ], + }, + })), +})); + +describe("Footer", () => { + it("renders logos from GraphQL data with alt + extraClass", async () => { + const wrapper = await mountSuspended(Footer); + const imgs = wrapper.findAll(".footer-logos img"); + expect(imgs.length).toBe(2); + expect(imgs[0]!.attributes("alt")).toBe("EU logo"); + expect(imgs[1]!.attributes("src")).toContain("logo-p2"); + expect(wrapper.find(".footer-logos a.logo-p2").exists()).toBe(true); + }); +}); diff --git a/components/Footer.vue b/components/Footer.vue index df1add4..b796e9f 100644 --- a/components/Footer.vue +++ b/components/Footer.vue @@ -1,78 +1,141 @@ - + - - - - diff --git a/components/Icons.vue b/components/Icons.vue index 648944a..5bfe3fb 100644 --- a/components/Icons.vue +++ b/components/Icons.vue @@ -1,115 +1,445 @@ - diff --git a/components/LabeledSection.vue b/components/LabeledSection.vue new file mode 100644 index 0000000..18aadfc --- /dev/null +++ b/components/LabeledSection.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/components/People.vue b/components/People.vue new file mode 100644 index 0000000..a7e36e3 --- /dev/null +++ b/components/People.vue @@ -0,0 +1,23 @@ + + + diff --git a/components/ProgramFilters.spec.js b/components/ProgramFilters.spec.js new file mode 100644 index 0000000..f52f7fe --- /dev/null +++ b/components/ProgramFilters.spec.js @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import ProgramFilters from "@/components/ProgramFilters.vue"; + +// ProgramFilters only manages selection state (category/year multiselects, +// upcoming toggle, view mode) and reports it upward via v-model emits — the +// page owns fetching/filtering. The category/year MultiSelect option lists +// are PrimeVue-internal (only rendered once the dropdown is opened), so +// these tests stick to what's always in the DOM: the result count, the +// selected-filter chips, and the plain toggle/view-mode buttons. +const categories = [ + { id: 1, name: "Workshops" }, + { id: 2, name: "Talks" }, +]; + +const baseProps = { + categories, + years: ["2024", "2023"], + total: 3, + selectedCategories: [], + selectedYears: [], + upcomingOnly: false, + viewMode: "calendar", +}; + +describe("ProgramFilters", () => { + it("renders the total result count", async () => { + const wrapper = await mountSuspended(ProgramFilters, { + props: { ...baseProps, total: 5 }, + }); + expect(wrapper.get(".tabular-nums").text()).toBe("5"); + }); + + it("renders a removable chip per selected category and emits update:selectedCategories on removal", async () => { + const wrapper = await mountSuspended(ProgramFilters, { + props: { ...baseProps, selectedCategories: [1, 2] }, + }); + expect(wrapper.text()).toContain("Workshops"); + expect(wrapper.text()).toContain("Talks"); + + const chip = wrapper.findAll("button").find((b) => b.text().includes("Workshops")); + await chip.trigger("click"); + expect(wrapper.emitted("update:selectedCategories")).toEqual([[[2]]]); + }); + + it("renders a removable chip per selected year and emits update:selectedYears on removal", async () => { + const wrapper = await mountSuspended(ProgramFilters, { + props: { ...baseProps, selectedYears: ["2024", "2023"] }, + }); + const chip = wrapper.findAll("button").find((b) => b.text().includes("2024")); + await chip.trigger("click"); + expect(wrapper.emitted("update:selectedYears")).toEqual([[["2023"]]]); + }); + + it("emits update:viewMode when the list-view button is clicked", async () => { + const wrapper = await mountSuspended(ProgramFilters, { props: baseProps }); + const listButton = wrapper.findAll("button").find((b) => b.text() === "Seznam"); + await listButton.trigger("click"); + expect(wrapper.emitted("update:viewMode")).toEqual([["list"]]); + }); + + it("emits update:upcomingOnly when the toggle is clicked", async () => { + const wrapper = await mountSuspended(ProgramFilters, { props: baseProps }); + const toggle = wrapper.findAll("button").find((b) => b.text().includes("Pouze aktuální")); + await toggle.trigger("click"); + expect(wrapper.emitted("update:upcomingOnly")).toEqual([[true]]); + }); +}); diff --git a/components/ProgramFilters.vue b/components/ProgramFilters.vue index 723d28e..4df9acb 100644 --- a/components/ProgramFilters.vue +++ b/components/ProgramFilters.vue @@ -1,68 +1,231 @@ - diff --git a/components/ProgramList.spec.js b/components/ProgramList.spec.js new file mode 100644 index 0000000..3f03d21 --- /dev/null +++ b/components/ProgramList.spec.js @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import ProgramList from "@/components/ProgramList.vue"; + +// ProgramList only groups/sorts for display — the page (via utils/program.ts's +// filterProgramList) filters by category/year/upcoming before events ever +// reach this component, so these fixtures are handed to it pre-filtered. +const programCategories = [ + { id: 1, name: "Workshops" }, + { id: 2, name: "Talks" }, +]; + +const program = [ + { slug: "x", title: "Event X", program_type: [1], acf: { event_date: "2024-05-10" } }, + { slug: "y", title: "Event Y", program_type: [1], acf: { event_date: "2024-05-20" } }, + { slug: "z", title: "Event Z", program_type: [2], acf: { event_date: "2024-08-01" } }, +]; + +describe("ProgramList", () => { + it("renders a thumbnail per event it's given", async () => { + const wrapper = await mountSuspended(ProgramList, { + props: { program, programCategories }, + }); + expect(wrapper.findAll(".program-thumb")).toHaveLength(3); + expect(wrapper.text()).toContain("Event X"); + expect(wrapper.text()).toContain("Event Z"); + }); + + it("renders each event's category name from programCategories", async () => { + const wrapper = await mountSuspended(ProgramList, { + props: { program, programCategories }, + }); + expect(wrapper.text()).toContain("Workshops"); + expect(wrapper.text()).toContain("Talks"); + }); + + it("groups events by month in calendar view (default)", async () => { + const wrapper = await mountSuspended(ProgramList, { + props: { program, programCategories }, + }); + // May and August 2024 => two month groups. + expect(wrapper.findAll("section")).toHaveLength(2); + }); + + it("renders one flat, unlabeled group in list view", async () => { + const wrapper = await mountSuspended(ProgramList, { + props: { program, programCategories, viewMode: "list" }, + }); + expect(wrapper.findAll("section")).toHaveLength(1); + expect(wrapper.findAll(".program-thumb")).toHaveLength(3); + }); +}); diff --git a/components/ProgramList.vue b/components/ProgramList.vue index d9e37aa..1841f1c 100644 --- a/components/ProgramList.vue +++ b/components/ProgramList.vue @@ -1,195 +1,122 @@ - diff --git a/components/ProgramSection.graphql b/components/ProgramSection.graphql new file mode 100644 index 0000000..042b5e8 --- /dev/null +++ b/components/ProgramSection.graphql @@ -0,0 +1,12 @@ +query ProgramSectionEvents { + events(first: 500) { + nodes { + slug + title + uri + eventMeta { eventDate eventEnd } + eventTypes { nodes { slug databaseId name } } + featuredImage { node { sourceUrl(size: LARGE) altText } } + } + } +} diff --git a/components/ProgramSection.vue b/components/ProgramSection.vue new file mode 100644 index 0000000..b60c440 --- /dev/null +++ b/components/ProgramSection.vue @@ -0,0 +1,96 @@ + + + diff --git a/components/ProgramThumb.vue b/components/ProgramThumb.vue new file mode 100644 index 0000000..785f50d --- /dev/null +++ b/components/ProgramThumb.vue @@ -0,0 +1,54 @@ + + + diff --git a/components/ProseBody.vue b/components/ProseBody.vue new file mode 100644 index 0000000..1ac2aba --- /dev/null +++ b/components/ProseBody.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/components/Slider.vue b/components/Slider.vue new file mode 100644 index 0000000..50f7fba --- /dev/null +++ b/components/Slider.vue @@ -0,0 +1,52 @@ + + + diff --git a/components/Wysiwyg.spec.ts b/components/Wysiwyg.spec.ts new file mode 100644 index 0000000..98d028e --- /dev/null +++ b/components/Wysiwyg.spec.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import Wysiwyg from "@/components/Wysiwyg.vue"; + +describe("Wysiwyg", () => { + it("adds is-large-type on the content wrapper when largeType is set", async () => { + const wrapper = await mountSuspended(Wysiwyg, { + props: { content: "

Arjana Shameti

", largeType: true }, + }); + expect(wrapper.find(".is-large-type").exists()).toBe(true); + expect(wrapper.html()).toContain("Arjana Shameti"); + }); + + it("omits is-large-type by default", async () => { + const wrapper = await mountSuspended(Wysiwyg, { + props: { content: "

plain

" }, + }); + expect(wrapper.find(".is-large-type").exists()).toBe(false); + }); +}); diff --git a/components/Wysiwyg.vue b/components/Wysiwyg.vue new file mode 100644 index 0000000..cdef9b5 --- /dev/null +++ b/components/Wysiwyg.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/components/articlesMarquee.vue b/components/articlesMarquee.vue index 4930f72..35745bf 100644 --- a/components/articlesMarquee.vue +++ b/components/articlesMarquee.vue @@ -1,43 +1,26 @@ - diff --git a/composables/usePageSections.ts b/composables/usePageSections.ts new file mode 100644 index 0000000..75dd625 --- /dev/null +++ b/composables/usePageSections.ts @@ -0,0 +1,11 @@ +// In-page section anchors shown in the sticky header (Header.vue). A page sets +// these in its setup (from its WordPress "Submenu" ACF field, or hardcoded); +// the header reads them. Cleared on every navigation by the page-sections +// plugin, so a page without sections shows none. +export interface PageSection { + title: string; + link: string; +} + +export const usePageSections = () => + useState("page-sections", () => []); diff --git a/composables/useSlider.ts b/composables/useSlider.ts new file mode 100644 index 0000000..16a2f07 --- /dev/null +++ b/composables/useSlider.ts @@ -0,0 +1,44 @@ +import type { Ref } from "vue"; + +// Horizontal image slider logic: centers the active slide and scrolls between +// them. Used by the Slider component; reusable on any page/post that needs the +// same behaviour. Pass a reactive slide count (e.g. computed from a prop). +export function useSlider(slideCount: Ref) { + const sliderRef = ref(null); + const currentSlide = ref(1); + + function setupPadding() { + const slider = sliderRef.value; + if (!slider) return; + slider.scrollLeft = 0; + const first = slider.querySelector("img"); + const last = slider.querySelector("img:last-child"); + if (first && last) { + slider.style.paddingLeft = `${slider.offsetWidth / 2 - first.offsetWidth / 2}px`; + slider.style.paddingRight = `${slider.offsetWidth / 2 - last.offsetWidth / 2}px`; + } + } + + function goToSlide(dir: number) { + const next = currentSlide.value + dir; + if (next < 1 || next > slideCount.value) return; + currentSlide.value = next; + const slider = sliderRef.value; + if (!slider) return; + const img = slider.querySelectorAll("img")[next - 1]; + if (!img) return; + slider.scroll({ + left: img.offsetLeft - (slider.offsetWidth / 2 - img.offsetWidth / 2), + top: 0, + behavior: "smooth", + }); + } + + onMounted(() => { + setupPadding(); + window.addEventListener("resize", setupPadding); + }); + onUnmounted(() => window.removeEventListener("resize", setupPadding)); + + return { sliderRef, currentSlide, goToSlide }; +} diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml new file mode 100644 index 0000000..1bf578b --- /dev/null +++ b/docker-compose.deploy.yml @@ -0,0 +1,19 @@ +services: + nuxt: + container_name: nuxt-ssr + build: + context: . + dockerfile: Dockerfile.deploy + ports: + - "127.0.0.1:3000:3000" + environment: + NODE_ENV: "production" + NITRO_PORT: "3000" + NITRO_HOST: "0.0.0.0" + API_PER_PAGE: "20" + API_REQUEST_DELAY_MS: "500" + API_RETRIES: "2" + API_RETRY_DELAY_MS: "1000" + API_TIMEOUT_MS: "45000" + restart: unless-stopped + command: ["node", ".output/server/index.mjs"] diff --git a/docker-compose.yml b/docker-compose.yml index bbc13b1..6473e54 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,19 @@ services: nuxt: - container_name: nuxt-netlify + container_name: nuxt-ssr build: context: . dockerfile: Dockerfile ports: - "3000:3000" environment: - NODE_VERSION: "24.11.1" - NPM_FLAGS: "--no-optional" + NODE_ENV: "production" NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt" - volumes: - - .:/app - - /app/node_modules - command: ["npm", "run", "dev"] + NITRO_PORT: "3000" + NITRO_HOST: "0.0.0.0" + API_PER_PAGE: "20" + API_REQUEST_DELAY_MS: "500" + API_RETRIES: "2" + API_RETRY_DELAY_MS: "1000" + API_TIMEOUT_MS: "45000" + command: ["node", ".output/server/index.mjs"] diff --git a/docs/superpowers/plans/2026-06-06-nuxt4-migration.md b/docs/superpowers/plans/2026-06-06-nuxt4-migration.md new file mode 100644 index 0000000..7e64082 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-nuxt4-migration.md @@ -0,0 +1,2425 @@ +# Bubec Nuxt 2 → Nuxt 4 Migration Implementation Plan (SSR) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate the Studio Bubec marketing site from Nuxt 2.18 / Vue 2 / Vuex to Nuxt **4.4.7** / Vue 3.5 / Pinia, running as a **server-side-rendered (SSR) Node server** (not a static `nuxt generate` build), with **Composition API (` +``` + +- [ ] **Step 2: Commit** + +```bash +git add app.vue +git commit -m "feat(app): app.vue with store init + WP head assets + GTM noscript" +``` + +--- + +### Task C2: Convert the default layout + +**Files:** +- Modify: `layouts/default.vue` + +Convert to ` +``` + +- [ ] **Step 2: Commit** + +```bash +git add layouts/default.vue +git commit -m "refactor(layout): convert default layout to script setup (useLocaleHead, useCookie, GTM)" +``` + +--- + +### Task C3: Create root `error.vue` + +**Files:** +- Create: `error.vue` +- Delete: `layouts/error.vue` + +Nuxt 3/4 use a root `error.vue` (not `layouts/error.vue`). The old one was essentially empty with a title. + +- [ ] **Step 1: Write `error.vue`** + +```vue + + + +``` + +- [ ] **Step 2: Delete the old layout** + +```bash +git rm layouts/error.vue +``` + +- [ ] **Step 3: Commit** + +```bash +git add error.vue +git commit -m "feat(error): add root error.vue, remove layouts/error.vue" +``` + +--- + +## Phase D — Components (full Composition API conversion) + +> Every component becomes ` +``` + +Template notes: `showMenu = !showMenu`, `activeSubmenu = …` etc. in the template mutate the refs directly (auto-unwrapped). The `ref="mainHeader"` binds to the `mainHeader` ref. The explicit `import Icons/articlesMarquee` and `components:{}` block are gone (auto-import). Keep the ` +``` + +- [ ] **Step 3: Write the failing Wysiwyg test** + +Create `tests/component/Wysiwyg.spec.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import Wysiwyg from "@/components/Wysiwyg.vue"; + +describe("Wysiwyg", () => { + it("adds is-large-type on the content wrapper when largeType is set", async () => { + const wrapper = await mountSuspended(Wysiwyg, { + props: { content: "

Arjana Shameti

", largeType: true }, + }); + expect(wrapper.find(".is-large-type").exists()).toBe(true); + expect(wrapper.html()).toContain("Arjana Shameti"); + }); + + it("omits is-large-type by default", async () => { + const wrapper = await mountSuspended(Wysiwyg, { + props: { content: "

plain

" }, + }); + expect(wrapper.find(".is-large-type").exists()).toBe(false); + }); +}); +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `yarn test tests/component/Wysiwyg.spec.ts` +Expected: FAIL — `.is-large-type` not found. + +- [ ] **Step 5: Add the `largeType` prop + wrapper class + scoped mixin** + +In `components/Wysiwyg.vue`, update the props: + +```ts +defineProps<{ + heading?: string; + content?: string; + hasBackground?: boolean; + anchorId?: string; + largeType?: boolean; +}>(); +``` + +Change the content render line to a wrapper carrying the conditional class: + +```html +
+``` + +Add a scoped style block that applies the mixin only under the class: + +```scss + +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `yarn test tests/component/Wysiwyg.spec.ts` +Expected: PASS (2 passed). + +- [ ] **Step 7: Guard against regressions in LabeledSection** + +Run: `yarn test tests/component` +Expected: PASS across the component suite (LabeledSection still renders its body; no snapshot/DOM errors). + +- [ ] **Step 8: Commit** (only if authorized) + +```bash +git add assets/scss/_variables.scss components/LabeledSection.vue components/Wysiwyg.vue tests/component/Wysiwyg.spec.ts +git commit -m "feat(wysiwyg): shared large-type prose mixin + largeType prop" +``` + +--- + +## Task 4: Seed script — scaffolding, helpers, and the Czech `divadlo` page + +**Files:** +- Create: `bubec-wordpress/www/migrate/divadlo-theatre-sections.php` + +**Interfaces:** +- Consumes: ACF `sections` flexible content with the `contacts.intro` field (Task 2); `content_raw` title setting. +- Produces: an idempotent seeding function `bubec_seed_theatre_page(array $cfg)` and a gallery-id resolver `bubec_gallery_ids(array $filenames): array`. Task 5 adds the English config and the two invocations. + +- [ ] **Step 1: Create the script with helpers + Czech content, seeding divadlo (547)** + +Create `bubec-wordpress/www/migrate/divadlo-theatre-sections.php`: + +```php + 'attachment', + 'post_status' => 'inherit', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'meta_query' => [[ + 'key' => '_wp_attached_file', + 'value' => $fname, + 'compare' => 'LIKE', + ]], + ]); + if (!empty($q->posts)) { + $ids[] = (int) $q->posts[0]; + echo " gallery: found {$fname} -> {$q->posts[0]}\n"; + continue; + } + $id = media_sideload_image(BUBEC_UPLOADS_BASE . $fname, 0, null, 'id'); + if (is_wp_error($id)) { + echo " gallery: FAILED to sideload {$fname}: " . $id->get_error_message() . "\n"; + continue; + } + $ids[] = (int) $id; + echo " gallery: sideloaded {$fname} -> {$id}\n"; + } + return $ids; +} + +// Build the flexible-content `sections` array and write the page body + title flags. +function bubec_seed_theatre_page(array $cfg): void { + $pid = $cfg['pid']; + + // 1) Two-column intro as the page body; render it raw (contentRaw) so the + // wp-block layout classes pass through untouched. + wp_update_post(['ID' => $pid, 'post_content' => $cfg['body']]); + update_field('render_title', 1, $pid); + update_field('content_raw', 1, $pid); + + // 2) Flexible-content sections in the target order. + $sections = [ + [ + 'acf_fc_layout' => 'contacts', + 'heading' => $cfg['donors_heading'], + 'intro' => $cfg['donors_intro'], + 'rows' => $cfg['donors_rows'], + ], + [ + 'acf_fc_layout' => 'wysiwyg', + 'heading' => '', + 'content' => $cfg['arjana'], + ], + [ + 'acf_fc_layout' => 'wysiwyg', + 'heading' => '', + 'content' => $cfg['studio'], + ], + [ + 'acf_fc_layout' => 'gallery', + 'images' => bubec_gallery_ids($cfg['gallery']), + 'full_width' => 0, + ], + [ + 'acf_fc_layout' => 'contacts', + 'heading' => $cfg['crew_heading'], + 'rows' => $cfg['crew_rows'], + ], + ]; + update_field('sections', $sections, $pid); + echo "seeded page {$pid}: " . count($sections) . " sections, " + . count($cfg['donors_rows']) . " donor rows, " + . count($cfg['crew_rows']) . " crew rows\n"; +} + +// Note: the two wysiwyg blocks are seeded with `largeType` OFF at the ACF level +// (there is no ACF flag); the frontend applies large type to these blocks — see +// Task 6. The markup below uses

so the shared large-type scale targets it. + +$GALLERY = [ + 'pejsek-a-kocicka-1.png', 'pejsek-a-kocicka-2.png', + 'predstaveni-noe-1.jpg', 'predstaveni-noe-2.png', 'predstaveni-noe-3.png', + 'predstaveni-noe-4.png', 'predstaveni-noe-5.png', 'predstaveni-noe-6.png', +]; + +$CS = [ + 'pid' => 547, + 'body' => '

' + . '
' + . '

Bubec hraje pravidelně v pražských nemocnicích loutková terapeutická představení. Jak nám to funguje? Každou středu jsme k vidění na dětských odděleních pražských nemocnic s loutkami v pojízdné tašce. Hrajeme v hernách, v pokojích, na postýlkách malých pacientů. Rozesmíváme, zpíváme, hrajeme a někdy hrají děti a my jen zíráme.

' + . '
' + . '
' + . '

Je to taková hra dětí s námi herci a loutkami, nebo obráceně naše herecká představení, do kterých děti vstupují se svými nápady. Pomáháme jim, aby se cítily co možná nejlépe v zařízení jménem nemocnice. Zahrajeme ale, kde si řeknete, třeba v divadle, v kostele, na jevišti, pod jevištěm, na ulici, ve škole, ve školkách, doma, na zahradě, v kuchyni, na stropě… a tak.

' + . '
', + 'donors_heading' => 'Dárci', + 'donors_intro' => '

Děkujeme a jsme vděční všem podporovatelům, zvláště pak našim věrným dárcům, kteří získali titul Zlatí dárci: Ing. Veronice Winklerové a Ing. Petrovi Vernerovi, Csc.. Díky Vám může Divadelní studio Bubec vstupovat do pražských nemocnic a dětských domovů a rozfoukat legrací a vtipem pohádek tato prostředí. Prosíme jménem dětí, zařaďte se do čestné řady dárců. Pomozte!

Dárci, kteří podpořili naši činnost:

', + 'donors_rows' => [ + ['role' => '2025', 'contact' => 'Městská část Praha 4 a Městská část Praha 5'], + ['role' => '2020', 'contact' => 'Nadace život umělce, Městská část Praha 2, 4 a 8'], + ['role' => '2016', 'contact' => 'Nadace umění pro zdraví'], + ['role' => '2014', 'contact' => 'Zlatí dárci: Ing. Veronika Winklerová a Ing. Petr Verner'], + ['role' => '2013', 'contact' => 'Ing. Petr Verner CSc., Ing. Veronika Winklerová, Martin Reiman, MHMP, Městská část Praha 5'], + ['role' => '2012', 'contact' => 'Ing. Petr Verner CSc., MHMP, Městská část Praha 5'], + ['role' => '2011', 'contact' => 'Ing. Petr Verner CSc., MHMP'], + ['role' => '2010', 'contact' => 'Ing. Petr Verner CSc., MHMP, rodiče domácí školy, Voršilská, Praha 1'], + ['role' => '2009', 'contact' => 'Ing. Petr Verner CSc.'], + ['role' => '2006', 'contact' => 'Manželé Šafránkovi'], + ['role' => '2005', 'contact' => 'Ungelt / Spectrum; Boiron CZ, s r.o.; Travellers hostel s r.o.; Squire, Sanders a Dempsey L.L.P – organizační složka; Advokátní kancelář: Balcar, Polanský a Spol.; DLA Weiss-Tessbach Prague; Děti ze Základní školy, Praha 13, Kuncova 1580; Manželé Šafránkovi; Cobra s.r.o.'], + ], + 'arjana' => '

Arjana Shameti
arjanasuska@gmail.com
+420 603 807 526

', + 'studio' => '

Studio Bubec
Tělovýchovná 748, 155 00 Praha 5 – Řeporyje

' + . '

divadelní č. ú.: 1910018001/5500

' + . '

poštovní adresa: Bubec, o.p.s., Tělovýchovná 748, 155 00 Praha 5 – Řeporyje

' + . '

IČO: 70824185   DIČ: CZ70824185

', + 'gallery' => $GALLERY, + 'crew_heading' => 'Herci a asistenti', + 'crew_rows' => [ + ['role' => 'Arjana Shameti', 'contact' => 'arjana@suska.cz'], + ['role' => 'Čestmír Suška', 'contact' => 'cestmir@suska.cz'], + ['role' => 'Daniel Suška', 'contact' => 'suskadaniel.design@gmail.com'], + ], +]; + +bubec_seed_theatre_page($CS); +echo "DONE (cs)\n"; +``` + +- [ ] **Step 2: Run the seed script** + +Run: + +```bash +cd /Users/mp/dev/bubec-wordpress && docker compose run --rm php wp eval-file migrate/divadlo-theatre-sections.php --allow-root +``` + +Expected: prints `gallery: found …` lines for all 8 images (no sideload), `seeded page 547: 5 sections, 11 donor rows, 3 crew rows`, then `DONE (cs)`. + +- [ ] **Step 3: Verify the seeded structure via GraphQL** + +Run: + +```bash +curl -s -X POST 'http://localhost:8888/wordpress/graphql' -H 'Content-Type: application/json' \ + -d '{"query":"{ pages(where:{name:\"divadlo\"},first:1){ nodes { titleSettings{ contentRaw } pageSections{ sections{ __typename ... on PageSectionsSectionsContactsLayout { heading } } } } } }"}' +``` + +Expected: `contentRaw: true`; section `__typename` order is Contacts, Wysiwyg, Wysiwyg, Gallery, Contacts; first Contacts `heading` = `Dárci`, last = `Herci a asistenti`. + +- [ ] **Step 4: Commit** (only if authorized) + +```bash +git -C /Users/mp/dev/bubec-wordpress add www/migrate/divadlo-theatre-sections.php +git -C /Users/mp/dev/bubec-wordpress commit -m "feat(migrate): restructure divadlo (cs) sections to match production" +``` + +--- + +## Task 5: Seed the English `theatre` page (translated) + +**Files:** +- Modify: `bubec-wordpress/www/migrate/divadlo-theatre-sections.php` + +**Interfaces:** +- Consumes: `bubec_seed_theatre_page()` and `$GALLERY` from Task 4. +- Produces: seeded page 961 with the same structure, English copy. Proper nouns (donor names/orgs, staff names, addresses, account numbers) are unchanged; only labels/prose are translated. + +- [ ] **Step 1: Add the English config + invocation** + +In `divadlo-theatre-sections.php`, immediately before the final `echo "DONE (cs)\n";`, insert the English config and call (keep the same `$GALLERY`): + +```php +$EN = [ + 'pid' => 961, + 'body' => '
' + . '
' + . '

Bubec regularly performs puppet therapy shows in Prague hospitals. How does it work? Every Wednesday you can find us in the children\'s wards of Prague hospitals with puppets in a rolling bag. We play in playrooms, in rooms, at the bedsides of little patients. We make them laugh, we sing, we play, and sometimes the children play and we just watch in awe.

' + . '
' + . '
' + . '

It is a kind of play between the children and us actors and puppets, or the other way around — our performances into which the children enter with their own ideas. We help them feel as good as possible in a place called a hospital. But we will perform wherever you ask — in a theatre, in a church, on stage, under the stage, in the street, at school, in kindergartens, at home, in the garden, in the kitchen, on the ceiling… and so on.

' + . '
', + 'donors_heading' => 'Donors', + 'donors_intro' => '

We thank and are grateful to all our supporters, and especially to our loyal donors who earned the title of Golden Donors: Ing. Veronika Winklerová and Ing. Petr Verner, CSc.. Thanks to you, Divadelní studio Bubec can enter Prague hospitals and children\'s homes and fill these places with the fun and wit of fairy tales. On behalf of the children, please join the honourable ranks of donors. Help us!

Donors who have supported our work:

', + 'donors_rows' => [ + ['role' => '2025', 'contact' => 'Prague 4 and Prague 5 districts'], + ['role' => '2020', 'contact' => 'Nadace život umělce, Prague 2, 4 and 8 districts'], + ['role' => '2016', 'contact' => 'Nadace umění pro zdraví'], + ['role' => '2014', 'contact' => 'Golden Donors: Ing. Veronika Winklerová and Ing. Petr Verner'], + ['role' => '2013', 'contact' => 'Ing. Petr Verner CSc., Ing. Veronika Winklerová, Martin Reiman, MHMP, Prague 5 district'], + ['role' => '2012', 'contact' => 'Ing. Petr Verner CSc., MHMP, Prague 5 district'], + ['role' => '2011', 'contact' => 'Ing. Petr Verner CSc., MHMP'], + ['role' => '2010', 'contact' => 'Ing. Petr Verner CSc., MHMP, home-school parents, Voršilská, Prague 1'], + ['role' => '2009', 'contact' => 'Ing. Petr Verner CSc.'], + ['role' => '2006', 'contact' => 'The Šafránek family'], + ['role' => '2005', 'contact' => 'Ungelt / Spectrum; Boiron CZ, s r.o.; Travellers hostel s r.o.; Squire, Sanders a Dempsey L.L.P – organizační složka; Advokátní kancelář: Balcar, Polanský a Spol.; DLA Weiss-Tessbach Prague; children from the primary school, Prague 13, Kuncova 1580; The Šafránek family; Cobra s.r.o.'], + ], + 'arjana' => '

Arjana Shameti
arjanasuska@gmail.com
+420 603 807 526

', + 'studio' => '

Studio Bubec
Tělovýchovná 748, 155 00 Praha 5 – Řeporyje

' + . '

theatre account no.: 1910018001/5500

' + . '

postal address: Bubec, o.p.s., Tělovýchovná 748, 155 00 Praha 5 – Řeporyje

' + . '

Company ID: 70824185   VAT: CZ70824185

', + 'gallery' => $GALLERY, + 'crew_heading' => 'Actors and assistants', + 'crew_rows' => [ + ['role' => 'Arjana Shameti', 'contact' => 'arjana@suska.cz'], + ['role' => 'Čestmír Suška', 'contact' => 'cestmir@suska.cz'], + ['role' => 'Daniel Suška', 'contact' => 'suskadaniel.design@gmail.com'], + ], +]; + +bubec_seed_theatre_page($EN); +echo "DONE (en)\n"; +``` + +- [ ] **Step 2: Re-run the seed script** + +Run: + +```bash +cd /Users/mp/dev/bubec-wordpress && docker compose run --rm php wp eval-file migrate/divadlo-theatre-sections.php --allow-root +``` + +Expected: `seeded page 547 …`, `DONE (cs)`, `seeded page 961 …`, `DONE (en)`. + +- [ ] **Step 3: Verify the English page via GraphQL** + +Run: + +```bash +curl -s -X POST 'http://localhost:8888/wordpress/graphql' -H 'Content-Type: application/json' \ + -d '{"query":"{ pages(where:{name:\"theatre\"},first:1){ nodes { pageSections{ sections{ __typename ... on PageSectionsSectionsContactsLayout { heading } } } } } }"}' +``` + +Expected: same 5-section order; Contacts headings `Donors` and `Actors and assistants`. + +- [ ] **Step 4: Commit** (only if authorized) + +```bash +git -C /Users/mp/dev/bubec-wordpress add www/migrate/divadlo-theatre-sections.php +git -C /Users/mp/dev/bubec-wordpress commit -m "feat(migrate): seed theatre (en) sections, translated copy" +``` + +--- + +## Task 6: Apply large type to the typographic blocks + verify rendered pages + +**Files:** +- Modify: `pages/[slug].vue` +- Modify: `TODO.md` + +**Interfaces:** +- Consumes: `Wysiwyg` `largeType` prop (Task 3); seeded pages (Tasks 4–5). +- Produces: the two seeded typographic `wysiwyg` sections render with large type. Because there is no per-section ACF flag, `[slug].vue` marks `wysiwyg` sections with an empty `heading` as `largeType` (the Arjana + Studio Bubec blocks are seeded with empty headings; the Podpořte/other wysiwyg blocks on o-nas have headings, so they are unaffected). + +- [ ] **Step 1: Pass `largeType` for heading-less wysiwyg sections** + +In `pages/[slug].vue`, the `wysiwyg` Section variant and mapping already exist. Add a `largeType` field. Update the `wysiwyg` case in the `sections` computed to set it when there is no heading: + +```ts + case "PageSectionsSectionsWysiwygLayout": + return { + type: "wysiwyg", + heading: s.heading ?? "", + anchorId: s.anchorId ?? "", + hasBackground: Boolean(s.hasBackground), + content: s.content ?? "", + largeType: !(s.heading ?? "").trim(), + }; +``` + +Add `largeType: boolean` to the `wysiwyg` variant of the `Section` union: + +```ts + | { type: "wysiwyg"; heading: string; content: string; hasBackground: boolean; anchorId: string; largeType: boolean } +``` + +And pass it in the template: + +```html + +``` + +- [ ] **Step 2: Type-check + component suite** + +Run: `yarn test tests/component` +Expected: PASS (no type/runtime regressions). + +- [ ] **Step 3: Manually verify both rendered pages** + +Start the dev server if not running (`yarn dev`), then load: +- `http://localhost:3000/divadlo/` +- `http://localhost:3000/en/theatre/` + +Confirm on each: h1 title; two-column intro; **Dárci/Donors** with heading + intro paragraph + year→donor table; Arjana block in large type; Studio Bubec block in large type; 8-image gallery; **Herci a asistenti/Actors and assistants** table with linked emails. Order matches production. + +- [ ] **Step 4: Confirm o-nas is unaffected** + +Load `http://localhost:3000/o-nas/` and confirm the Tým a kontakty / Další kontakty tables still render (no intro block, normal type) — proves the shared changes are backwards-compatible. + +- [ ] **Step 5: Move the TODO item to DONE** + +In `TODO.md`, cut the line under `## IN PROGRESS`: + +``` +- [ ] divadlo page is wrong, we need to follow the page design and section content based on the production site. … +``` + +and add it under `## DONE` as a checked item: + +``` +- [x] divadlo (cs) + theatre (en) pages restructured to match production: two-column intro, Dárci table-with-intro, Arjana + Studio Bubec large-type blocks, gallery, Herci a asistenti table (contacts.intro ACF field + shared large-type mixin + seed script) +``` + +- [ ] **Step 6: Commit** (only if authorized) + +```bash +git add pages/\[slug\].vue TODO.md +git commit -m "feat([slug]): large type for heading-less wysiwyg blocks; mark divadlo TODO done" +``` + +--- + +## Self-Review notes + +- **Spec coverage:** intro columns (Task 4/5 body + Task 6 render), Dárci table-with-intro (Tasks 1–2 + 4/5 data), Arjana + Studio Bubec large-type (Task 3 + Task 6), gallery with imported images (Task 4 resolver), Herci a asistenti table (Task 4/5), both languages (Task 5), large-type reuse from o-nas via shared mixin (Task 3). All covered. +- **Type consistency:** `intro` (string) used identically in Contacts prop, `[slug].vue` mapping, ACF `graphql_field_name`. `largeType` (boolean) consistent across Wysiwyg prop, `[slug].vue` variant + mapping. Gallery resolver returns `int[]` matching ACF `images` (id return format). +- **Backwards-compat:** `intro`/`largeType` optional; existing o-nas contacts (with headings, no intro) unchanged — verified in Task 6 Step 4. +- **Assumption to watch:** the large-type toggle keys off an empty wysiwyg heading. If a future heading-less wysiwyg block should NOT be large type, promote this to an explicit ACF boolean field. Noted, not built (YAGNI). diff --git a/docs/superpowers/plans/2026-07-06-kreativ-bubec-rebuild.md b/docs/superpowers/plans/2026-07-06-kreativ-bubec-rebuild.md new file mode 100644 index 0000000..7f01685 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-kreativ-bubec-rebuild.md @@ -0,0 +1,545 @@ +# Kreativ Bubec Rebuild Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Faithfully rebuild the local Kreativ Bubec page (WordPress page ID 99) as ACF flexible-content sections that reproduce the production page 1:1, adding the one missing `courses` section type. + +**Architecture:** Add a `courses` layout to the ACF `sections` flexible-content field in the headless WordPress theme (`bubec-wordpress`); render it in the Nuxt frontend (`bubec-nuxt`) via a new `Courses.vue` + `CourseThumb.vue` pair wired into `pages/[slug].vue`; then repopulate page 99's `sections` meta with an idempotent PHP script run through WP-CLI. All referenced media already exists locally. + +**Tech Stack:** WordPress + ACF Pro + WPGraphQL (wp-graphql-polylang) in `bubec-wordpress`; Nuxt 4 / Vue 3 ` +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +cd /Users/mp/dev/bubec-nuxt +yarn test tests/component/CourseThumb.spec.ts +``` + +Expected: PASS (both tests). + +- [ ] **Step 5: Create `Courses.vue`** + +Create `bubec-nuxt/components/Courses.vue`: + +```vue + + + +``` + +- [ ] **Step 6: Add the Courses fragment to the page query** + +In `bubec-nuxt/pages/[slug].graphql`, add this fragment inside the `sections { ... }` selection, immediately after the `PageSectionsSectionsPeopleLayout` block: + +```graphql + ... on PageSectionsSectionsCoursesLayout { + heading + anchorId + hasBackground + courses { + nodes { + ... on Course { + slug + title + excerpt + } + } + } + } +``` + +- [ ] **Step 7: Regenerate GraphQL types** + +```bash +cd /Users/mp/dev/bubec-nuxt +yarn codegen +``` + +Expected: completes without error; `models/graphql.generated.ts` now contains `PageSectionsSectionsCoursesLayout`. (Do NOT stage this file — it is gitignored.) + +- [ ] **Step 8: Wire the section into `pages/[slug].vue`** + +In `bubec-nuxt/pages/[slug].vue`, make three edits: + +(a) Add to the `Section` union type (after the `people` variant, around line 133): + +```ts + | { type: "courses"; heading: string; anchorId: string; hasBackground: boolean; courses: Array<{ slug: string; title: string; excerpt: string }> } +``` + +(b) Add a case in the normalizer `switch` (after the `PageSectionsSectionsPeopleLayout` case, before `PageSectionsSectionsContactsLayout`): + +```ts + case "PageSectionsSectionsCoursesLayout": + return { + type: "courses", + heading: s.heading ?? "", + anchorId: s.anchorId ?? "", + hasBackground: Boolean(s.hasBackground), + courses: (s.courses?.nodes ?? []).map((n: any) => ({ + slug: n.slug ?? "", + title: n.title ?? "", + excerpt: n.excerpt ?? "", + })), + }; +``` + +(c) Add to the template's flow-sections loop (after the `` block): + +```vue + +``` + +- [ ] **Step 9: Typecheck and run the component tests** + +```bash +cd /Users/mp/dev/bubec-nuxt +yarn typecheck +yarn test tests/component/CourseThumb.spec.ts +``` + +Expected: typecheck passes; tests pass. + +- [ ] **Step 10: Lint the new/changed files** + +```bash +cd /Users/mp/dev/bubec-nuxt +yarn lint:fix +``` + +Expected: no remaining errors. + +- [ ] **Step 11: Stage (do NOT commit)** + +```bash +cd /Users/mp/dev/bubec-nuxt +git add components/CourseThumb.vue components/Courses.vue tests/component/CourseThumb.spec.ts pages/\[slug\].graphql pages/\[slug\].vue +git status --short +``` + +Verify `models/graphql.generated.ts` is NOT in the staged list. + +--- + +## Task 3: Repopulate page 99's sections (content rebuild) + +**Files:** +- Create: `bubec-wordpress/www/migrate/kreativ-rebuild.php` (run via `wp eval-file`, then kept in the repo as the migration record) + +**Interfaces:** +- Consumes from Task 1: layout names `banner`, `wysiwyg`, `gallery`, `courses`, `people`, `downloads`, `program` and their sub-field names. +- Produces: page 99 `sections` flexible content matching the spec's 27-row target order. + +**Note on ACF write API:** `update_field('sections', $rows, 99)` where each `$row` is an associative array with `acf_fc_layout` plus sub-field values keyed by **name**. Image/gallery/relationship/file sub-fields (all `return_format => id`) accept an attachment ID or an array of IDs. The `program` `category` (taxonomy field) accepts the term ID `17` (`event_type/creative`). The `all_events_link` (link field) accepts an array `['url'=>..,'title'=>..,'target'=>..]`. + +- [ ] **Step 1: Write the migration script** + +Create `bubec-wordpress/www/migrate/kreativ-rebuild.php`: + +```php +
Kreativ Bubec je součástí uměleckých aktivit Studia Bubec, které díky němu podporuje mimoškolní vzdělávání. Je místem pro diskuze o umění a především prostorem pro samotnou uměleckou tvorbu, kterou zpřístupňujeme všem bez rozdílu věku a dosavadní zkušenosti s oborem. V Kreativ cíleně pracujeme s výtvarnou interpretací uměleckých děl a nabízíme prostor pro netradiční experimenty v rámci pořádaných workshopů a pravidelných kurzů pro veřejnost.
Kreativ se vydává i mimo své domovské prostory – můžete se s ním setkat například v rámci doprovodného programu Festivalu m3 / Umění v prostoru. Kreativ také tvoří „přenosné workshopy" ve formě prodejních Kreativ boxů – ty obsahují nápady a potřebné materiály pro výtvarné experimenty ve vašem domácím prostředí. Tvořit s námi tedy můžete, i když nemáte možnost nás zrovna navštívit.
'; + +$deti = '
Kurzy děti
Přihláška ZDE
Studio dlouhodobě podporuje mimoškolní vzdělávací programy pro děti formou pravidelných výtvarných kurzů. Výtvarné kurzy vedou zkušené lektorky s rozmanitým zaměřením.
Pracujeme s mnoha výtvarnými médii tradičně i netradičně. Je běžné se v Kreativ nabídce setkat jak s akademickou kresbou, malbou, grafikou, prostorovou tvorbou, tak s novými médii a materiálovými experimenty. Nabídka se soustřeďuje na výtvarnou interpretaci významných uměleckých děl a současnou experimentální tvorbu. V navazujících kurzech tak děti rozvíjejí schopnost vlastního vnímání současného umění i sebe samých skrze možnost sebevyjadřování.
'; + +$dospeli = '
Kurzy dospělí
Naše programy přinášejí nabídku skupinám, které mají zájem navštívit Studio Bubec, seznámit se s historií Studia a s aktuálním výstavním programem. Součástí programu je vždy i vlastní umělecká aktivita se zaměřením na hry s materiálem či se zvukem a hmatem. Vše probíhá v Kreativ ateliéru, nebo v přilehlé zahradě Bubec. Programy jsou vždy určeny pro vybrané věkové kategorie a trvají minimálně 60 minut.
'; + +$tabory = '
Kreativ Bubec příměstské tábory navazují na dlouholetou tradici. S dětmi se vydáváme tvořit, poznávat, cestovat a hrát si i v letním období. Nabídka jednotlivých turnusů se odlišuje výtvarným konceptem jednotlivých lektorek, aby s námi děti mohly růst a prožívat každé léto nové tvůrčí obzory své kreativity. Tábory probíhají v našem studiu, ale vydáváme se tvořit i do přilehlé přírody nebo navštěvujeme pražské galerie. Děti tak prožívají společné sdílení uměleckých zážitků.
'; + +$lektorske = '
Lektorské programy
Kreativ Bubec dlouhodobě podporuje edukaci dětí v oblasti umění. Naše lektorské programy nabízí školním či zájmovým skupinám tvořit v autentickém sochařském studiu, doslova po boku sochařů a multimediálních autorů, kteří u nás vystavují i tvoří. Děti se seznámí s celým tvůrčím prostorem a samy se tvůrci stanou.
V rámci vybraného programu se děti seznámí s rozsáhlým depozitářem sochařských děl Čestmíra Sušky a stálou expozicí soch na Zahradě Bubec.
Rezervace programu: 14 dní před vaší návštěvou. Délka: 120 minut. Cena: 1 žák ZŠ / SŠ 120 Kč.
Přihlášky: eliska.studiobubec@gmail.com
Financováno Evropskou unií – Next Generation EU
Financováno Evropskou unií – Next Generation EU
'; + +$box_intro = '
Kreativ box
Kreativ myslí na děti, které se za námi nemohou osobně vydat a mají tvořivého ducha.
Pro ně navrhujeme a vytváříme Kreativ Boxy s obsahem méně tradičních výtvarných technik pro domácí tvoření.
Naleznete v nich inspiraci pro experiment, který je zábavný, a jeho výsledkem je vlastní umělecké dílo.

Box vždy obsahuje návod a materiál k výtvarné tvorbě pro děti i hravé rodiče.

'; + +$kyanotypie = '

Kyanotypie

KREATIV BOX KYANOTYPIE nabízí jednu z nejstarších fotografických technik – kyanotypii neboli modrotisk. Technika podněcuje experiment a hravou tvorbu. Vhodné pro dospělé a děti od 7 let ve spolupráci s rodiči.

'; + +$rezotisk = '

Rezotisk

KREATIV BOX REZOTISK představuje experimentální techniku rezotisku neboli tisku pomocí rzi. Proces korodování našel uplatnění v umělecké tvorbě pro svojí schopnost vytvářet zajímavé struktury i barvy. Součástí boxu je originální železný fragment autorské tvorby Čestmíra Sušky.

'; + +$vylet = '

Výlet v kapse

KREATIV BOX VÝLET V KAPSE rozvíjí fantazii a smysl pro hravost. Je navržen jako skladná hra, kterou si děti sestavují podle svých představ a nápadů. Krabička obsahuje set s papírovými díly, samolepkami a magnetkami. Po vytvoření herní plochy ji děti mohou hned rozehrát. Vhodné pro děti od 8 let.

'; + +$workshopy = '
Workshopy pro školy a firmy
Pro velké školní skupiny nebo pro firmy navrhujeme individuální kreativní workshopy. Pro školy lze sjednat i klauzury nebo praxe. Máme profesionální vybavené zázemí, kde mohou kreativci využít našeho personálu a naučit se s dřevem, kovem, textilem a jinými materiály.
Více informací: eliska.studiobubec@gmail.com nebo nám napište do formuláře níže (přihláška).
'; + +$lektori_intro = '

Lektorky a lektoři

Naše zkušené lektorky a lektoři nabízejí tvorbu pomocí rozmanitých výtvarných technik. Od klasické kresby, malby, nebo grafiky, se skrze fotografii a video setkáte i s pojmy happening nebo site-specific umění. Program je vždy vytvářen na míru skupině, která se na kurzu propojuje.

'; + +$prihlaska = '
Přihláška
Přihlášku, kterou najdete na tomto odkazu PŘIHLÁŠKA, posílejte na email eliska.studiobubec@gmail.com.
Uveďte své jméno, telefonní číslo a email.
'; + +$rows = [ + ['acf_fc_layout' => 'banner', 'image' => 2710, 'pin_to_top' => 1], + ['acf_fc_layout' => 'wysiwyg', 'heading' => '', 'anchor_id' => 'info', 'has_background' => 0, 'content' => $intro], + ['acf_fc_layout' => 'gallery', 'images' => [2710,2711,2712,2713,2714], 'full_width' => 0], + ['acf_fc_layout' => 'wysiwyg', 'heading' => '', 'anchor_id' => 'kurzy-deti', 'has_background' => 0, 'content' => $deti], + ['acf_fc_layout' => 'wysiwyg', 'heading' => '', 'anchor_id' => 'kurzy-dospeli', 'has_background' => 0, 'content' => $dospeli], + ['acf_fc_layout' => 'wysiwyg', 'heading' => '', 'anchor_id' => 'priměstske-tabory', 'has_background' => 0, 'content' => $tabory], + ['acf_fc_layout' => 'banner', 'image' => 2694, 'pin_to_top' => 0], + ['acf_fc_layout' => 'gallery', 'images' => [1985,1984,1983,1982,1981,1980,1979], 'full_width' => 0], + ['acf_fc_layout' => 'wysiwyg', 'heading' => '', 'anchor_id' => 'lektorske-programy', 'has_background' => 0, 'content' => $lektorske], + ['acf_fc_layout' => 'courses', 'heading' => '', 'anchor_id' => 'kurzy', 'has_background' => 0, 'courses' => [642,640,657,1820,1826,1941,1699]], + ['acf_fc_layout' => 'wysiwyg', 'heading' => '', 'anchor_id' => 'kreativ-box', 'has_background' => 0, 'content' => $box_intro], + ['acf_fc_layout' => 'banner', 'image' => 2680, 'pin_to_top' => 0], + ['acf_fc_layout' => 'wysiwyg', 'heading' => '', 'anchor_id' => '', 'has_background' => 0, 'content' => $kyanotypie], + ['acf_fc_layout' => 'banner', 'image' => 2662, 'pin_to_top' => 0], + ['acf_fc_layout' => 'gallery', 'images' => [2655,2656,2657,2658,2659,2660,2661], 'full_width' => 0], + ['acf_fc_layout' => 'wysiwyg', 'heading' => '', 'anchor_id' => '', 'has_background' => 0, 'content' => $rezotisk], + ['acf_fc_layout' => 'banner', 'image' => 2663, 'pin_to_top' => 0], + ['acf_fc_layout' => 'gallery', 'images' => [2664,2665,2666,2667,2668,2669,2670], 'full_width' => 0], + ['acf_fc_layout' => 'wysiwyg', 'heading' => '', 'anchor_id' => '', 'has_background' => 0, 'content' => $vylet], + ['acf_fc_layout' => 'banner', 'image' => 2671, 'pin_to_top' => 0], + ['acf_fc_layout' => 'gallery', 'images' => [2672,2673,2674,2675,2676,2677,2678,2679], 'full_width' => 0], + ['acf_fc_layout' => 'wysiwyg', 'heading' => '', 'anchor_id' => 'workshopy', 'has_background' => 0, 'content' => $workshopy], + ['acf_fc_layout' => 'wysiwyg', 'heading' => '', 'anchor_id' => '', 'has_background' => 0, 'content' => $lektori_intro], + ['acf_fc_layout' => 'people', 'people' => [2953,2954,2955,2956]], + ['acf_fc_layout' => 'wysiwyg', 'heading' => '', 'anchor_id' => 'prihlaska', 'has_background' => 0, 'content' => $prihlaska], + ['acf_fc_layout' => 'downloads', 'heading' => '', 'anchor_id' => '', 'has_background' => 0, 'items' => [ + ['title' => 'Přihláška Kreativ Bubec', 'file' => 1677], + ]], + ['acf_fc_layout' => 'program', 'heading' => 'Nadcházející akce', 'anchor_id' => 'nadchazejici-akce', 'has_background' => 0, + 'category' => 17, 'sort_order' => 'asc', 'upcoming_only' => 1, 'count' => 0, + 'all_events_link' => ['url' => '/program/kreativ/', 'title' => 'Všechny akce', 'target' => '']], +]; + +$ok = update_field('sections', $rows, $PAGE); +WP_CLI::success('sections updated (' . count($rows) . ' rows); update_field returned: ' . var_export($ok, true)); +``` + +- [ ] **Step 2: Run the script** + +```bash +cd /Users/mp/dev/bubec-wordpress +docker compose run --rm php wp eval-file www/migrate/kreativ-rebuild.php --allow-root 2>/dev/null | grep -vE 'WP admin|Username|Email|User found|Done' +``` + +Expected: `Success: sections updated (27 rows); ...`. + +- [ ] **Step 3: Verify the sections layout order via WP-CLI** + +```bash +docker compose run --rm php wp eval 'echo implode(",", (array) get_field("sections", 99) ? array_map(fn($r)=>$r["acf_fc_layout"], get_field("sections",99)) : []);' --allow-root 2>/dev/null | grep -vE 'WP admin|Username|Email|User found|Done' +``` + +Expected: `banner,wysiwyg,gallery,wysiwyg,wysiwyg,wysiwyg,banner,gallery,wysiwyg,courses,wysiwyg,banner,wysiwyg,banner,gallery,wysiwyg,banner,gallery,wysiwyg,banner,gallery,wysiwyg,wysiwyg,people,wysiwyg,downloads,program` + +- [ ] **Step 4: Verify via GraphQL that the page resolves the new sections (courses + program)** + +```bash +curl -s -X POST http://localhost:8888/wordpress/graphql \ + -H 'Content-Type: application/json' \ + -d '{"query":"{ pages(where:{name:\"kreativ-bubec\"},first:1){ nodes { pageSections { sections { __typename ... on PageSectionsSectionsCoursesLayout { courses { nodes { ... on Course { slug } } } } ... on PageSectionsSectionsProgramLayout { heading category { nodes { slug } } } } } } } }"}' | head -c 1200 +``` + +Expected: JSON listing the section `__typename`s including `PageSectionsSectionsCoursesLayout` (with 7 course slugs) and `PageSectionsSectionsProgramLayout` (heading "Nadcházející akce", category slug `creative`). + +- [ ] **Step 5: Stage (do NOT commit)** + +```bash +cd /Users/mp/dev/bubec-wordpress +git add www/migrate/kreativ-rebuild.php +git status --short +``` + +--- + +## Task 4: End-to-end verification against production + +**Files:** none (verification only) + +- [ ] **Step 1: Confirm no referenced media is missing** + +```bash +cd /Users/mp/dev/bubec-wordpress +docker compose run --rm php wp eval ' +$ids=[2710,2711,2712,2713,2714,2694,1985,1984,1983,1982,1981,1980,1979,2680,2662,2655,2656,2657,2658,2659,2660,2661,2663,2664,2665,2666,2667,2668,2669,2670,2671,2672,2673,2674,2675,2676,2677,2678,2679,636,1677]; +$miss=array_filter($ids, fn($i)=>get_post($i)===null || get_post($i)->post_type!=="attachment"); +echo empty($miss)?"ALL PRESENT\n":("MISSING: ".implode(",",$miss)."\n");' --allow-root 2>/dev/null | grep -vE 'WP admin|Username|Email|User found|Done' +``` + +Expected: `ALL PRESENT`. If anything is missing, download it from the production URL (`https://www.bubec.cz/wp-content/uploads/...`) and import with `wp media import`, then update the ID in the script and re-run Task 3. + +- [ ] **Step 2: Start the frontend and load the page** + +```bash +cd /Users/mp/dev/bubec-nuxt +yarn dev +``` + +Then open `http://localhost:3000/kreativ-bubec/`. + +- [ ] **Step 3: Manual fidelity check (use the `verify` skill)** + +Confirm, top to bottom, against `https://www.bubec.cz/kreativ-bubec/`: +- Hero banner shows the correct image (screenshot 2710), NOT the box image. +- Hero gallery has 5 images (not 35). +- Kurzy děti shows the "Přihláška ZDE" Google-Drive link. +- Příměstské tábory shows the tábory banner + 7-image gallery + price links. +- Lektorské programy is followed by the **7-course carousel** (Kyanotypie a experiment, (O)tisk hrou, Díky smyslům poznám smysl, Umění město mění, Umění je taky příroda, Grafika a tisk, Od kresby po animaci), each linking to `/kurz//`, and shows the EU-funding logo. +- Kreativ box section renders Kyanotypie / Rezotisk / Výlet v kapse, each with its banner + gallery. +- Lektorky a lektoři shows the 4 instructor photos. +- Přihláška shows the text + the `.docx` download. +- Nadcházející akce shows the upcoming Creative events (Land Art, Skate a Street Art) with an "Všechny akce" link. +- Submenu anchors (Kurzy děti, Kurzy dospělí, Příměstské tábory, Lektorské programy, Workshopy, Nadcházející akce) scroll to the right sections. + +- [ ] **Step 4: Report the result** + +Summarize what renders correctly and any discrepancies. If discrepancies are content-level, adjust `www/migrate/kreativ-rebuild.php` and re-run Task 3, Step 2. If rendering-level, revisit Task 2. Then hand back to the user for commit. + +--- + +## Self-Review notes + +- **Spec coverage:** Part A → Task 1; Part B → Task 2; Part C (27-row rebuild) → Task 3; Part D (media check) → Task 4 Step 1; Part E (verify) → Task 4. Decisions #1 (omit `[109,750]`), #2 (title-only thumbs), #3 (program category `creative`) are all reflected. +- **Type consistency:** `CourseThumb` item shape `{ slug, title, excerpt }` is identical across the component, its test, `Courses.vue`, the `[slug].vue` normalizer, and the GraphQL fragment. +- **No auto-commit:** every "Stage" step uses `git add` only, per the Global Constraints. +``` diff --git a/docs/superpowers/plans/2026-07-08-program-filter-url-sync.md b/docs/superpowers/plans/2026-07-08-program-filter-url-sync.md new file mode 100644 index 0000000..090ff77 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-program-filter-url-sync.md @@ -0,0 +1,541 @@ +# Program Filter URL Sync + Category Redirect Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Sync `/program`'s category/year/upcoming/view filters to the URL query string (readable and restorable), and turn the legacy `/program/:category` route into a 301 redirect to the new filtered URL instead of rendering its own page. + +**Architecture:** Two pure, framework-free helper functions (`programFiltersToQuery`/`programFiltersFromQuery`) in `utils/program.ts` handle all state↔query-string mapping and are unit-tested in isolation. `pages/program/index.vue` wires them to `route`/`router` via two `watch()` calls. `pages/program/[slug].vue` is rewritten to fetch just the category list, resolve the URL's slug against it, and `navigateTo` a 301 redirect (throwing a 404 for unknown slugs, unchanged from today). + +**Tech Stack:** Nuxt 4 / Vue 3 Composition API, Vue Router (via Nuxt's auto-imported `useRoute`/`useRouter`), `@nuxtjs/i18n` v10 (`useLocalePath`), Vitest (unit tests), Playwright (e2e). + +## Global Constraints + +- Package manager is Yarn 4 — use `yarn`, not npm/pnpm. +- Prefer `@/` for source-root imports. +- Colocate `.graphql` files with their primary consumer, same base name, Czech (canonical) slug directory; run `yarn codegen` after adding/editing a `.graphql` operation. +- **Never `git commit` automatically.** Stage changes (`git add` named files) at the end of each task and stop — do not run `git commit`. The user commits explicitly per their own workflow. +- `utils/program.ts` is deliberately framework-free (no Vue/Vue Router imports) so its logic is unit-testable in isolation — match this existing convention for any additions. + +--- + +## Task 1: `programFiltersToQuery` / `programFiltersFromQuery` helpers + +**Files:** +- Modify: `utils/program.ts` +- Test: `tests/unit/program.spec.ts` + +**Interfaces:** +- Produces (consumed by Task 2): + - `interface ProgramFilterState { categoryIds: readonly number[]; years: readonly string[]; upcomingOnly: boolean; viewMode: "calendar" | "list"; }` + - `interface ProgramCategoryRef { readonly id: number; readonly slug: string; }` (structurally satisfied by the existing `WpProgramCategory` from `@/models`, so callers can pass `WpProgramCategory[]` directly) + - `function programFiltersToQuery(filters: ProgramFilterState, categories: readonly ProgramCategoryRef[]): Record` + - `function programFiltersFromQuery(query: Record, categories: readonly ProgramCategoryRef[]): ProgramFilterState` + +- [ ] **Step 1: Write the failing tests** + +Add this import line alongside the existing one at the top of `tests/unit/program.spec.ts` (do not remove the existing `selectProgramEvents`/`RawProgramEvent` import): + +```ts +import { programFiltersToQuery, programFiltersFromQuery } from "@/utils/program"; +import type { ProgramFilterState, ProgramCategoryRef } from "@/utils/program"; +``` + +Append this `describe` block at the end of the file, before the final closing of the file (i.e. after the existing `describe("selectProgramEvents", ...)` block): + +```ts +describe("programFiltersToQuery / programFiltersFromQuery", () => { + const categories: ProgramCategoryRef[] = [ + { id: 19, slug: "vystava" }, + { id: 71, slug: "koncert" }, + ]; + const defaults: ProgramFilterState = { + categoryIds: [], + years: [], + upcomingOnly: true, + viewMode: "calendar", + }; + + it("toQuery: default state produces an empty query object", () => { + expect(programFiltersToQuery(defaults, categories)).toEqual({}); + }); + + it("toQuery: maps category ids to slugs, comma-joined", () => { + const query = programFiltersToQuery({ ...defaults, categoryIds: [71, 19] }, categories); + expect(query.category).toBe("koncert,vystava"); + }); + + it("toQuery: comma-joins years", () => { + const query = programFiltersToQuery({ ...defaults, years: ["2025", "2026"] }, categories); + expect(query.year).toBe("2025,2026"); + }); + + it("toQuery: upcomingOnly=false writes upcoming=0; true omits the key", () => { + expect(programFiltersToQuery({ ...defaults, upcomingOnly: false }, categories).upcoming).toBe("0"); + expect(programFiltersToQuery(defaults, categories).upcoming).toBeUndefined(); + }); + + it("toQuery: viewMode='list' writes view=list; 'calendar' omits the key", () => { + expect(programFiltersToQuery({ ...defaults, viewMode: "list" }, categories).view).toBe("list"); + expect(programFiltersToQuery(defaults, categories).view).toBeUndefined(); + }); + + it("toQuery: drops category ids with no matching category", () => { + const query = programFiltersToQuery({ ...defaults, categoryIds: [999] }, categories); + expect(query.category).toBeUndefined(); + }); + + it("fromQuery: empty query parses to defaults", () => { + expect(programFiltersFromQuery({}, categories)).toEqual(defaults); + }); + + it("fromQuery: maps category slugs to ids, drops unknown slugs", () => { + const result = programFiltersFromQuery({ category: "koncert,unknown,vystava" }, categories); + expect(result.categoryIds).toEqual([71, 19]); + }); + + it("fromQuery: splits years on commas", () => { + expect(programFiltersFromQuery({ year: "2025,2026" }, categories).years).toEqual(["2025", "2026"]); + }); + + it("fromQuery: upcoming=0 means false; any other value or absence means true", () => { + expect(programFiltersFromQuery({ upcoming: "0" }, categories).upcomingOnly).toBe(false); + expect(programFiltersFromQuery({ upcoming: "1" }, categories).upcomingOnly).toBe(true); + expect(programFiltersFromQuery({}, categories).upcomingOnly).toBe(true); + }); + + it("fromQuery: view=list means list; any other value or absence means calendar", () => { + expect(programFiltersFromQuery({ view: "list" }, categories).viewMode).toBe("list"); + expect(programFiltersFromQuery({ view: "bogus" }, categories).viewMode).toBe("calendar"); + expect(programFiltersFromQuery({}, categories).viewMode).toBe("calendar"); + }); + + it("fromQuery: normalizes a repeated-key array query value by taking the first", () => { + const result = programFiltersFromQuery({ category: ["koncert", "vystava"] }, categories); + expect(result.categoryIds).toEqual([71]); + }); + + it("round-trips a non-default state through toQuery -> fromQuery", () => { + const state: ProgramFilterState = { + categoryIds: [71], + years: ["2026"], + upcomingOnly: false, + viewMode: "list", + }; + const query = programFiltersToQuery(state, categories); + expect(programFiltersFromQuery(query, categories)).toEqual(state); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `yarn vitest run tests/unit/program.spec.ts` +Expected: FAIL — `programFiltersToQuery`/`programFiltersFromQuery` are not exported from `@/utils/program` (import error / undefined is not a function). + +- [ ] **Step 3: Implement the helpers** + +Append to `utils/program.ts` (after the existing `selectProgramEvents` function, at the end of the file): + +```ts +/** Filter state for the /program URL sync — independent of any UI framework. */ +export interface ProgramFilterState { + categoryIds: readonly number[]; + years: readonly string[]; + upcomingOnly: boolean; + viewMode: "calendar" | "list"; +} + +/** Minimal category shape the URL-filter helpers need to map ids <-> slugs. */ +export interface ProgramCategoryRef { + readonly id: number; + readonly slug: string; +} + +/** A query object value as vue-router's `route.query` returns it, or a plain string map. */ +type QueryValue = string | (string | null)[] | null | undefined; + +function firstQueryValue(value: QueryValue): string | undefined { + const v = Array.isArray(value) ? value[0] : value; + return v ?? undefined; +} + +/** + * Serializes program filter state to URL query params for `router.push({ query })`. + * Categories/years are identified by slug/value, not database id, so links are + * readable and stable. Values equal to the default are omitted entirely, so the + * unfiltered page has no query string at all. + */ +export function programFiltersToQuery( + { categoryIds, years, upcomingOnly, viewMode }: ProgramFilterState, + categories: readonly ProgramCategoryRef[], +): Record { + const query: Record = {}; + + const categorySlugs = categoryIds + .map((id) => categories.find((c) => c.id === id)?.slug) + .filter((slug): slug is string => Boolean(slug)); + if (categorySlugs.length > 0) query.category = categorySlugs.join(","); + + if (years.length > 0) query.year = years.join(","); + + if (!upcomingOnly) query.upcoming = "0"; + + if (viewMode === "list") query.view = "list"; + + return query; +} + +/** + * Parses URL query params back into program filter state — the inverse of + * `programFiltersToQuery`. Unknown category slugs are dropped; missing or + * unrecognized values fall back to the defaults (upcomingOnly: true, + * viewMode: "calendar"). + */ +export function programFiltersFromQuery( + query: Record, + categories: readonly ProgramCategoryRef[], +): ProgramFilterState { + const categoryParam = firstQueryValue(query.category); + const categoryIds = categoryParam + ? categoryParam + .split(",") + .map((slug) => categories.find((c) => c.slug === slug)?.id) + .filter((id): id is number => id !== undefined) + : []; + + const yearParam = firstQueryValue(query.year); + const years = yearParam ? yearParam.split(",").filter(Boolean) : []; + + const upcomingOnly = firstQueryValue(query.upcoming) !== "0"; + const viewMode = firstQueryValue(query.view) === "list" ? "list" : "calendar"; + + return { categoryIds, years, upcomingOnly, viewMode }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `yarn vitest run tests/unit/program.spec.ts` +Expected: PASS — all tests including the new `describe("programFiltersToQuery / programFiltersFromQuery", ...)` block. + +- [ ] **Step 5: Lint** + +Run: `yarn eslint utils/program.ts tests/unit/program.spec.ts` +Expected: no errors. + +- [ ] **Step 6: Stage the changes** + +```bash +git add utils/program.ts tests/unit/program.spec.ts +``` + +Do not commit — stop here for review (see Global Constraints). + +--- + +## Task 2: Wire URL sync into `pages/program/index.vue` + +**Files:** +- Modify: `pages/program/index.vue` + +**Interfaces:** +- Consumes: `programFiltersToQuery`, `programFiltersFromQuery`, `ProgramFilterState` from `@/utils/program` (Task 1). + +- [ ] **Step 1: Replace the file's ` +``` + +- [ ] **Step 2: Manually verify in the dev server** + +Run: `yarn dev` (leave it running), then in a browser: + +1. Go to `http://localhost:3000/program/`. The URL should have no query string. +2. Select a category and a year, toggle "Pouze aktuální" off, switch to list view. + Expected: the URL updates to something like + `http://localhost:3000/program/?category=&year=&upcoming=0&view=list` + after each individual change (4 separate updates, not batched). +3. Copy that URL, open it in a new tab. + Expected: the filter UI is pre-populated to match (category/year selected, "Pouze aktuální" off, list view active). +4. Click the browser Back button repeatedly. + Expected: the filter UI steps backward through each prior change (view mode, then upcoming, then year, then category), ending back at `/program/` with no query string and default filters. + +- [ ] **Step 3: Typecheck and lint** + +Run: `yarn typecheck && yarn eslint pages/program/index.vue` +Expected: no errors. + +- [ ] **Step 4: Stage the changes** + +```bash +git add pages/program/index.vue +``` + +Do not commit — stop here for review. + +--- + +## Task 3: `/program/:category` → 301 redirect + +**Files:** +- Create: `pages/program/[slug].graphql` +- Modify: `pages/program/[slug].vue` +- Modify: `tests/e2e/routes.spec.ts` + +**Interfaces:** +- Consumes: `gqlFetch` from `@/utils/graphql` (existing), `WpProgramCategory` from `@/models` (existing), `useLocalePath` (Nuxt/i18n auto-import). +- Produces: nothing consumed by other tasks — this task is self-contained. + +- [ ] **Step 1: Create the slim colocated GraphQL query** + +Create `pages/program/[slug].graphql`: + +```graphql +query ProgramCategories { + eventTypes(first: 100) { + nodes { databaseId name slug } + } +} +``` + +This replaces this page's previous reuse of `pages/program/index.graphql` (which also fetches up to 500 full event records this page no longer needs, now that it only resolves a category slug and redirects). + +- [ ] **Step 2: Rewrite `pages/program/[slug].vue`** + +Replace the entire contents of `pages/program/[slug].vue` with: + +```vue + +``` + +No `