diff --git a/.changeset/amplitude-plugin-descriptions.md b/.changeset/amplitude-plugin-descriptions.md deleted file mode 100644 index 7c44e6bec..000000000 --- a/.changeset/amplitude-plugin-descriptions.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/web-destination-amplitude': patch ---- - -The `sessionReplay`, `experiment` and `engagement` descriptions now say that -`@amplitude/unified` sets up all three plugins on every init and that these -settings only pass options to them, and how Session Replay sampling is decided. -The step examples name the destination config they rely on, and the login -example uses a millisecond timestamp. diff --git a/.changeset/clarity-hotjar-step-example-settings.md b/.changeset/clarity-hotjar-step-example-settings.md deleted file mode 100644 index aa77c0e30..000000000 --- a/.changeset/clarity-hotjar-step-example-settings.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@walkeros/web-destination-clarity': patch -'@walkeros/web-destination-hotjar': patch ---- - -Step examples that run with destination settings now name them in their -description (`settings.identify`, `settings.consent`), because the docs show -only an example's event, mapping and output, not the settings it runs with. diff --git a/.changeset/core-thenable-cache-wildcard-once.md b/.changeset/core-thenable-cache-wildcard-once.md deleted file mode 100644 index 1cd1905a9..000000000 --- a/.changeset/core-thenable-cache-wildcard-once.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@walkeros/core': patch ---- - -A failed async cache write no longer escapes as an unhandled rejection on pages -that replace the global `Promise`, and a `* *` mapping rule's condition runs -once per event instead of twice. A transformer with `code` or `package` that -declares both `config.mapping` and `mapping` now gets a warning naming both. diff --git a/.changeset/destination-demo-step-examples.md b/.changeset/destination-demo-step-examples.md deleted file mode 100644 index 17c8d18b0..000000000 --- a/.changeset/destination-demo-step-examples.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@walkeros/destination-demo': patch ---- - -The demo destination's step examples now show the call it really makes: one -`log` call with the `[demo]` prefix and the event as indented JSON, instead of -the event object. The package's docs link now points to the destinations -overview instead of a page that does not exist. diff --git a/.changeset/flow-complete-warehouse-segment.md b/.changeset/flow-complete-warehouse-segment.md deleted file mode 100644 index 9825664ed..000000000 --- a/.changeset/flow-complete-warehouse-segment.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@walkeros/cli': patch ---- - -The warehouse examples of `flow-complete.json` (Pub/Sub, the warehouse source -and the BigQuery row) now carry the customer `segment` next to `ltv`, as the -events upstream do. diff --git a/.changeset/frames-belong-to-a-flow.md b/.changeset/frames-belong-to-a-flow.md deleted file mode 100644 index 1250bb514..000000000 --- a/.changeset/frames-belong-to-a-flow.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@walkeros/cli': patch -'@walkeros/mcp': patch ---- - -Frames belong to a flow: `frame_manage` (list, page and get) and the CLI frame -reads now take the `flowId` of the flow they live in. Deleting a flow also -deletes its frames. - -A frame read also returns `lastSavedVersion`, `lastSavedNumber` and -`markupAssetId`, so a client can tell whether a frame has changed since it was -last saved as a version. diff --git a/.changeset/fullstory-capture-descriptions.md b/.changeset/fullstory-capture-descriptions.md deleted file mode 100644 index 96087ca5c..000000000 --- a/.changeset/fullstory-capture-descriptions.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@walkeros/web-destination-fullstory': patch ---- - -The `startCaptureManually` description now says that the FullStory script still -loads at init and that recording starts once a consent key mapped to `capture` -is granted. The step examples name the consent mapping they rely on. diff --git a/.changeset/gtag-script-src-schema.md b/.changeset/gtag-script-src-schema.md deleted file mode 100644 index 11434362d..000000000 --- a/.changeset/gtag-script-src-schema.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@walkeros/web-destination-gtag': patch ---- - -The settings schema now lists the GA4 settings `scriptSrc` and `init`, so -`walkeros validate` no longer warns about them as unknown properties. diff --git a/.changeset/heap-settings-descriptions.md b/.changeset/heap-settings-descriptions.md deleted file mode 100644 index 3fdfae375..000000000 --- a/.changeset/heap-settings-descriptions.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/web-destination-heap': patch ---- - -The `disablePageviewAutocapture`, `disableSessionReplay` and `ingestServer` -descriptions now say that the classic Heap SDK loaded by `loadScript` ignores -them. The settings schema lists `heapConfig` for further `heap.load()` options, -and the step examples name the destination config they rely on and the consent -methods the classic SDK lacks. diff --git a/.changeset/matomo-ecommerce-examples.md b/.changeset/matomo-ecommerce-examples.md deleted file mode 100644 index adbe6a054..000000000 --- a/.changeset/matomo-ecommerce-examples.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/web-destination-matomo': patch ---- - -The ecommerce step examples are no longer published in the docs, and their -descriptions say they are test fixtures. Matomo needs an `addEcommerceItem` call -per product before a cart update, and before an order to record its products, -and tracks a product view as `setEcommerceView` followed by a page view. One -rule sends one command. diff --git a/.changeset/mixpanel-group-description.md b/.changeset/mixpanel-group-description.md deleted file mode 100644 index 2ff8f2217..000000000 --- a/.changeset/mixpanel-group-description.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@walkeros/web-destination-mixpanel': patch ---- - -The `group` setting description now says it is resolved and sent with -`set_group` on every push, not at init. The step examples name the destination -config they rely on, the login example uses a millisecond timestamp, and a new -example covers the destination-level group. diff --git a/.changeset/piwikpro-identified-consent-description.md b/.changeset/piwikpro-identified-consent-description.md deleted file mode 100644 index a8a9aff94..000000000 --- a/.changeset/piwikpro-identified-consent-description.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/web-destination-piwikpro': patch -'@walkeros/server-destination-piwikpro': patch ---- - -The `identified` description and hint now explain why its consent states stay -out of `config.consent`: a destination needs only one granted state there, so a -repeated state lets events through on its own, and as the only state it holds -events back, so no anonymous hit is sent. diff --git a/.changeset/plausible-custom-event-example.md b/.changeset/plausible-custom-event-example.md deleted file mode 100644 index 7277edf59..000000000 --- a/.changeset/plausible-custom-event-example.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@walkeros/web-destination-plausible': patch ---- - -The custom event step example now sends scalar `props` only. Plausible accepts -no arrays or objects as prop values and expects `revenue` as -`{ currency, amount }`, as the purchase example shows. diff --git a/.changeset/posthog-project-default-descriptions.md b/.changeset/posthog-project-default-descriptions.md deleted file mode 100644 index 9a31b56fb..000000000 --- a/.changeset/posthog-project-default-descriptions.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@walkeros/web-destination-posthog': patch ---- - -The `capture_heatmaps` and `capture_exceptions` descriptions now say that, when -unset, the PostHog project setting decides. The `identify` and `group` -descriptions say when the calls repeat. The step examples name the destination -config they rely on, and the login example uses a millisecond timestamp. diff --git a/.changeset/runner-release-check.md b/.changeset/runner-release-check.md deleted file mode 100644 index 281d455a5..000000000 --- a/.changeset/runner-release-check.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@walkeros/runner': patch ---- - -The runner refuses to start when the loaded flow does not match -`WALKEROS_FLOW_RELEASE` (and `WALKEROS_FLOW_NAME`, when set), so a container -never serves an artifact other than the one it was deployed with. Every start -logs the loaded flow's name and release. diff --git a/.changeset/segment-consent-cdn-descriptions.md b/.changeset/segment-consent-cdn-descriptions.md deleted file mode 100644 index 455402863..000000000 --- a/.changeset/segment-consent-cdn-descriptions.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@walkeros/web-destination-segment': patch ---- - -The `consent` setting description now says that only mapped keys are forwarded -and that no consent context is sent without it. The `cdnURL` description says -the setting is not applied, the `identify` description says identify repeats on -every push while `anonymousId` is set, and the step examples name the config -they rely on. diff --git a/.changeset/step-example-product-conditions.md b/.changeset/step-example-product-conditions.md deleted file mode 100644 index e9fd39f00..000000000 --- a/.changeset/step-example-product-conditions.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@walkeros/web-destination-matomo': patch -'@walkeros/web-destination-piwikpro': patch -'@walkeros/server-destination-piwikpro': patch ---- - -The ecommerce step examples now test for a product entity without a package -helper. The published condition, shown in the docs and returned by -`package_get`, referenced a minified import and threw when copied into a flow; -it now runs on its own. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8650c50d..b563bdd53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -181,29 +181,16 @@ jobs: if: inputs.images_only == '' id: changesets run: | - CHANGES="" PACKAGES="" - RELEASE_NEEDED="false" for file in .changeset/*.md; do if [ -f "$file" ] && [ "$(basename $file)" != "README.md" ]; then - # Extract package names from frontmatter - PKGS=$(sed -n '/^---$/,/^---$/p' "$file" | grep -oE "'@?walkeros[^']*'" | tr -d "'" | sort -u) + # Extract package names from frontmatter, in single or double quotes + PKGS=$(sed -n '/^---$/,/^---$/p' "$file" | grep -oE "[\"']@?walkeros[^\"']*[\"']" | tr -d "\"'" | sort -u) for pkg in $PKGS; do # Add to packages list (dedupe later) PACKAGES="$PACKAGES$pkg\n" done - - # Check if core or collector triggers release - if echo "$PKGS" | grep -qE "@walkeros/(core|collector)$"; then - RELEASE_NEEDED="true" - fi - - # Capture description (text after frontmatter) - CONTENT=$(sed -n '/^---$/,/^---$/d; p' "$file" | sed '/^$/d') - if [ -n "$CONTENT" ]; then - CHANGES="$CHANGES$CONTENT\n\n" - fi fi done @@ -221,13 +208,9 @@ jobs: echo "No changesets found: recovery re-dispatch, nothing new to version." fi - echo "changes<> $GITHUB_OUTPUT - echo -e "$CHANGES" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT echo "packages<> $GITHUB_OUTPUT echo "$PACKAGES_FORMATTED" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - echo "release_needed=$RELEASE_NEEDED" >> $GITHUB_OUTPUT - name: Configure Git if: inputs.images_only == '' @@ -348,49 +331,35 @@ jobs: npx changeset publish fi + # Every stable release gets a GitHub release at its version commit (the + # published tree), never at a later branch tip. The notes come from the + # changesets that commit consumed (scripts/release-notes.sh, also used to + # create a missed release by hand). A re-dispatch whose release exists is + # a no-op; one whose HEAD is not the version commit fails. - name: Create GitHub Release - if: | - inputs.images_only == '' && - inputs.release_type == 'stable release' && - steps.changesets.outputs.release_needed == 'true' + if: inputs.images_only == '' && inputs.release_type == 'stable release' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Changeset-authored text is read from the environment, never spliced - # into the script body, where it could break out into the shell. - CHANGES: ${{ steps.changesets.outputs.changes }} - PACKAGES: ${{ steps.changesets.outputs.packages }} run: | VERSION="v$(node -p "require('./packages/collector/package.json').version")" - # Tag the pushed version commit (the published tree), never the default branch tip. - TARGET="$(git rev-parse HEAD)" - - printf '## Changes\n\n%s\n\n## Published Packages\n\n%s\n' \ - "$CHANGES" "$PACKAGES" > /tmp/release-notes.md - - # Idempotent: a re-dispatch that recovers a partial release must not - # die here just because the tag already landed. if gh release view "$VERSION" >/dev/null 2>&1; then - echo "Release $VERSION already exists, refreshing notes" - if git fetch --quiet origin "refs/tags/$VERSION:refs/tags/$VERSION" && TAGGED="$(git rev-list -n1 "$VERSION")"; then - if [ "$TAGGED" != "$TARGET" ]; then - echo "::warning::Tag $VERSION is at $TAGGED, not at the version commit $TARGET. It is not moved; retag by hand if needed." - fi - else - echo "::warning::Could not read tag $VERSION to check it against the version commit $TARGET." - fi - gh release edit "$VERSION" --notes-file /tmp/release-notes.md - else - gh release create "$VERSION" \ - --target "$TARGET" \ - --title "walkerOS $VERSION" \ - --notes-file /tmp/release-notes.md + echo "::notice::Release $VERSION already exists; nothing to release." + exit 0 + fi + PREVIOUS="$(git show HEAD~1:packages/collector/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")" + if [ "$VERSION" = "v$PREVIOUS" ]; then + echo "::error::HEAD is not the version commit of $VERSION. Create the release by hand at that commit (scripts/release-notes.sh )." + exit 1 fi + bash scripts/release-notes.sh HEAD > /tmp/release-notes.md + gh release create "$VERSION" --target "$(git rev-parse HEAD)" --title "walkerOS $VERSION" --notes-file /tmp/release-notes.md - name: Comment on PR if: inputs.images_only == '' && steps.changesets.outputs.packages != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # As with the release notes: changeset-authored text stays in env. + # Changeset-authored text is read from the environment, never spliced + # into the script body, where it could break out into the shell. PACKAGES: ${{ steps.changesets.outputs.packages }} RELEASE_TYPE: ${{ inputs.release_type }} run: | diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 43c9e915e..07ac6310b 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -5,11 +5,13 @@ on: branches: [main] paths: - 'website/**' + - 'apps/demos/storybook/**' - '.github/workflows/website.yml' pull_request: types: [opened, synchronize, reopened, closed] paths: - 'website/**' + - 'apps/demos/storybook/**' - '.github/workflows/website.yml' workflow_dispatch: inputs: diff --git a/.prettierignore b/.prettierignore index 22be2a42c..b6fc38b28 100644 --- a/.prettierignore +++ b/.prettierignore @@ -32,4 +32,11 @@ logs/ # MDX docs are not formatted by any tool: both prettier and remark rewrite the # `code={`...`}` template literals that carry the documentation snippets. -website/docs/**/*.mdx \ No newline at end of file +website/docs/**/*.mdx + +# walkerOS design system: design/ mirrors the Claude Design artifact file for +# file; src/design/index.ts and src/design/preview-css.ts are generated by +# src/design/generate.ts. +apps/explorer/design/ +apps/explorer/src/design/index.ts +apps/explorer/src/design/preview-css.ts diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 5202f6f40..38a741cca 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,13 @@ # walkeros +## 4.8.0 + +### Patch Changes + +- Updated dependencies [5593d7b] +- Updated dependencies [5593d7b] + - @walkeros/cli@4.8.0 + ## 4.7.2 ### Patch Changes diff --git a/apps/cli/package.json b/apps/cli/package.json index b1f8c96a6..d5a7d247e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "walkeros", - "version": "4.7.2", + "version": "4.8.0", "description": "walkerOS CLI - Bundle and deploy walkerOS components", "license": "MIT", "type": "module", @@ -18,7 +18,7 @@ "clean": "rm -rf .turbo && rm -rf dist" }, "dependencies": { - "@walkeros/cli": "4.7.2" + "@walkeros/cli": "4.8.0" }, "devDependencies": { "tsup": "^8.5.1", diff --git a/apps/demos/README.md b/apps/demos/README.md index 29a881491..fe2b47112 100644 --- a/apps/demos/README.md +++ b/apps/demos/README.md @@ -1,4 +1,11 @@ # walkerOS demos - [React](./react/) -- [Tagging Website](./tagging/) +- [Storybook](./storybook/): the shared demo kit and the demos built from it, + with the walkerOS Storybook addon + - Shop (`storybook/src/demos/shop`): the static tagging demo as a one-page + shop + - Media (`storybook/src/demos/media`): a one-page streaming site + - The demo pages at walkeros.io/demos/ (`storybook/src/site`), built from the + same package and copied into the website build +- [Tagging Website](./tagging/): the static tagging demo diff --git a/apps/demos/react/CHANGELOG.md b/apps/demos/react/CHANGELOG.md index c7ff2654f..99f9f909d 100644 --- a/apps/demos/react/CHANGELOG.md +++ b/apps/demos/react/CHANGELOG.md @@ -1,5 +1,20 @@ # walkeros-demo-react +## 2.0.25 + +### Patch Changes + +- Updated dependencies [7132619] +- Updated dependencies [5593d7b] +- Updated dependencies [421233d] +- Updated dependencies [5593d7b] + - @walkeros/core@4.8.0 + - @walkeros/web-destination-gtag@4.8.0 + - @walkeros/collector@4.8.0 + - @walkeros/web-core@4.8.0 + - @walkeros/web-destination-api@4.8.0 + - @walkeros/web-source-browser@4.8.0 + ## 2.0.24 ### Patch Changes diff --git a/apps/demos/react/package.json b/apps/demos/react/package.json index 2506deabf..0ab054367 100644 --- a/apps/demos/react/package.json +++ b/apps/demos/react/package.json @@ -1,6 +1,6 @@ { "name": "walkeros-demo-react", - "version": "2.0.24", + "version": "2.0.25", "private": true, "type": "module", "scripts": { @@ -16,12 +16,12 @@ }, "dependencies": { "@remix-run/router": "^1.23.0", - "@walkeros/collector": "4.7.2", - "@walkeros/core": "4.7.2", - "@walkeros/web-core": "4.7.2", - "@walkeros/web-destination-api": "4.7.2", - "@walkeros/web-destination-gtag": "4.7.2", - "@walkeros/web-source-browser": "4.7.2", + "@walkeros/collector": "4.8.0", + "@walkeros/core": "4.8.0", + "@walkeros/web-core": "4.8.0", + "@walkeros/web-destination-api": "4.8.0", + "@walkeros/web-destination-gtag": "4.8.0", + "@walkeros/web-source-browser": "4.8.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-router-dom": "^7.10.1" @@ -34,7 +34,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/config": "4.7.2", + "@walkeros/config": "4.8.0", "autoprefixer": "^10.4.23", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", diff --git a/apps/demos/storybook/.storybook/preview.ts b/apps/demos/storybook/.storybook/preview.tsx similarity index 64% rename from apps/demos/storybook/.storybook/preview.ts rename to apps/demos/storybook/.storybook/preview.tsx index 3c54ad166..90c78921c 100644 --- a/apps/demos/storybook/.storybook/preview.ts +++ b/apps/demos/storybook/.storybook/preview.tsx @@ -1,7 +1,16 @@ -import type { Preview } from '@storybook/react-vite'; +import type { Decorator, Preview } from '@storybook/react-vite'; import '../src/index.css'; +// The theme sits on , as on every walkerOS surface: dark by default, +// light from the toolbar. +const withTheme: Decorator = (Story, context) => { + const theme = context.globals.theme === 'light' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', theme); + return ; +}; + const preview: Preview = { + decorators: [withTheme], parameters: { actions: { argTypesRegex: '^on[A-Z].*' }, controls: { @@ -30,11 +39,11 @@ const preview: Preview = { globalTypes: { theme: { description: 'Global theme for components', - defaultValue: 'light', + defaultValue: 'dark', toolbar: { title: 'Theme', icon: 'circlehollow', - items: ['light', 'dark'], + items: ['dark', 'light'], dynamicTitle: true, }, }, diff --git a/apps/demos/storybook/CHANGELOG.md b/apps/demos/storybook/CHANGELOG.md index 771f60046..9e93ec9d2 100644 --- a/apps/demos/storybook/CHANGELOG.md +++ b/apps/demos/storybook/CHANGELOG.md @@ -1,5 +1,26 @@ # @walkeros/storybook-demo +## 4.8.0 + +### Patch Changes + +- Updated dependencies [7132619] +- Updated dependencies [5593d7b] +- Updated dependencies [421233d] +- Updated dependencies [7132619] +- Updated dependencies [d5cbc9c] +- Updated dependencies [d5cbc9c] +- Updated dependencies [01d86a5] +- Updated dependencies [bf6221f] +- Updated dependencies [d5cbc9c] +- Updated dependencies [7132619] +- Updated dependencies [aa4bb8b] +- Updated dependencies [7132619] +- Updated dependencies [a4b03ba] + - @walkeros/core@4.8.0 + - @walkeros/explorer@4.8.0 + - @walkeros/web-source-browser@4.8.0 + ## 4.7.2 ### Patch Changes diff --git a/apps/demos/storybook/README.md b/apps/demos/storybook/README.md index 02f19abc8..f079aa271 100644 --- a/apps/demos/storybook/README.md +++ b/apps/demos/storybook/README.md @@ -15,12 +15,16 @@ visualize and debug event tracking in React components. ## Quick Start ```bash -# Clone and navigate to the demo +# Clone and install the monorepo git clone https://github.com/elbwalker/walkerOS.git -cd walkerOS/apps/demos/storybook - -# Install dependencies and start +cd walkerOS npm install + +# Build the demo's dependencies, explorer's design CSS among them +npx turbo run build --filter=@walkeros/storybook-demo... + +# Start Storybook +cd apps/demos/storybook npm run storybook ``` @@ -48,7 +52,7 @@ export default { Add tracking to your components using the `dataElb` prop pattern: ```tsx -import { createTrackingProps, type DataElb } from '../utils/tagger'; +import { createTrackingProps, type DataElb } from '../shared/tagger'; interface ButtonProps { label: string; @@ -101,14 +105,146 @@ export const Primary = { 3. Interact with components to see live events 4. Inspect event data structure and validate tracking -## Demo Components +## Demos and the Shared Kit -This demo includes examples of: +Every demo is a one-page example site for an industry, built from one shared +component kit and tagged through the typed tagger in `src/shared/tagger.ts`. The +kit is tagged once and reused everywhere; each demo adds the tagging of its own +parts. + +``` +src/ + shared/ the kit, stories under Shared/* + atoms/ Badge, Button, Heading, Icon, Image, Input, Link, Price, + Select, StarRating, Text + molecules/ ConsentControls, FormField, LanguageToggle, NavLinks, + SocialLinks, StatusMessage, UserSwitch + organisms/ ConsentBar, Footer, Header + templates/ OnePager: header, sections, footer, pinned consent bar + language/ elbish(), toEnglish() and the text function useText() + personas.ts the demo users + consent.ts useDemoConsent(), the demo CMP behind the consent bar + controls.ts DemoControls, what a page needs from whoever runs it + demos/ + shop/ the static tagging demo as a shop (stories under Shop/*) + media/ a streaming site (stories under Media/*) +``` -- **Simple Components**: Basic buttons with tracking -- **Complex Components**: Hero banners with nested data -- **Template Components**: Full page layouts with contextual tracking -- **Different Patterns**: Various approaches to component tracking +- **Shop** (`src/demos/shop`): the [static tagging demo](../tagging/) rebuilt as + components. Promotion, recommendations (with `data-elbobserve` and "Add + product"), product detail, checkout and order complete carry the same tags as + the static page. `Shop/Pages/Shop` shows the whole page. +- **Media** (`src/demos/media`): a streaming site with a hero banner, content + carousels, a promotion banner and "Add row". `Media/Pages/Media` shows the + whole page. + +Every story carries `shop`, `media` or both in its `tags`, so the sidebar's tag +filter shows one demo. [story.md](./story.md) lists every component. + +### Add an Industry Demo + +1. Create `src/demos//` with `data.ts` (its copy, in English, and its + section anchors) and its own molecules and organisms, tagged with + `createTrackingProps`. +2. Build the page from the kit: `OnePager` with the shared `Header` (your + `links`, ``, your `extras`), your + sections (each in a container with the id its anchor points at), the shared + `Footer` and ``. The page takes + `DemoPageProps` and wraps itself in + ``. +3. Show every visible string through `const t = useText()`: copy, labels, `alt`, + `aria-label`. Tag values use the untranslated data. +4. Add a page story that renders the page with `useStoryControls()`, tag every + story with the demo's name, and add a tag parity and a language test like + `src/demos/shop/parity.test.tsx` and `language.test.tsx`. + +### Demo Users + +The header's `UserSwitch` picks one of three demo users. Its root tags the user +as `data-elbuser`, which the browser source reads before the page view: + +| Persona | `?user=` | `data-elbuser` | +| ---------- | -------- | -------------------------------------------------- | +| Anonymous | none | no attribute | +| Lisa Loyal | `lisa` | `id:lisa-loyal;email:lisa@example.com;segment:vip` | +| Sam Sales | `sam` | `id:sam-sales;email:sam@example.com` | + +Sam has no `segment` key on purpose: written as `segment:undefined` it would +arrive as the string "undefined". In a story the switch only re-renders; the +demo site reloads the page with `?user=`. + +### English and Elbish + +The `LanguageToggle` switches the page between English and Elbish, a playful +language made by a fixed letter table: vowels rotate (a to e, e to i and so on), +consonants move one step, case stays, and digits, symbols and other letters pass +unchanged. `toEnglish(elbish(text))` always gives the text back. Only visible +text changes: tag attributes never do, so a static value stays English while a +value read with `#innerText` (the promotion's name) carries the Elbish text. The +toggle sends the page language as the `language` global. Its own labels, the +persona names and system notices are never translated, so the way back and what +went wrong always read. + +### Consent + +The shared consent bar is on every page. Accept sends +`walker user { device: }` with a short random device id (made once, reused +after) and `walker consent { functional: true, marketing: true }`. Deny sends +`walker consent { functional: true, marketing: false }` and +`walker user { device: undefined }`. Reset forgets the choice and sends nothing. +Consent never sets `user.id`: the demo user owns it. A choice replaced while its +first command is on the way sends no second command. A command that does not +reach walkerOS shows as a notice in the bar. In stories the choice lives in +story state; with `persist` (the demo site) the choice and the device id are +kept in `localStorage` (`consentState`, `demoDeviceId`) and applied again on +load, as a CMP does. + +## Demo Site + +The same package builds the demo pages that walkeros.io serves under `demos/`: +one HTML entry per page, no client-side routing, every asset URL relative (Vite +`base: './'`), so the pages work under any path prefix, PR previews included. + +| Path on walkeros.io | Entry | What it is | +| ------------------- | ------------------ | -------------- | +| `/demos/shop/` | `shop/index.html` | the Shop page | +| `/demos/media/` | `media/index.html` | the Media page | + +`/demos/` itself is the website's Demos page, which frames these pages. This +Storybook stays at `storybook.walkeros.io` (`storybook.yml`). + +`src/site/boot.tsx` runs a demo page (`bootDemo`): + +- It defines the `elb` queue of the static tagging demo first, so consent and + user commands made before walker.js runs wait in `window.elbLayer`. +- It reads the demo user from `?user=`; switching the user reloads the page with + the new `?user=`. The language is page state and switches live. +- It loads walker.js (`src/site/walkerjs.ts`, the managed bundle of the app flow + "Tagging demo") after the first render, so `data-elbuser` and every tagged + element are in the DOM when it runs. +- If walker.js does not load, the page says "walkerOS didn't load: this page + sends no events." +- The consent bar remembers its choice in `localStorage`. + +A new demo is one more entry: `/index.html`, `src/site/.tsx` calling +`bootDemo(Page, pageRoot(document))`, one line in `vite.config.ts` and one +in `website/scripts/copy-demos.mjs` (`DEMO_PAGES`). + +## Design System + +Both libraries are styled only from the walkerOS design system, which lives in +`@walkeros/explorer` (a dependency of this package). `src/index.css` imports +`@walkeros/explorer/design/tailwind.css` and +`@walkeros/explorer/design/base.css`, and the Storybook toolbar switches between +the dark (default) and the light theme. `npm run lint` runs +`walkeros-design-check` over `src` and `.storybook`. + +The design CSS is read from explorer's built `dist`. In a fresh clone, build it +before `npm run storybook`, `npm run build-storybook` or `npm run build`: run +`npx turbo run build --filter=@walkeros/storybook-demo...` from the repository +root (or build `apps/explorer`). A job that builds this Storybook does the same. +A standalone `npm install` of this package, outside the monorepo, needs an +`@walkeros/explorer` release that ships the design exports. ## Key Features @@ -122,9 +258,37 @@ This demo includes examples of: ```bash npm run storybook # Start development server npm run build-storybook # Build static version -npm run build # Build demo components +npm run dev # Start the demo pages (/shop/, /media/) +npm run build # Build the demo pages into dist/ +npm run preview # Serve dist/ +npm test # Tag parity, language, personas, consent and boot tests ``` +## Deploy + +The demo pages ship with the website; they have no deploy of their own: + +- `website/turbo.json` makes the website `build` depend on this package's + `build`. +- The website's `build` script runs `scripts/copy-demos.mjs` after + `docusaurus build`. It copies this package's `dist/` (the pages, their assets + and the favicon) into `website/build/demos/`, never over the Demos page at + `demos/index.html`, and fails the build when the demo build is missing. +- `website.yml` also runs on changes under `apps/demos/storybook/`. + +This Storybook deploys as before, through `storybook.yml` to +`storybook.walkeros.io`. + +Before the merge to `main` that brings the Demos page: in the app flow "Tagging +demo", allow the origin `https://www.walkeros.io` next to +`https://tagging.walkeros.io` and redeploy the flow. Its server flow answers +cross-origin requests from `https://tagging.walkeros.io` only, so without this +every event sent from the demo pages after Accept is blocked. + +The static tagging demo at `tagging.walkeros.io` stays: it is the app's preview +sandbox (`DEMO_SITE_URL`). Never add `www.walkeros.io` to the app's preview +allowlist: the "Tagging demo" bundle accepts any project's preview code. + ## Learn More - [walkerOS Documentation](https://www.walkeros.io/docs) diff --git a/apps/demos/storybook/jest.config.mjs b/apps/demos/storybook/jest.config.mjs new file mode 100644 index 000000000..95ab2f1fa --- /dev/null +++ b/apps/demos/storybook/jest.config.mjs @@ -0,0 +1,27 @@ +import baseConfig from '@walkeros/config/jest/web.config'; + +const config = { + // The base transform, with JSX compiled by the automatic runtime as + // tsconfig's `react-jsx` does (no React import in every file). + transform: { + '^.+\\.(t|j|mj)sx?$': [ + '@swc/jest', + { + jsc: { + target: 'es2022', + parser: { syntax: 'typescript', tsx: true }, + transform: { react: { runtime: 'automatic' } }, + }, + module: { type: 'es6' }, + }, + ], + }, + moduleNameMapper: { + '\\.(css)$': 'identity-obj-proxy', + '\\.(png|jpe?g|svg|webp)$': '/jest.file-stub.cjs', + '^@walkeros/explorer$': '/jest.explorer-stub.cjs', + ...baseConfig.moduleNameMapper, + }, +}; + +export default { ...baseConfig, ...config }; diff --git a/apps/demos/storybook/jest.explorer-stub.cjs b/apps/demos/storybook/jest.explorer-stub.cjs new file mode 100644 index 000000000..ad3b692cd --- /dev/null +++ b/apps/demos/storybook/jest.explorer-stub.cjs @@ -0,0 +1,15 @@ +// Jest only. Explorer's main entry loads ESM-only highlighters (shiki) that +// jest does not transform. The demo takes only ViewSource and ToggleButton +// from it. +// +// ViewSource adds no data-elb attribute and in its visual mode shows its +// child: all a tag test reads. Its own behaviour is tested in apps/explorer. +// ToggleButton is a plain React atom, so tests get the real one, from +// explorer's source. +const { createElement } = require('react'); + +exports.ViewSource = ({ children }) => + createElement('div', { 'data-view-source': '' }, children); + +exports.ToggleButton = + require('../../explorer/src/components/atoms/toggle-button').ToggleButton; diff --git a/apps/demos/storybook/jest.file-stub.cjs b/apps/demos/storybook/jest.file-stub.cjs new file mode 100644 index 000000000..105ff4974 --- /dev/null +++ b/apps/demos/storybook/jest.file-stub.cjs @@ -0,0 +1 @@ +module.exports = 'file-stub'; diff --git a/apps/demos/storybook/media/index.html b/apps/demos/storybook/media/index.html new file mode 100644 index 000000000..372e455c9 --- /dev/null +++ b/apps/demos/storybook/media/index.html @@ -0,0 +1,13 @@ + + + + + + + Media: walkerOS demo + + +
+ + + diff --git a/apps/demos/storybook/package.json b/apps/demos/storybook/package.json index 91ffd42f7..0cb7c5afa 100644 --- a/apps/demos/storybook/package.json +++ b/apps/demos/storybook/package.json @@ -1,20 +1,22 @@ { "name": "@walkeros/storybook-demo", "private": true, - "version": "4.7.2", + "version": "4.8.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "lint": "eslint .", + "lint": "eslint . && walkeros-design-check src .storybook", "typecheck": "tsc -b --noEmit", + "test": "jest", "preview": "vite preview", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, "dependencies": { - "@walkeros/core": "4.7.2", - "@walkeros/web-source-browser": "4.7.2", + "@walkeros/core": "4.8.0", + "@walkeros/explorer": "4.8.0", + "@walkeros/web-source-browser": "4.8.0", "react": "^19.2.3", "react-dom": "^19.2.3" }, @@ -26,7 +28,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@walkeros/storybook-addon": "4.7.2", + "@walkeros/storybook-addon": "4.8.0", "autoprefixer": "^10.4.23", "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/apps/demos/storybook/public/favicon.png b/apps/demos/storybook/public/favicon.png new file mode 100644 index 000000000..3f5091593 Binary files /dev/null and b/apps/demos/storybook/public/favicon.png differ diff --git a/apps/demos/storybook/public/vite.svg b/apps/demos/storybook/public/vite.svg deleted file mode 100644 index e7b8dfb1b..000000000 --- a/apps/demos/storybook/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/demos/storybook/index.html b/apps/demos/storybook/shop/index.html similarity index 56% rename from apps/demos/storybook/index.html rename to apps/demos/storybook/shop/index.html index e4b78eae1..6244d1dd8 100644 --- a/apps/demos/storybook/index.html +++ b/apps/demos/storybook/shop/index.html @@ -2,12 +2,12 @@ - + - Vite + React + TS + Shop: walkerOS demo
- + diff --git a/apps/demos/storybook/src/App.css b/apps/demos/storybook/src/App.css deleted file mode 100644 index b9d355df2..000000000 --- a/apps/demos/storybook/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/apps/demos/storybook/src/App.stories.tsx b/apps/demos/storybook/src/App.stories.tsx deleted file mode 100644 index 3c661deb9..000000000 --- a/apps/demos/storybook/src/App.stories.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import App from './App'; -import './index.css'; - -const meta: Meta = { - title: 'Templates/Demos', - component: App, - parameters: { - layout: 'fullscreen', - docs: { - description: { - component: '# Full Application Demo', - }, - }, - }, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - name: 'Mediathek', - parameters: { - docs: { - description: { - story: - 'Shows the application starting directly in mediathek mode. Use the navigation toggle to explore other sections.', - }, - }, - }, - play: async ({ canvasElement }) => { - // Auto-navigate to mediathek template for this story - const mediathekButton = canvasElement.querySelector( - '[data-testid="mediathek-button"] button', - ) as HTMLButtonElement; - if (mediathekButton) { - mediathekButton.click(); - } - }, -}; diff --git a/apps/demos/storybook/src/App.tsx b/apps/demos/storybook/src/App.tsx deleted file mode 100644 index c409fed0f..000000000 --- a/apps/demos/storybook/src/App.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useState } from 'react'; -import { MediathekTemplate } from './components/media/templates/MediathekTemplate'; -import { Button } from './stories/Button'; -import { Typography } from './components/media/atoms/Typography/Typography'; -import './App.css'; - -type TemplateType = 'landing' | 'publisher'; - -function App() { - const [currentTemplate, setCurrentTemplate] = - useState('landing'); - - if (currentTemplate === 'landing') { - return ( -
-
- - Component Demo - - - Explore complete application domains built with Atomic Design - principles - - -
-
-
-
- -
- - 🎯 For the Full Experience - - - Run{' '} - - npm run storybook - {' '} - to explore all components in detail with interactive documentation - -
-
-
- ); - } - - return ( -
-
-
- - {currentTemplate === 'publisher' && } -
- ); -} - -export default App; diff --git a/apps/demos/storybook/src/assets/react.svg b/apps/demos/storybook/src/assets/react.svg deleted file mode 100644 index 6c87de9bb..000000000 --- a/apps/demos/storybook/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/demos/storybook/src/components/index.ts b/apps/demos/storybook/src/components/index.ts deleted file mode 100644 index e5d441c20..000000000 --- a/apps/demos/storybook/src/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Media Components -export * as Media from './media'; diff --git a/apps/demos/storybook/src/components/media/atoms/Button/Button.css b/apps/demos/storybook/src/components/media/atoms/Button/Button.css deleted file mode 100644 index dc91dc763..000000000 --- a/apps/demos/storybook/src/components/media/atoms/Button/Button.css +++ /dev/null @@ -1,30 +0,0 @@ -.storybook-button { - font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; - font-weight: 700; - border: 0; - border-radius: 3em; - cursor: pointer; - display: inline-block; - line-height: 1; -} -.storybook-button--primary { - color: white; - background-color: #1ea7fd; -} -.storybook-button--secondary { - color: #333; - background-color: transparent; - box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; -} -.storybook-button--small { - font-size: 12px; - padding: 10px 16px; -} -.storybook-button--medium { - font-size: 14px; - padding: 11px 20px; -} -.storybook-button--large { - font-size: 16px; - padding: 12px 24px; -} diff --git a/apps/demos/storybook/src/components/media/atoms/Button/Button.stories.ts b/apps/demos/storybook/src/components/media/atoms/Button/Button.stories.ts deleted file mode 100644 index 45b8433ae..000000000 --- a/apps/demos/storybook/src/components/media/atoms/Button/Button.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Button } from './Button'; - -const meta: Meta = { - title: 'Media/Atoms/Button', - component: Button, - parameters: { - layout: 'centered', - }, - tags: ['media'], - argTypes: { - backgroundColor: { control: 'color' }, - dataElb: { - control: { type: 'object' }, - description: 'walkerOS tracking configuration', - }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Primary: Story = { - args: { - primary: true, - label: 'Button', - dataElb: { - entity: 'button', - action: 'click', - data: { variant: 'primary' }, - }, - }, -}; - -export const Secondary: Story = { - args: { - label: 'Button', - dataElb: { - entity: 'button', - action: 'click', - data: { variant: 'secondary' }, - }, - }, -}; diff --git a/apps/demos/storybook/src/components/media/atoms/Button/Button.tsx b/apps/demos/storybook/src/components/media/atoms/Button/Button.tsx deleted file mode 100644 index 932241394..000000000 --- a/apps/demos/storybook/src/components/media/atoms/Button/Button.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import './Button.css'; -import { createTrackingProps, type DataElb } from '../../../../utils/tagger'; - -export interface ButtonProps { - primary?: boolean; - backgroundColor?: string; - size?: 'small' | 'medium' | 'large'; - label: string; - disabled?: boolean; - onClick?: () => void; - dataElb?: DataElb; -} - -export const Button = ({ - primary = false, - size = 'medium', - disabled = false, - label, - onClick, - dataElb, -}: ButtonProps) => { - const mode = primary - ? 'storybook-button--primary' - : 'storybook-button--secondary'; - - const trackingProps = createTrackingProps(dataElb, 'Button'); - - return ( - - ); -}; diff --git a/apps/demos/storybook/src/components/media/atoms/Button/index.ts b/apps/demos/storybook/src/components/media/atoms/Button/index.ts deleted file mode 100644 index fa3c8a518..000000000 --- a/apps/demos/storybook/src/components/media/atoms/Button/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Button } from './Button'; -export type { ButtonProps } from './Button'; diff --git a/apps/demos/storybook/src/components/media/atoms/Icon/Icon.stories.ts b/apps/demos/storybook/src/components/media/atoms/Icon/Icon.stories.ts deleted file mode 100644 index dd9fc5df9..000000000 --- a/apps/demos/storybook/src/components/media/atoms/Icon/Icon.stories.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Icon } from './Icon'; - -const meta: Meta = { - title: 'Media/Atoms/Icon', - component: Icon, - parameters: { - layout: 'centered', - }, - tags: ['autodocs'], - argTypes: { - type: { - control: { type: 'select' }, - options: ['search', 'profile'], - }, - size: { - control: { type: 'select' }, - options: ['small', 'medium', 'large'], - }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Search: Story = { - args: { - type: 'search', - className: 'text-primary-600', - }, -}; - -export const Profile: Story = { - args: { - type: 'profile', - className: 'text-primary-600', - }, -}; diff --git a/apps/demos/storybook/src/components/media/atoms/Icon/Icon.tsx b/apps/demos/storybook/src/components/media/atoms/Icon/Icon.tsx deleted file mode 100644 index e7051b61e..000000000 --- a/apps/demos/storybook/src/components/media/atoms/Icon/Icon.tsx +++ /dev/null @@ -1,54 +0,0 @@ -export interface IconProps { - type: 'search' | 'profile'; - size?: 'small' | 'medium' | 'large'; - className?: string; -} - -export const Icon = ({ type, size = 'medium', className = '' }: IconProps) => { - const sizeClasses = { - small: 'w-4 h-4', - medium: 'w-6 h-6', - large: 'w-8 h-8', - }; - - const SearchIcon = () => ( - - - - ); - - const ProfileIcon = () => ( - - - - ); - - switch (type) { - case 'search': - return ; - case 'profile': - return ; - default: - return ; - } -}; diff --git a/apps/demos/storybook/src/components/media/atoms/Icon/index.ts b/apps/demos/storybook/src/components/media/atoms/Icon/index.ts deleted file mode 100644 index 65b69431e..000000000 --- a/apps/demos/storybook/src/components/media/atoms/Icon/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Icon } from './Icon'; -export type { IconProps } from './Icon'; diff --git a/apps/demos/storybook/src/components/media/atoms/Image/Image.tsx b/apps/demos/storybook/src/components/media/atoms/Image/Image.tsx deleted file mode 100644 index 29c28ab78..000000000 --- a/apps/demos/storybook/src/components/media/atoms/Image/Image.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { createTrackingProps, type DataElb } from '../../../../utils/tagger'; -import { assign } from '@walkeros/core'; - -export interface ImageProps { - type: 'thumbnail' | 'banner' | 'postcard'; - style?: number; - alt?: string; - title?: string; - className?: string; - dataElb?: DataElb; -} - -export const Image = ({ - type, - style = 1, - alt, - title, - className = '', - dataElb, -}: ImageProps) => { - const getPlaceholderColor = ( - styleNum: number, - alt: string = '', - title: string = '', - ) => { - const colors = [ - 'from-blue-200 to-blue-800', - 'from-purple-800 to-purple-500', - 'from-green-200 to-green-400', - 'from-orange-300 to-orange-200', - 'from-pink-900 to-pink-300', - 'from-teal-400 to-teal-700', - 'from-red-800 to-red-300', - 'from-blue-700 to-blue-300', - 'from-indigo-600 to-indigo-800', - 'from-gray-200 to-gray-800', - 'from-yellow-200 to-yellow-800', - 'from-cyan-200 to-cyan-800', - 'from-emerald-200 to-emerald-800', - 'from-fuchsia-200 to-fuchsia-800', - 'from-violet-200 to-violet-800', - 'from-rose-200 to-rose-800', - 'from-amber-200 to-amber-800', - 'from-sky-200 to-sky-800', - 'from-lime-200 to-lime-800', - ]; - return colors[(styleNum + title.length + alt.length) % colors.length]; - }; - - const placeholderColor = getPlaceholderColor(style, alt, title); - - const styles = [ - ['text-base', 'text-lg', 'text-xl'], // sizes - [ - 'font-light', - 'font-normal', - 'font-medium', - 'font-semibold', - 'font-bold', - 'font-extrabold', - 'font-black', - ], // weights - [ - 'tracking-tight', - 'tracking-normal', - 'tracking-wide', - 'tracking-wider', - 'tracking-widest', - ], // spacing - ['font-sans', 'font-serif', 'font-mono'], // font families - ]; - - const getStyle = (styleNum: number, title: string = '', alt: string = '') => { - const seed = styleNum + title.length + alt.length; - const size = styles[0][seed % styles[0].length]; - const weight = styles[1][seed % styles[1].length]; - const spacing = styles[2][seed % styles[2].length]; - const family = styles[3][seed % styles[3].length]; - return `${size} ${weight} ${spacing} ${family}`; - }; - - const titleStyle = getStyle(style, title, alt); - - const typeClasses = { - thumbnail: 'aspect-video rounded-lg', - banner: 'aspect-[16/6] rounded-xl', - postcard: 'aspect-[16/20] rounded-xl', - }; - - const trackingProps = createTrackingProps( - assign( - { - data: { - img: `id-${titleStyle.length}`, - type: type, - ...(title && { title }), - ...(alt && { alt }), - }, - }, - dataElb, - ), - 'Image', - ); - - return ( -
-
- {title &&
{title}
} -
{alt}
-
-
- ); -}; diff --git a/apps/demos/storybook/src/components/media/atoms/Typography/Typography.css b/apps/demos/storybook/src/components/media/atoms/Typography/Typography.css deleted file mode 100644 index c7fb55790..000000000 --- a/apps/demos/storybook/src/components/media/atoms/Typography/Typography.css +++ /dev/null @@ -1,94 +0,0 @@ -.storybook-typography { - font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; - margin: 0; -} - -.storybook-typography--h1 { - font-size: 2.5rem; - font-weight: 700; - line-height: 1.2; -} - -.storybook-typography--h2 { - font-size: 2rem; - font-weight: 600; - line-height: 1.3; -} - -.storybook-typography--h3 { - font-size: 1.5rem; - font-weight: 600; - line-height: 1.4; -} - -.storybook-typography--h4 { - font-size: 1.25rem; - font-weight: 500; - line-height: 1.4; -} - -.storybook-typography--h5 { - font-size: 1.125rem; - font-weight: 500; - line-height: 1.5; -} - -.storybook-typography--h6 { - font-size: 1rem; - font-weight: 500; - line-height: 1.5; -} - -.storybook-typography--body1 { - font-size: 1rem; - font-weight: 400; - line-height: 1.5; -} - -.storybook-typography--body2 { - font-size: 0.875rem; - font-weight: 400; - line-height: 1.5; -} - -.storybook-typography--caption { - font-size: 0.75rem; - font-weight: 400; - line-height: 1.4; -} - -.storybook-typography--primary { - color: #333; -} - -.storybook-typography--secondary { - color: #666; -} - -.storybook-typography--error { - color: #e74c3c; -} - -.storybook-typography--warning { - color: #f39c12; -} - -.storybook-typography--info { - color: #1ea7fd; -} - -.storybook-typography--success { - color: #27ae60; -} - -.storybook-typography--left { - text-align: left; -} - -.storybook-typography--center { - text-align: center; -} - -.storybook-typography--right { - text-align: right; -} diff --git a/apps/demos/storybook/src/components/media/atoms/Typography/Typography.stories.ts b/apps/demos/storybook/src/components/media/atoms/Typography/Typography.stories.ts deleted file mode 100644 index 245bb23b7..000000000 --- a/apps/demos/storybook/src/components/media/atoms/Typography/Typography.stories.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Typography } from './Typography'; - -const meta: Meta = { - title: 'Media/Atoms/Typography', - component: Typography, - parameters: { - layout: 'centered', - }, - tags: ['media'], -}; - -export default meta; -type Story = StoryObj; - -export const Heading1: Story = { - args: { - variant: 'h1', - children: 'Heading 1', - }, -}; - -export const Heading2: Story = { - args: { - variant: 'h2', - children: 'Heading 2', - }, -}; - -export const Heading3: Story = { - args: { - variant: 'h3', - children: 'Heading 3', - }, -}; - -export const Body1: Story = { - args: { - variant: 'body1', - children: - 'This is body text (body1). Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - }, -}; - -export const Body2: Story = { - args: { - variant: 'body2', - children: - 'This is smaller body text (body2). Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - }, -}; - -export const Caption: Story = { - args: { - variant: 'caption', - children: 'This is caption text - small and subtle.', - }, -}; - -export const ErrorText: Story = { - args: { - variant: 'body1', - color: 'error', - children: 'This is an error message.', - }, -}; - -export const SuccessText: Story = { - args: { - variant: 'body1', - color: 'success', - children: 'This is a success message.', - }, -}; - -export const CenteredText: Story = { - args: { - variant: 'h2', - align: 'center', - children: 'This text is centered.', - }, -}; diff --git a/apps/demos/storybook/src/components/media/atoms/Typography/Typography.tsx b/apps/demos/storybook/src/components/media/atoms/Typography/Typography.tsx deleted file mode 100644 index 310404fce..000000000 --- a/apps/demos/storybook/src/components/media/atoms/Typography/Typography.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; -import './Typography.css'; - -export interface TypographyProps { - variant?: - | 'h1' - | 'h2' - | 'h3' - | 'h4' - | 'h5' - | 'h6' - | 'body1' - | 'body2' - | 'caption'; - color?: 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success'; - align?: 'left' | 'center' | 'right'; - children: React.ReactNode; - className?: string; - style?: React.CSSProperties; -} - -export const Typography = ({ - variant = 'body1', - color = 'primary', - align = 'left', - children, - className, - style, - ...props -}: TypographyProps) => { - const tag = variant.startsWith('h') ? variant : 'p'; - - return React.createElement( - tag, - { - className: [ - 'storybook-typography', - `storybook-typography--${variant}`, - `storybook-typography--${color}`, - `storybook-typography--${align}`, - className, - ] - .filter(Boolean) - .join(' '), - style, - ...props, - }, - children, - ); -}; diff --git a/apps/demos/storybook/src/components/media/atoms/Typography/index.ts b/apps/demos/storybook/src/components/media/atoms/Typography/index.ts deleted file mode 100644 index c884e7d93..000000000 --- a/apps/demos/storybook/src/components/media/atoms/Typography/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Typography } from './Typography'; -export type { TypographyProps } from './Typography'; diff --git a/apps/demos/storybook/src/components/media/molecules/NavigationMenu/NavigationMenu.stories.ts b/apps/demos/storybook/src/components/media/molecules/NavigationMenu/NavigationMenu.stories.ts deleted file mode 100644 index 5296fd10d..000000000 --- a/apps/demos/storybook/src/components/media/molecules/NavigationMenu/NavigationMenu.stories.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { NavigationMenu } from './NavigationMenu'; - -const meta: Meta = { - title: 'Media/Molecules/NavigationMenu', - component: NavigationMenu, - parameters: { - layout: 'centered', - }, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: {}, -}; - -export const WithActiveItem: Story = { - args: { - activeItem: 'Series', - }, -}; diff --git a/apps/demos/storybook/src/components/media/molecules/NavigationMenu/NavigationMenu.tsx b/apps/demos/storybook/src/components/media/molecules/NavigationMenu/NavigationMenu.tsx deleted file mode 100644 index bacbddc4c..000000000 --- a/apps/demos/storybook/src/components/media/molecules/NavigationMenu/NavigationMenu.tsx +++ /dev/null @@ -1,29 +0,0 @@ -export interface NavigationMenuProps { - activeItem?: string; - onItemClick?: (item: string) => void; -} - -const menuItems = ['Movies', 'Series', 'Documentaries', 'Sports', 'Kids']; - -export const NavigationMenu = ({ - activeItem, - onItemClick, -}: NavigationMenuProps) => { - return ( - - ); -}; diff --git a/apps/demos/storybook/src/components/media/molecules/NavigationMenu/index.ts b/apps/demos/storybook/src/components/media/molecules/NavigationMenu/index.ts deleted file mode 100644 index cff2a5742..000000000 --- a/apps/demos/storybook/src/components/media/molecules/NavigationMenu/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { NavigationMenu } from './NavigationMenu'; -export type { NavigationMenuProps } from './NavigationMenu'; diff --git a/apps/demos/storybook/src/components/media/molecules/TaggedButton/TaggedButton.tsx b/apps/demos/storybook/src/components/media/molecules/TaggedButton/TaggedButton.tsx deleted file mode 100644 index 68d1f153f..000000000 --- a/apps/demos/storybook/src/components/media/molecules/TaggedButton/TaggedButton.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { type ButtonProps, Button } from '../../atoms/Button'; -import { createTrackingProps, type DataElb } from '../../../../utils/tagger'; -import { assign } from '@walkeros/core'; - -export interface TaggedButtonProps extends ButtonProps { - dataElb?: DataElb; -} - -export const TaggedButton = ({ - dataElb, - ...buttonProps -}: TaggedButtonProps) => { - const trackingProps = createTrackingProps( - assign( - { - data: { - cta: buttonProps.label, - type: buttonProps.primary ? 'primary' : 'secondary', - }, - }, - dataElb, - ), - 'TaggedButton', - ); - - return ( - - - - - - - ); -}; diff --git a/apps/demos/storybook/src/components/media/organisms/HeaderBar/index.ts b/apps/demos/storybook/src/components/media/organisms/HeaderBar/index.ts deleted file mode 100644 index 82b2aeaca..000000000 --- a/apps/demos/storybook/src/components/media/organisms/HeaderBar/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { HeaderBar } from './HeaderBar'; -export type { HeaderBarProps } from './HeaderBar'; diff --git a/apps/demos/storybook/src/components/media/templates/MediathekTemplate/MediathekTemplate.stories.ts b/apps/demos/storybook/src/components/media/templates/MediathekTemplate/MediathekTemplate.stories.ts deleted file mode 100644 index 408733721..000000000 --- a/apps/demos/storybook/src/components/media/templates/MediathekTemplate/MediathekTemplate.stories.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { MediathekTemplate } from './MediathekTemplate'; - -const meta: Meta = { - title: 'Media/Templates/MediathekTemplate', - component: MediathekTemplate, - parameters: { - layout: 'fullscreen', - }, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: {}, -}; diff --git a/apps/demos/storybook/src/components/media/templates/MediathekTemplate/MediathekTemplate.tsx b/apps/demos/storybook/src/components/media/templates/MediathekTemplate/MediathekTemplate.tsx deleted file mode 100644 index f6bc8f5d0..000000000 --- a/apps/demos/storybook/src/components/media/templates/MediathekTemplate/MediathekTemplate.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { useState } from 'react'; -import { HeaderBar } from '../../organisms/HeaderBar'; -import { HeroBanner } from '../../organisms/HeroBanner'; -import { CarouselSection } from '../../organisms/CarouselSection'; -import { PromotionBanner } from '../../organisms/PromotionBanner'; -import { createTrackingProps, type DataElb } from '../../../../utils/tagger'; -import { assign } from '@walkeros/core'; - -export interface MediathekTemplateProps { - activeMenuItem?: string; - onMenuItemClick?: (item: string) => void; - dataElb?: DataElb; -} - -const topSeriesItems = [ - { - title: 'Debugging Dreams', - }, - { - title: 'Return of the Bug', - }, - { - title: 'Code Wars', - }, - { - title: 'Data Diaries', - }, - { - title: 'Sleepless in Stack Overflow', - }, -]; - -const filmRecommendations = [ - { - title: 'Inside Server-Side Valley', - }, - { - title: 'A Beautiful Code', - }, - { - title: 'The Art of Refactoring', - }, - { - title: 'The Pragmatic Programmer', - }, - { - title: 'A Journey into Agile', - }, -]; - -// Rows appended at runtime by the "Add row" button. Cycled so repeated clicks -// keep producing fresh, tagged content. -const extraRowCatalog = [ - { - title: 'Trending Now', - items: [ - { title: 'The Nightly Build' }, - { title: 'Merge Conflict' }, - { title: 'Ship It' }, - { title: 'Rubber Duck Tales' }, - ], - }, - { - title: 'New Releases', - items: [ - { title: 'Null and Void' }, - { title: 'The Big O' }, - { title: 'Race Condition' }, - { title: 'Off By One' }, - ], - }, - { - title: 'Because You Watched', - items: [ - { title: 'Kernel Panic' }, - { title: 'The Legacy System' }, - { title: 'Hotfix Heroes' }, - { title: 'Semver Saga' }, - ], - }, -]; - -export const MediathekTemplate = ({ - activeMenuItem, - onMenuItemClick, - dataElb, -}: MediathekTemplateProps) => { - const trackingProps = createTrackingProps( - assign( - { - context: { stage: 'inspo' }, - }, - dataElb, - ), - 'MediathekTemplate', - ); - - const [extraRows, setExtraRows] = useState(0); - - return ( -
- - - {/* data-elbobserve: the walker auto-registers rows added below at runtime, - so a new row's tagged items fire their events without a walker re-run. */} -
- - - - - - - - - - - {Array.from({ length: extraRows }).map((_, index) => { - const row = extraRowCatalog[index % extraRowCatalog.length]; - return ( - - ); - })} - -
- -
-
-
- ); -}; diff --git a/apps/demos/storybook/src/components/media/templates/MediathekTemplate/index.ts b/apps/demos/storybook/src/components/media/templates/MediathekTemplate/index.ts deleted file mode 100644 index ae855a0cc..000000000 --- a/apps/demos/storybook/src/components/media/templates/MediathekTemplate/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { MediathekTemplate } from './MediathekTemplate'; -export type { MediathekTemplateProps } from './MediathekTemplate'; diff --git a/apps/demos/storybook/src/demos/__fixtures__/controls.ts b/apps/demos/storybook/src/demos/__fixtures__/controls.ts new file mode 100644 index 000000000..36e1d347d --- /dev/null +++ b/apps/demos/storybook/src/demos/__fixtures__/controls.ts @@ -0,0 +1,26 @@ +import type { DemoControls } from '../../shared/controls'; + +const noop = () => {}; + +/** + * The default demo controls (Anonymous, English, consent unknown) with no-op + * callbacks, for rendering a page in a test. + */ +export function testControls( + overrides: Partial = {}, +): DemoControls { + return { + persona: 'anonymous', + onPersonaSwitch: noop, + language: 'en', + onLanguageToggle: noop, + consent: { + state: 'unknown', + onAccept: noop, + onDeny: noop, + onReset: noop, + }, + elb: async () => ({ ok: true }), + ...overrides, + }; +} diff --git a/apps/demos/storybook/src/demos/__fixtures__/elbAttributes.ts b/apps/demos/storybook/src/demos/__fixtures__/elbAttributes.ts new file mode 100644 index 000000000..ad4f0ee76 --- /dev/null +++ b/apps/demos/storybook/src/demos/__fixtures__/elbAttributes.ts @@ -0,0 +1,17 @@ +import { render } from '@testing-library/react'; +import type { ReactElement } from 'react'; + +/** + * Every `data-elb*` attribute a rendered page carries, as sorted + * `name=value` strings: the page's tags, wherever they sit. + */ +export function elbAttributes(ui: ReactElement): string[] { + const { container } = render(ui); + const pairs: string[] = []; + container.querySelectorAll('*').forEach((element) => { + for (const attribute of Array.from(element.attributes)) + if (attribute.name.startsWith('data-elb')) + pairs.push(`${attribute.name}=${attribute.value}`); + }); + return pairs.sort(); +} diff --git a/apps/demos/storybook/src/demos/__fixtures__/media-attributes.json b/apps/demos/storybook/src/demos/__fixtures__/media-attributes.json new file mode 100644 index 000000000..7043c74a0 --- /dev/null +++ b/apps/demos/storybook/src/demos/__fixtures__/media-attributes.json @@ -0,0 +1,108 @@ +[ + "data-elb-=img:id-1;type:postcard;title:Inside Server-Side Valley", + "data-elb-=img:id-1;type:thumbnail;title:Debugging Dreams", + "data-elb-=img:id-1;type:thumbnail;title:The Future of Tech", + "data-elb-=img:id-2;type:postcard;title:A Beautiful Code", + "data-elb-=img:id-2;type:thumbnail;title:AI Revolution", + "data-elb-=img:id-2;type:thumbnail;title:Return of the Bug", + "data-elb-=img:id-3;type:postcard;title:The Art of Refactoring", + "data-elb-=img:id-3;type:thumbnail;title:Code Wars", + "data-elb-=img:id-3;type:thumbnail;title:Open Source Stories", + "data-elb-=img:id-4;type:postcard;title:The Pragmatic Programmer", + "data-elb-=img:id-4;type:thumbnail;title:Data Diaries", + "data-elb-=img:id-4;type:thumbnail;title:Digital Transformation", + "data-elb-=img:id-5;type:banner", + "data-elb-=img:id-5;type:postcard;title:A Journey into Agile", + "data-elb-=img:id-5;type:thumbnail;title:Sleepless in Stack Overflow", + "data-elb-=type:button", + "data-elb-=type:primary", + "data-elb-content=title:A Beautiful Code;position:2", + "data-elb-content=title:A Journey into Agile;position:5", + "data-elb-content=title:AI Revolution;position:2", + "data-elb-content=title:Code Wars;position:3", + "data-elb-content=title:Data Diaries;position:4", + "data-elb-content=title:Debugging Dreams;position:1", + "data-elb-content=title:Digital Transformation;position:4", + "data-elb-content=title:Inside Server-Side Valley;position:1", + "data-elb-content=title:Open Source Stories;position:3", + "data-elb-content=title:Return of the Bug;position:2", + "data-elb-content=title:Sleepless in Stack Overflow;position:5", + "data-elb-content=title:The Art of Refactoring;position:3", + "data-elb-content=title:The Future of Tech;position:1", + "data-elb-content=title:The Pragmatic Programmer;position:4", + "data-elb-promotion=type:promo", + "data-elb-teaser=type:hero", + "data-elb=content", + "data-elb=content", + "data-elb=content", + "data-elb=content", + "data-elb=content", + "data-elb=content", + "data-elb=content", + "data-elb=content", + "data-elb=content", + "data-elb=content", + "data-elb=content", + "data-elb=content", + "data-elb=content", + "data-elb=content", + "data-elb=promotion", + "data-elb=teaser", + "data-elbaction=click:click", + "data-elbaction=click:explore", + "data-elbaction=visible:visible", + "data-elbaction=visible:visible;click:click", + "data-elbaction=visible:visible;click:click", + "data-elbaction=visible:visible;click:click", + "data-elbaction=visible:visible;click:click", + "data-elbaction=visible:visible;click:click", + "data-elbaction=visible:visible;click:click", + "data-elbaction=visible:visible;click:click", + "data-elbaction=visible:visible;click:click", + "data-elbaction=visible:visible;click:click", + "data-elbaction=visible:visible;click:click", + "data-elbaction=visible:visible;click:click", + "data-elbaction=visible:visible;click:click", + "data-elbaction=visible:visible;click:click", + "data-elbaction=visible:visible;click:click", + "data-elbcontext=component:Button", + "data-elbcontext=component:Button", + "data-elbcontext=component:CarouselItem", + "data-elbcontext=component:CarouselItem", + "data-elbcontext=component:CarouselItem", + "data-elbcontext=component:CarouselItem", + "data-elbcontext=component:CarouselItem", + "data-elbcontext=component:CarouselItem", + "data-elbcontext=component:CarouselItem", + "data-elbcontext=component:CarouselItem", + "data-elbcontext=component:CarouselItem", + "data-elbcontext=component:CarouselItem", + "data-elbcontext=component:CarouselItem", + "data-elbcontext=component:CarouselItem", + "data-elbcontext=component:CarouselItem", + "data-elbcontext=component:CarouselItem", + "data-elbcontext=component:HeroBanner", + "data-elbcontext=component:Image", + "data-elbcontext=component:Image", + "data-elbcontext=component:Image", + "data-elbcontext=component:Image", + "data-elbcontext=component:Image", + "data-elbcontext=component:Image", + "data-elbcontext=component:Image", + "data-elbcontext=component:Image", + "data-elbcontext=component:Image", + "data-elbcontext=component:Image", + "data-elbcontext=component:Image", + "data-elbcontext=component:Image", + "data-elbcontext=component:Image", + "data-elbcontext=component:Image", + "data-elbcontext=component:Image", + "data-elbcontext=component:PromotionBanner", + "data-elbcontext=component:TaggedButton", + "data-elbcontext=component:TaggedButton", + "data-elbcontext=list:Exciting Documentaries and Reports;component:CarouselSection", + "data-elbcontext=list:Movie Recommendations;component:CarouselSection", + "data-elbcontext=list:Our Top Series;component:CarouselSection", + "data-elbcontext=stage:inspo;component:MediathekTemplate", + "data-elbobserve=" +] diff --git a/apps/demos/storybook/src/demos/__fixtures__/shop-attributes.json b/apps/demos/storybook/src/demos/__fixtures__/shop-attributes.json new file mode 100644 index 000000000..982709e24 --- /dev/null +++ b/apps/demos/storybook/src/demos/__fixtures__/shop-attributes.json @@ -0,0 +1,96 @@ +[ + "data-elb-checkout=shipping:5;currency:EUR", + "data-elb-checkout=subtotal:249;currency:EUR", + "data-elb-checkout=taxes:5.52;currency:EUR", + "data-elb-checkout=total:259.52;currency:EUR", + "data-elb-order=id:0rd3r1d", + "data-elb-order=shipping-city:20354 Hamburg", + "data-elb-order=shipping-country:Germany", + "data-elb-order=shipping:5", + "data-elb-order=subtotal:249;currency:EUR", + "data-elb-order=taxes:5.52", + "data-elb-order=total:259.52", + "data-elb-product=availability:in_stock", + "data-elb-product=category:Travel/Bags", + "data-elb-product=color:black", + "data-elb-product=color:black", + "data-elb-product=color:green", + "data-elb-product=color:green", + "data-elb-product=flag:schnapper", + "data-elb-product=id:rcksnck", + "data-elb-product=name:Cool Cap", + "data-elb-product=name:Cool Cap", + "data-elb-product=name:Cool Cap", + "data-elb-product=name:Everyday Ruck Snack", + "data-elb-product=name:Everyday Ruck Snack", + "data-elb-product=name:Everyday Ruck Snack", + "data-elb-product=name:Everyday Ruck Snack", + "data-elb-product=name:Hoodie", + "data-elb-product=name:Party pants", + "data-elb-product=price:210", + "data-elb-product=price:210;currency:EUR", + "data-elb-product=price:210;currency:EUR", + "data-elb-product=price:210;currency:EUR", + "data-elb-product=price:217", + "data-elb-product=price:39", + "data-elb-product=price:39;currency:EUR", + "data-elb-product=price:39;currency:EUR", + "data-elb-product=price:59", + "data-elb-product=quantity:#value", + "data-elb-product=quantity:#value", + "data-elb-product=rating:4", + "data-elb-product=reviews:1624", + "data-elb-product=size:large", + "data-elb-product=size:large", + "data-elb-product=size:onesize", + "data-elb-product=size:onesize", + "data-elb-promotion=category:analytics", + "data-elb-promotion=name:#innerText", + "data-elb=checkout", + "data-elb=order", + "data-elb=product", + "data-elb=product", + "data-elb=product", + "data-elb=product", + "data-elb=product", + "data-elb=product", + "data-elb=product", + "data-elb=product", + "data-elb=product", + "data-elb=promotion", + "data-elbaction=click:add", + "data-elbaction=click:add", + "data-elbaction=click:add", + "data-elbaction=click:add", + "data-elbaction=click:click", + "data-elbaction=click:click", + "data-elbaction=click:click", + "data-elbaction=click:click", + "data-elbaction=click:confirm", + "data-elbaction=click:more", + "data-elbaction=click:remove", + "data-elbaction=click:remove", + "data-elbaction=click:start", + "data-elbaction=impression:impression", + "data-elbaction=impression:impression", + "data-elbaction=impression:impression", + "data-elbaction=impression:impression", + "data-elbaction=visible:complete", + "data-elbaction=visible:read", + "data-elbaction=visible:view", + "data-elbaction=visible:view", + "data-elbaction=visible:view", + "data-elbcontext=module:recommendations", + "data-elbcontext=shopping:cart", + "data-elbcontext=shopping:cart", + "data-elbcontext=shopping:cart", + "data-elbcontext=shopping:cart", + "data-elbcontext=shopping:checkout", + "data-elbcontext=shopping:complete", + "data-elbcontext=shopping:detail", + "data-elbcontext=shopping:inspo", + "data-elbcontext=test:engagement;category:analytics", + "data-elbglobals=cart_value:249", + "data-elbglobals=language:en", + "data-elbobserve=" +] diff --git a/apps/demos/storybook/src/demos/__fixtures__/taggedElements.ts b/apps/demos/storybook/src/demos/__fixtures__/taggedElements.ts new file mode 100644 index 000000000..b5ff00f7f --- /dev/null +++ b/apps/demos/storybook/src/demos/__fixtures__/taggedElements.ts @@ -0,0 +1,6 @@ +/** Every element under `root` that carries a `data-elb*` attribute. */ +export function taggedElements(root: Element): Element[] { + return Array.from(root.querySelectorAll('*')).filter((element) => + element.getAttributeNames().some((name) => name.startsWith('data-elb')), + ); +} diff --git a/apps/demos/storybook/src/demos/media/data.ts b/apps/demos/storybook/src/demos/media/data.ts new file mode 100644 index 000000000..da873efda --- /dev/null +++ b/apps/demos/storybook/src/demos/media/data.ts @@ -0,0 +1,110 @@ +// The media site's content: its sections, their titles and the rows the +// "Add row" button appends. + +import type { NavLink } from '../../shared/molecules/NavLinks'; +import type { FooterLink } from '../../shared/organisms/Footer'; + +export interface MediaItem { + title: string; + alt?: string; +} + +export interface MediaRow { + title: string; + items: MediaItem[]; +} + +/** The header's links, each to a section of the page. */ +export const navLinks: NavLink[] = [ + { label: 'Series', href: '#series' }, + { label: 'Movies', href: '#films' }, + { label: 'Documentaries', href: '#documentaries' }, + { label: 'Kids', href: '#promotion' }, +]; + +export const hero = { + title: 'Life in Code', + subtitle: 'Balancing Passion and Work', + buttonText: 'Explore Now', + style: 5, +}; + +export const topSeries: MediaRow = { + title: 'Our Top Series', + items: [ + { title: 'Debugging Dreams' }, + { title: 'Return of the Bug' }, + { title: 'Code Wars' }, + { title: 'Data Diaries' }, + { title: 'Sleepless in Stack Overflow' }, + ], +}; + +export const filmRecommendations: MediaRow = { + title: 'Movie Recommendations', + items: [ + { title: 'Inside Server-Side Valley' }, + { title: 'A Beautiful Code' }, + { title: 'The Art of Refactoring' }, + { title: 'The Pragmatic Programmer' }, + { title: 'A Journey into Agile' }, + ], +}; + +export const promotion = { + headline: 'Activate Kids Mode', + subtitle: 'Create a safe space for younger viewers.', + buttonText: 'Activate Now', +}; + +export const documentaries: MediaRow = { + title: 'Exciting Documentaries and Reports', + items: [ + { title: 'The Future of Tech' }, + { title: 'AI Revolution' }, + { title: 'Open Source Stories' }, + { title: 'Digital Transformation' }, + ], +}; + +// Rows appended at runtime by the "Add row" button. Cycled so repeated clicks +// keep producing fresh, tagged content. +export const extraRows: MediaRow[] = [ + { + title: 'Trending Now', + items: [ + { title: 'The Nightly Build' }, + { title: 'Merge Conflict' }, + { title: 'Ship It' }, + { title: 'Rubber Duck Tales' }, + ], + }, + { + title: 'New Releases', + items: [ + { title: 'Null and Void' }, + { title: 'The Big O' }, + { title: 'Race Condition' }, + { title: 'Off By One' }, + ], + }, + { + title: 'Because You Watched', + items: [ + { title: 'Kernel Panic' }, + { title: 'The Legacy System' }, + { title: 'Hotfix Heroes' }, + { title: 'Semver Saga' }, + ], + }, +]; + +// The demo's footer links lead nowhere. +export const footerLinks: FooterLink[] = [ + 'About', + 'Help', + 'Kids Mode', + 'Imprint', +].map((label) => ({ label, href: '#' })); + +export const copyright = '© 2024 Media Platform. All rights reserved.'; diff --git a/apps/demos/storybook/src/components/media/index.ts b/apps/demos/storybook/src/demos/media/index.ts similarity index 50% rename from apps/demos/storybook/src/components/media/index.ts rename to apps/demos/storybook/src/demos/media/index.ts index aa8f99275..a27fafceb 100644 --- a/apps/demos/storybook/src/components/media/index.ts +++ b/apps/demos/storybook/src/demos/media/index.ts @@ -1,20 +1,16 @@ -// Atoms -export * from './atoms/Button'; -export * from './atoms/Icon'; -export * from './atoms/Image'; -export * from './atoms/Typography'; - // Molecules -export * from './molecules/NavigationMenu'; -export * from './molecules/CarouselItem'; export * from './molecules/BannerText'; +export * from './molecules/CarouselItem'; +export * from './molecules/SearchButton'; export * from './molecules/TaggedButton'; // Organisms -export * from './organisms/HeaderBar'; -export * from './organisms/HeroBanner'; export * from './organisms/CarouselSection'; +export * from './organisms/HeroBanner'; export * from './organisms/PromotionBanner'; -// Templates -export * from './templates/MediathekTemplate'; +// Pages +export * from './pages/MediaPage'; + +// Content +export * from './data'; diff --git a/apps/demos/storybook/src/demos/media/language.test.tsx b/apps/demos/storybook/src/demos/media/language.test.tsx new file mode 100644 index 000000000..90d380575 --- /dev/null +++ b/apps/demos/storybook/src/demos/media/language.test.tsx @@ -0,0 +1,61 @@ +import { render } from '@testing-library/react'; +import { testControls } from '../__fixtures__/controls'; +import { elbAttributes } from '../__fixtures__/elbAttributes'; +import { taggedElements } from '../__fixtures__/taggedElements'; +import { elbish } from '../../shared/language'; +import { topSeries } from './data'; +import { MediaPage } from './pages/MediaPage'; + +const page = (language: 'en' | 'elbish') => ( + +); + +test('the language changes no tag but the language global', () => { + const english = elbAttributes(page('en')); + const elbishTags = elbAttributes(page('elbish')); + + expect(english).toContain('data-elbglobals=language:en'); + expect(elbishTags).toContain('data-elbglobals=language:elbish'); + expect(elbishTags).toEqual( + english + .map((pair) => + pair === 'data-elbglobals=language:en' + ? 'data-elbglobals=language:elbish' + : pair, + ) + .sort(), + ); +}); + +test('a row heading shows in Elbish while its list context stays English', () => { + const { container } = render(page('elbish')); + const row = container.querySelector( + `[data-elbcontext="list:${topSeries.title};component:CarouselSection"]`, + ); + expect(row?.querySelector('h3')?.textContent).toBe(elbish(topSeries.title)); +}); + +test('the visible text changes', () => { + const english = render(page('en')).container; + const translated = render(page('elbish')).container; + const headings = (container: HTMLElement) => + Array.from(container.querySelectorAll('h3'), (h) => h.textContent); + + expect(headings(english)).toContain(topSeries.title); + expect(headings(translated)).not.toContain(topSeries.title); +}); + +// The site switches the language without a reload, and walker.js keeps the +// triggers it registered on the tagged elements: a toggle must not remount +// them. +test('a language toggle keeps every tagged element', () => { + const { container, rerender } = render(page('en')); + const before = taggedElements(container); + expect(before.length).toBeGreaterThan(0); + + rerender(page('elbish')); + const after = taggedElements(container); + + expect(after).toHaveLength(before.length); + after.forEach((element, index) => expect(element).toBe(before[index])); +}); diff --git a/apps/demos/storybook/src/components/media/molecules/BannerText/BannerText.stories.ts b/apps/demos/storybook/src/demos/media/molecules/BannerText/BannerText.stories.ts similarity index 95% rename from apps/demos/storybook/src/components/media/molecules/BannerText/BannerText.stories.ts rename to apps/demos/storybook/src/demos/media/molecules/BannerText/BannerText.stories.ts index 71c0aa4a1..ee2d72355 100644 --- a/apps/demos/storybook/src/components/media/molecules/BannerText/BannerText.stories.ts +++ b/apps/demos/storybook/src/demos/media/molecules/BannerText/BannerText.stories.ts @@ -7,7 +7,7 @@ const meta: Meta = { parameters: { layout: 'centered', }, - tags: ['autodocs'], + tags: ['autodocs', 'media'], }; export default meta; diff --git a/apps/demos/storybook/src/components/media/molecules/BannerText/BannerText.tsx b/apps/demos/storybook/src/demos/media/molecules/BannerText/BannerText.tsx similarity index 53% rename from apps/demos/storybook/src/components/media/molecules/BannerText/BannerText.tsx rename to apps/demos/storybook/src/demos/media/molecules/BannerText/BannerText.tsx index 4d884b45b..23c4d60ca 100644 --- a/apps/demos/storybook/src/components/media/molecules/BannerText/BannerText.tsx +++ b/apps/demos/storybook/src/demos/media/molecules/BannerText/BannerText.tsx @@ -1,3 +1,5 @@ +import { useText } from '../../../../shared/language'; + export interface BannerTextProps { headline: string; subtitle: string; @@ -9,14 +11,13 @@ export const BannerText = ({ subtitle, className = '', }: BannerTextProps) => { + const t = useText(); return (
-

- {headline} +

+ {t(headline)}

-

- {subtitle} -

+

{t(subtitle)}

); }; diff --git a/apps/demos/storybook/src/components/media/molecules/BannerText/index.ts b/apps/demos/storybook/src/demos/media/molecules/BannerText/index.ts similarity index 100% rename from apps/demos/storybook/src/components/media/molecules/BannerText/index.ts rename to apps/demos/storybook/src/demos/media/molecules/BannerText/index.ts diff --git a/apps/demos/storybook/src/components/media/molecules/CarouselItem/CarouselItem.stories.ts b/apps/demos/storybook/src/demos/media/molecules/CarouselItem/CarouselItem.stories.ts similarity index 94% rename from apps/demos/storybook/src/components/media/molecules/CarouselItem/CarouselItem.stories.ts rename to apps/demos/storybook/src/demos/media/molecules/CarouselItem/CarouselItem.stories.ts index 9695180aa..01536e959 100644 --- a/apps/demos/storybook/src/components/media/molecules/CarouselItem/CarouselItem.stories.ts +++ b/apps/demos/storybook/src/demos/media/molecules/CarouselItem/CarouselItem.stories.ts @@ -7,7 +7,7 @@ const meta: Meta = { parameters: { layout: 'centered', }, - tags: ['autodocs'], + tags: ['autodocs', 'media'], }; export default meta; diff --git a/apps/demos/storybook/src/components/media/molecules/CarouselItem/CarouselItem.tsx b/apps/demos/storybook/src/demos/media/molecules/CarouselItem/CarouselItem.tsx similarity index 67% rename from apps/demos/storybook/src/components/media/molecules/CarouselItem/CarouselItem.tsx rename to apps/demos/storybook/src/demos/media/molecules/CarouselItem/CarouselItem.tsx index 3f0f87486..3a9802c47 100644 --- a/apps/demos/storybook/src/components/media/molecules/CarouselItem/CarouselItem.tsx +++ b/apps/demos/storybook/src/demos/media/molecules/CarouselItem/CarouselItem.tsx @@ -1,6 +1,7 @@ -import { Image, type ImageProps } from '../../atoms/Image'; -import { Typography } from '../../atoms/Typography'; -import { createTrackingProps, type DataElb } from '../../../../utils/tagger'; +import { Image, type ImageProps } from '../../../../shared/atoms/Image'; +import { Text } from '../../../../shared/atoms/Text'; +import { useText } from '../../../../shared/language'; +import { createTrackingProps, type DataElb } from '../../../../shared/tagger'; import { assign } from '@walkeros/core'; export interface CarouselItemProps { @@ -22,6 +23,7 @@ export const CarouselItem = ({ position, dataElb, }: CarouselItemProps) => { + const t = useText(); const trackingProps = createTrackingProps( assign( { @@ -46,7 +48,7 @@ export const CarouselItem = ({ className="flex-shrink-0 w-64 cursor-pointer group px-2 py-2" onClick={onClick} > -
+
- - {title} - + {t(title)} +
); diff --git a/apps/demos/storybook/src/components/media/molecules/CarouselItem/index.ts b/apps/demos/storybook/src/demos/media/molecules/CarouselItem/index.ts similarity index 100% rename from apps/demos/storybook/src/components/media/molecules/CarouselItem/index.ts rename to apps/demos/storybook/src/demos/media/molecules/CarouselItem/index.ts diff --git a/apps/demos/storybook/src/demos/media/molecules/SearchButton/SearchButton.stories.ts b/apps/demos/storybook/src/demos/media/molecules/SearchButton/SearchButton.stories.ts new file mode 100644 index 000000000..1b37d9352 --- /dev/null +++ b/apps/demos/storybook/src/demos/media/molecules/SearchButton/SearchButton.stories.ts @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import { SearchButton } from './SearchButton'; + +const meta: Meta = { + title: 'Media/Molecules/SearchButton', + component: SearchButton, + parameters: { + layout: 'centered', + }, + tags: ['autodocs', 'media'], + args: { + onClick: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/demos/storybook/src/demos/media/molecules/SearchButton/SearchButton.tsx b/apps/demos/storybook/src/demos/media/molecules/SearchButton/SearchButton.tsx new file mode 100644 index 000000000..a543afd2c --- /dev/null +++ b/apps/demos/storybook/src/demos/media/molecules/SearchButton/SearchButton.tsx @@ -0,0 +1,16 @@ +import { Button } from '../../../../shared/atoms/Button'; +import { Icon } from '../../../../shared/atoms/Icon'; +import { useText } from '../../../../shared/language'; + +export interface SearchButtonProps { + onClick?: () => void; +} + +export const SearchButton = ({ onClick }: SearchButtonProps) => { + const t = useText(); + return ( + + ); +}; diff --git a/apps/demos/storybook/src/demos/media/molecules/SearchButton/index.ts b/apps/demos/storybook/src/demos/media/molecules/SearchButton/index.ts new file mode 100644 index 000000000..e2ea383f2 --- /dev/null +++ b/apps/demos/storybook/src/demos/media/molecules/SearchButton/index.ts @@ -0,0 +1,2 @@ +export { SearchButton } from './SearchButton'; +export type { SearchButtonProps } from './SearchButton'; diff --git a/apps/demos/storybook/src/components/media/molecules/TaggedButton/TaggedButton.stories.ts b/apps/demos/storybook/src/demos/media/molecules/TaggedButton/TaggedButton.stories.ts similarity index 97% rename from apps/demos/storybook/src/components/media/molecules/TaggedButton/TaggedButton.stories.ts rename to apps/demos/storybook/src/demos/media/molecules/TaggedButton/TaggedButton.stories.ts index c2daa2754..e61c312c2 100644 --- a/apps/demos/storybook/src/components/media/molecules/TaggedButton/TaggedButton.stories.ts +++ b/apps/demos/storybook/src/demos/media/molecules/TaggedButton/TaggedButton.stories.ts @@ -7,7 +7,7 @@ const meta: Meta = { parameters: { layout: 'centered', }, - tags: ['autodocs'], + tags: ['autodocs', 'media'], argTypes: { dataElb: { name: 'walkerOS Data', diff --git a/apps/demos/storybook/src/demos/media/molecules/TaggedButton/TaggedButton.tsx b/apps/demos/storybook/src/demos/media/molecules/TaggedButton/TaggedButton.tsx new file mode 100644 index 000000000..2218274f1 --- /dev/null +++ b/apps/demos/storybook/src/demos/media/molecules/TaggedButton/TaggedButton.tsx @@ -0,0 +1,47 @@ +import { assign } from '@walkeros/core'; +import { Button } from '../../../../shared/atoms/Button'; +import { useText } from '../../../../shared/language'; +import { createTrackingProps, type DataElb } from '../../../../shared/tagger'; + +export interface TaggedButtonProps { + label: string; + primary?: boolean; + disabled?: boolean; + onClick?: () => void; + dataElb?: DataElb; +} + +export const TaggedButton = ({ + label, + primary = false, + disabled, + onClick, + dataElb, +}: TaggedButtonProps) => { + const t = useText(); + const trackingProps = createTrackingProps( + assign( + { + data: { + cta: label, + type: primary ? 'primary' : 'secondary', + }, + }, + dataElb, + ), + 'TaggedButton', + ); + + return ( + + + + ); +}; diff --git a/apps/demos/storybook/src/components/media/molecules/TaggedButton/index.ts b/apps/demos/storybook/src/demos/media/molecules/TaggedButton/index.ts similarity index 100% rename from apps/demos/storybook/src/components/media/molecules/TaggedButton/index.ts rename to apps/demos/storybook/src/demos/media/molecules/TaggedButton/index.ts diff --git a/apps/demos/storybook/src/components/media/organisms/CarouselSection/CarouselSection.stories.ts b/apps/demos/storybook/src/demos/media/organisms/CarouselSection/CarouselSection.stories.ts similarity index 77% rename from apps/demos/storybook/src/components/media/organisms/CarouselSection/CarouselSection.stories.ts rename to apps/demos/storybook/src/demos/media/organisms/CarouselSection/CarouselSection.stories.ts index 318e4f0c5..42b69364c 100644 --- a/apps/demos/storybook/src/components/media/organisms/CarouselSection/CarouselSection.stories.ts +++ b/apps/demos/storybook/src/demos/media/organisms/CarouselSection/CarouselSection.stories.ts @@ -7,7 +7,7 @@ const meta: Meta = { parameters: { layout: 'fullscreen', }, - tags: ['autodocs'], + tags: ['autodocs', 'media'], }; export default meta; @@ -17,17 +17,14 @@ const sampleItems = [ { id: '1', title: 'Debugging Dreams', - src: 'https://picsum.photos/400/225?random=1', }, { id: '2', title: 'Code Wars', - src: 'https://picsum.photos/400/225?random=2', }, { id: '3', title: 'API Chronicles', - src: 'https://picsum.photos/400/225?random=3', }, { id: '4', @@ -36,12 +33,10 @@ const sampleItems = [ { id: '5', title: 'Sleepless in Stack Overflow', - src: 'https://picsum.photos/400/225?random=5', }, { id: '6', title: 'The Art of Refactoring', - src: 'https://picsum.photos/400/225?random=6', }, ]; diff --git a/apps/demos/storybook/src/demos/media/organisms/CarouselSection/CarouselSection.tsx b/apps/demos/storybook/src/demos/media/organisms/CarouselSection/CarouselSection.tsx new file mode 100644 index 000000000..2f6959d32 --- /dev/null +++ b/apps/demos/storybook/src/demos/media/organisms/CarouselSection/CarouselSection.tsx @@ -0,0 +1,72 @@ +import { Fragment, type ReactElement, type ReactNode } from 'react'; +import type { ImageProps } from '../../../../shared/atoms/Image'; +import { Heading } from '../../../../shared/atoms/Heading'; +import { useText } from '../../../../shared/language'; +import { CarouselItem } from '../../molecules/CarouselItem'; +import { createTrackingProps, type DataElb } from '../../../../shared/tagger'; +import { assign } from '@walkeros/core'; + +export interface CarouselSectionProps { + title: string; + items: Array<{ + title: string; + alt?: string; + }>; + onItemClick?: (id: unknown) => void; + type?: ImageProps['type']; + dataElb?: DataElb; + /** Wraps each item card, the entity box (for a source view). */ + wrapItem?: (card: ReactElement) => ReactNode; +} + +export const CarouselSection = ({ + title, + items, + onItemClick, + type = 'thumbnail', + dataElb, + wrapItem, +}: CarouselSectionProps) => { + // The list context keeps the English title; only the heading translates. + const t = useText(); + const trackingProps = createTrackingProps( + assign( + { + context: { list: title }, + }, + dataElb, + ), + 'CarouselSection', + ); + + return ( +
+
+ + {t(title)} + + +
+ {items.map((item, index) => { + const position = index + 1; + const card = ( + onItemClick?.(position)} + /> + ); + return ( + + {wrapItem ? wrapItem(card) : card} + + ); + })} +
+
+
+ ); +}; diff --git a/apps/demos/storybook/src/components/media/organisms/CarouselSection/index.ts b/apps/demos/storybook/src/demos/media/organisms/CarouselSection/index.ts similarity index 100% rename from apps/demos/storybook/src/components/media/organisms/CarouselSection/index.ts rename to apps/demos/storybook/src/demos/media/organisms/CarouselSection/index.ts diff --git a/apps/demos/storybook/src/components/media/organisms/HeroBanner/HeroBanner.stories.ts b/apps/demos/storybook/src/demos/media/organisms/HeroBanner/HeroBanner.stories.ts similarity index 95% rename from apps/demos/storybook/src/components/media/organisms/HeroBanner/HeroBanner.stories.ts rename to apps/demos/storybook/src/demos/media/organisms/HeroBanner/HeroBanner.stories.ts index 6c2cd5612..7028a9289 100644 --- a/apps/demos/storybook/src/components/media/organisms/HeroBanner/HeroBanner.stories.ts +++ b/apps/demos/storybook/src/demos/media/organisms/HeroBanner/HeroBanner.stories.ts @@ -7,7 +7,7 @@ const meta: Meta = { parameters: { layout: 'fullscreen', }, - tags: ['autodocs'], + tags: ['autodocs', 'media'], argTypes: { dataElb: { name: 'walkerOS Data', diff --git a/apps/demos/storybook/src/components/media/organisms/HeroBanner/HeroBanner.tsx b/apps/demos/storybook/src/demos/media/organisms/HeroBanner/HeroBanner.tsx similarity index 82% rename from apps/demos/storybook/src/components/media/organisms/HeroBanner/HeroBanner.tsx rename to apps/demos/storybook/src/demos/media/organisms/HeroBanner/HeroBanner.tsx index 313632c7d..2b673e82a 100644 --- a/apps/demos/storybook/src/components/media/organisms/HeroBanner/HeroBanner.tsx +++ b/apps/demos/storybook/src/demos/media/organisms/HeroBanner/HeroBanner.tsx @@ -1,7 +1,7 @@ -import { Image } from '../../atoms/Image'; +import { Image } from '../../../../shared/atoms/Image'; import { BannerText } from '../../molecules/BannerText'; import { TaggedButton } from '../../molecules/TaggedButton'; -import { createTrackingProps, type DataElb } from '../../../../utils/tagger'; +import { createTrackingProps, type DataElb } from '../../../../shared/tagger'; import { assign } from '@walkeros/core'; export interface HeroBannerProps { @@ -44,8 +44,8 @@ export const HeroBanner = ({ - {/* Overlay */} -
+ {/* Overlay: the page ground fades into the image, so the text reads in both themes */} +
{/* Content */}
diff --git a/apps/demos/storybook/src/components/media/organisms/HeroBanner/index.ts b/apps/demos/storybook/src/demos/media/organisms/HeroBanner/index.ts similarity index 100% rename from apps/demos/storybook/src/components/media/organisms/HeroBanner/index.ts rename to apps/demos/storybook/src/demos/media/organisms/HeroBanner/index.ts diff --git a/apps/demos/storybook/src/components/media/organisms/PromotionBanner/PromotionBanner.stories.ts b/apps/demos/storybook/src/demos/media/organisms/PromotionBanner/PromotionBanner.stories.ts similarity index 83% rename from apps/demos/storybook/src/components/media/organisms/PromotionBanner/PromotionBanner.stories.ts rename to apps/demos/storybook/src/demos/media/organisms/PromotionBanner/PromotionBanner.stories.ts index 9c75457c3..d14b73e11 100644 --- a/apps/demos/storybook/src/components/media/organisms/PromotionBanner/PromotionBanner.stories.ts +++ b/apps/demos/storybook/src/demos/media/organisms/PromotionBanner/PromotionBanner.stories.ts @@ -7,7 +7,7 @@ const meta: Meta = { parameters: { layout: 'fullscreen', }, - tags: ['autodocs'], + tags: ['autodocs', 'media'], }; export default meta; @@ -18,7 +18,7 @@ export const KidsMode: Story = { headline: 'Activate Kids Mode', subtitle: 'Create a safe space for younger viewers.', buttonText: 'Activate Now', - backgroundGradient: 'from-primary-600 to-primary-800', + backgroundGradient: 'from-surface-2 to-surface', }, }; @@ -27,6 +27,6 @@ export const PremiumUpgrade: Story = { headline: 'Upgrade to Premium', subtitle: 'Unlock exclusive content and ad-free viewing.', buttonText: 'Learn More', - backgroundGradient: 'from-primary-700 to-primary-900', + backgroundGradient: 'from-bg-2 to-surface-2', }, }; diff --git a/apps/demos/storybook/src/components/media/organisms/PromotionBanner/PromotionBanner.tsx b/apps/demos/storybook/src/demos/media/organisms/PromotionBanner/PromotionBanner.tsx similarity index 84% rename from apps/demos/storybook/src/components/media/organisms/PromotionBanner/PromotionBanner.tsx rename to apps/demos/storybook/src/demos/media/organisms/PromotionBanner/PromotionBanner.tsx index 16881f1ba..ba63f1b32 100644 --- a/apps/demos/storybook/src/components/media/organisms/PromotionBanner/PromotionBanner.tsx +++ b/apps/demos/storybook/src/demos/media/organisms/PromotionBanner/PromotionBanner.tsx @@ -1,6 +1,6 @@ import { BannerText } from '../../molecules/BannerText'; import { TaggedButton } from '../../molecules/TaggedButton'; -import { createTrackingProps, type DataElb } from '../../../../utils/tagger'; +import { createTrackingProps, type DataElb } from '../../../../shared/tagger'; import { assign } from '@walkeros/core'; export interface PromotionBannerProps { @@ -16,7 +16,7 @@ export const PromotionBanner = ({ headline, subtitle, buttonText, - backgroundGradient = 'from-primary-700 to-primary-900', + backgroundGradient = 'from-surface-2 to-surface', onButtonClick, dataElb, }: PromotionBannerProps) => { @@ -35,7 +35,7 @@ export const PromotionBanner = ({ return (
diff --git a/apps/demos/storybook/src/components/media/organisms/PromotionBanner/index.ts b/apps/demos/storybook/src/demos/media/organisms/PromotionBanner/index.ts similarity index 100% rename from apps/demos/storybook/src/components/media/organisms/PromotionBanner/index.ts rename to apps/demos/storybook/src/demos/media/organisms/PromotionBanner/index.ts diff --git a/apps/demos/storybook/src/demos/media/pages/MediaPage/MediaPage.stories.tsx b/apps/demos/storybook/src/demos/media/pages/MediaPage/MediaPage.stories.tsx new file mode 100644 index 000000000..25ba6827e --- /dev/null +++ b/apps/demos/storybook/src/demos/media/pages/MediaPage/MediaPage.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { MediaPage } from './MediaPage'; +import { useStoryControls } from '../../../../shared/stories/useStoryControls'; + +const ConnectedMediaPage = () => ; + +// No autodocs: the page is one long story with a pinned consent bar. +const meta: Meta = { + title: 'Media/Pages/Media', + tags: ['media'], + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: 'Media', + render: () => , +}; diff --git a/apps/demos/storybook/src/demos/media/pages/MediaPage/MediaPage.tsx b/apps/demos/storybook/src/demos/media/pages/MediaPage/MediaPage.tsx new file mode 100644 index 000000000..e2dc8d5cf --- /dev/null +++ b/apps/demos/storybook/src/demos/media/pages/MediaPage/MediaPage.tsx @@ -0,0 +1,138 @@ +import { Fragment, useState, type ReactElement } from 'react'; +import { ViewSource } from '@walkeros/explorer'; +import logoImg from '../../../../assets/logo.png'; +import { Button } from '../../../../shared/atoms/Button'; +import { Link } from '../../../../shared/atoms/Link'; +import type { DemoPageProps } from '../../../../shared/controls'; +import { LanguageProvider, useText } from '../../../../shared/language'; +import { ConsentBar } from '../../../../shared/organisms/ConsentBar'; +import { Footer } from '../../../../shared/organisms/Footer'; +import { Header, HeaderControls } from '../../../../shared/organisms/Header'; +import { createTrackingProps } from '../../../../shared/tagger'; +import { OnePager } from '../../../../shared/templates/OnePager'; +import { SearchButton } from '../../molecules/SearchButton'; +import { CarouselSection } from '../../organisms/CarouselSection'; +import { HeroBanner } from '../../organisms/HeroBanner'; +import { PromotionBanner } from '../../organisms/PromotionBanner'; +import { + copyright, + documentaries, + extraRows, + filmRecommendations, + footerLinks, + hero, + navLinks, + promotion, + topSeries, +} from '../../data'; + +const MediaContent = ({ controls }: DemoPageProps) => { + const t = useText(); + const [extraRowCount, setExtraRowCount] = useState(0); + const { elb } = controls; + // Source views sit on the outermost tagged element of a section (the + // organism's root) or of a card; no ancestor carries a click action. + const sectionSource = (organism: ReactElement) => ( + {organism} + ); + // Cards fill their slot in the carousel row and keep their width in it + // (shrink-0): the row scrolls, it never squeezes a card. + const cardSource = (card: ReactElement) => ( + + {card} + + ); + + return ( + // The page context the media demo has always sent, its first component + // name included. +
+ + {t('Media + + } + links={navLinks} + controls={} + extras={} + /> + } + footer={
} + consent={} + > + {/* data-elbobserve: the walker auto-registers rows added below at + runtime, so a new row's tagged items fire their events without a + walker re-run. Section anchors sit on the page's own containers, + never on a source view or inside an organism. */} +
+
{sectionSource()}
+
+ {sectionSource( + , + )} +
+
+ {sectionSource( + , + )} +
+
+ {sectionSource()} +
+
+ {sectionSource( + , + )} +
+ + {Array.from({ length: extraRowCount }, (_, index) => { + const row = extraRows[index % extraRows.length]; + return ( + + {sectionSource( + , + )} + + ); + })} + +
+ +
+
+ +
+ ); +}; + +/** The media demo: a streaming site as one page, every section tagged. */ +export const MediaPage = ({ controls }: DemoPageProps) => ( + + + +); diff --git a/apps/demos/storybook/src/demos/media/pages/MediaPage/index.ts b/apps/demos/storybook/src/demos/media/pages/MediaPage/index.ts new file mode 100644 index 000000000..2a195f84a --- /dev/null +++ b/apps/demos/storybook/src/demos/media/pages/MediaPage/index.ts @@ -0,0 +1 @@ +export { MediaPage } from './MediaPage'; diff --git a/apps/demos/storybook/src/demos/media/parity.test.tsx b/apps/demos/storybook/src/demos/media/parity.test.tsx new file mode 100644 index 000000000..88129eaa7 --- /dev/null +++ b/apps/demos/storybook/src/demos/media/parity.test.tsx @@ -0,0 +1,21 @@ +import fixture from '../__fixtures__/media-attributes.json'; +import { testControls } from '../__fixtures__/controls'; +import { elbAttributes } from '../__fixtures__/elbAttributes'; +import { MediaPage } from './pages/MediaPage'; + +// What the shared kit adds to a page that had neither before: the header's +// language global (LanguageToggle) and the footer's read action (Footer). +// Every other tag is the fixture's. +const fromSharedKit = [ + 'data-elbaction=visible:read', + 'data-elbglobals=language:en', +]; + +// The tags the Media page carried before the shared-kit restructure, with the +// default controls. A failing test means a tag moved: fix the code, never the +// fixture. +test('the Media page keeps its tags', () => { + expect(elbAttributes()).toEqual( + [...fixture, ...fromSharedKit].sort(), + ); +}); diff --git a/apps/demos/storybook/src/demos/shop/atoms/ProductImage/ProductImage.stories.tsx b/apps/demos/storybook/src/demos/shop/atoms/ProductImage/ProductImage.stories.tsx new file mode 100644 index 000000000..c8041e72d --- /dev/null +++ b/apps/demos/storybook/src/demos/shop/atoms/ProductImage/ProductImage.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ProductImage } from './ProductImage'; + +const meta: Meta = { + title: 'Shop/Atoms/ProductImage', + component: ProductImage, + parameters: { + layout: 'centered', + }, + tags: ['autodocs', 'shop'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Square: Story = { + args: { + name: 'Everyday Ruck Snack', + }, +}; + +export const Wide: Story = { + args: { + name: 'Everyday Ruck Snack', + aspect: 'wide', + }, +}; + +export const Small: Story = { + args: { + name: 'Cool Cap', + className: 'w-20', + }, +}; diff --git a/apps/demos/storybook/src/demos/shop/atoms/ProductImage/ProductImage.tsx b/apps/demos/storybook/src/demos/shop/atoms/ProductImage/ProductImage.tsx new file mode 100644 index 000000000..b6c4d9697 --- /dev/null +++ b/apps/demos/storybook/src/demos/shop/atoms/ProductImage/ProductImage.tsx @@ -0,0 +1,29 @@ +import { PhotoPlaceholder } from '@walkeros/explorer/design/components'; +import { useText } from '../../../../shared/language'; + +export interface ProductImageProps { + /** Names the image when `alt` is missing. */ + name: string; + alt?: string; + aspect?: 'square' | 'wide'; + className?: string; +} + +/** An offline image placeholder: stripes where the product photo would go. */ +export const ProductImage = ({ + name, + alt, + aspect = 'square', + className = '', +}: ProductImageProps) => { + const t = useText(); + return ( +
+ +
+ ); +}; diff --git a/apps/demos/storybook/src/demos/shop/atoms/ProductImage/index.ts b/apps/demos/storybook/src/demos/shop/atoms/ProductImage/index.ts new file mode 100644 index 000000000..d5921d358 --- /dev/null +++ b/apps/demos/storybook/src/demos/shop/atoms/ProductImage/index.ts @@ -0,0 +1,2 @@ +export { ProductImage } from './ProductImage'; +export type { ProductImageProps } from './ProductImage'; diff --git a/apps/demos/storybook/src/demos/shop/data.ts b/apps/demos/storybook/src/demos/shop/data.ts new file mode 100644 index 000000000..13d09a5e5 --- /dev/null +++ b/apps/demos/storybook/src/demos/shop/data.ts @@ -0,0 +1,230 @@ +// The shop's content, carried over from the static tagging demo +// (apps/demos/tagging/index.html): products, prices, ids and the catalog the +// "Add product" button draws from. + +import type { NavLink } from '../../shared/molecules/NavLinks'; +import type { FooterLink } from '../../shared/organisms/Footer'; + +/** A value shown to people beside the value the tag carries. */ +export interface Labelled { + value: string; + label: string; +} + +export interface Product { + name: string; + price: number; + id?: string; + flag?: Labelled; + imageAlt?: string; +} + +export interface CartItem { + name: string; + color: Labelled; + size: Labelled; + price: number; + currency: string; + imageAlt: string; +} + +export interface SummaryLine { + label: string; + property: string; + amount: number; + currency?: string; + emphasis?: boolean; +} + +export const navLinks: NavLink[] = [ + { label: 'Promotion', href: '#promotion' }, + { label: 'Recommendations', href: '#recommendations' }, + { label: 'Product', href: '#product' }, + { label: 'Checkout', href: '#checkout' }, + { label: 'Order', href: '#order' }, +]; + +/** The cart's value in the header, sent as the `cart_value` global. */ +export const cartValue = 249; + +/** A call to action: its label and the action its click fires. */ +export interface CallToAction { + label: string; + action: string; +} + +export interface PromotionContent { + name: string; + text: string; + category: string; + primaryCta: CallToAction; + secondaryCta: CallToAction; +} + +export const promotion: PromotionContent = { + name: 'Setting up tracking easily', + text: 'Open your console to see how we measure.', + category: 'analytics', + primaryCta: { label: 'Get started', action: 'start' }, + secondaryCta: { label: 'Learn more', action: 'more' }, +}; + +const recommendationAlt = + 'Front of zip tote bag with white canvas, black canvas straps and handle, and black zipper pulls.'; + +export const recommendations: Product[] = [ + { + name: 'Everyday Ruck Snack', + price: 210, + imageAlt: recommendationAlt, + }, + { name: 'Party pants', price: 217, imageAlt: recommendationAlt }, + { + name: 'Cool Cap', + price: 39, + flag: { value: 'schnapper', label: 'Schnapper' }, + imageAlt: recommendationAlt, + }, + { name: 'Hoodie', price: 59, imageAlt: recommendationAlt }, +]; + +export const demoCatalog: Array<{ name: string; price: number }> = [ + { name: 'Sunny Sneakers', price: 89 }, + { name: 'Cosy Hoodie', price: 59 }, + { name: 'Trail Bottle', price: 24 }, + { name: 'Desk Lamp', price: 45 }, + { name: 'Canvas Tote', price: 32 }, + { name: 'Wool Socks', price: 14 }, +]; + +/** + * The product the static demo's addProduct() appends as its `count`th one + * (1-based): it cycles through the catalog and numbers name and id. + */ +export function createDemoProduct(count: number): Product { + const index = (count - 1) % demoCatalog.length; + const item = demoCatalog[index]; + const name = `${item.name} #${count}`; + return { + id: `P${1000 + count}`, + name, + price: item.price, + imageAlt: name, + }; +} + +export interface ProductDetailContent { + id: string; + name: string; + category: string[]; + price: number; + currency: string; + rating: number; + reviews: number; + availability: Labelled; + description: string; + guarantee: string; + imageAlt: string; +} + +export const productDetail: ProductDetailContent = { + id: 'rcksnck', + name: 'Everyday Ruck Snack', + category: ['Travel', 'Bags'], + price: 210, + currency: 'EUR', + rating: 4, + reviews: 1624, + availability: { + value: 'in_stock', + label: 'In stock and ready to ship', + }, + description: + "Don't compromise on snack-carrying capacity with this lightweight and spacious bag. The drawstring top keeps all your favorite chips, crisps, fries, biscuits, crackers, and cookies secure.", + guarantee: 'Lifetime Guarantee', + imageAlt: + 'Model wearing light green backpack with black canvas straps and front zipper pouch.', +}; + +export const checkoutFields = { + countries: ['United States', 'Canada', 'Mexico'], +}; + +// The cart: 210 + 39 makes the header's cart value of 249. +export const cartItems: CartItem[] = [ + { + name: 'Everyday Ruck Snack', + color: { value: 'black', label: 'Black' }, + size: { value: 'large', label: 'Large' }, + price: 210, + currency: 'EUR', + imageAlt: "Front of men's Everyday Ruck Snack in black.", + }, + { + name: 'Cool Cap', + color: { value: 'green', label: 'Green' }, + size: { value: 'onesize', label: 'One-Size' }, + price: 39, + currency: 'EUR', + imageAlt: "Front of men's Cool Cap in green.", + }, +]; + +export const checkoutSummary: SummaryLine[] = [ + { label: 'Subtotal', property: 'subtotal', amount: 249, currency: 'EUR' }, + { label: 'Shipping', property: 'shipping', amount: 5, currency: 'EUR' }, + { label: 'Taxes', property: 'taxes', amount: 5.52, currency: 'EUR' }, + { + label: 'Total', + property: 'total', + amount: 259.52, + currency: 'EUR', + emphasis: true, + }, +]; + +// The completed order is the cart, checked out. +export const orderItems: CartItem[] = cartItems; + +export interface OrderContent { + id: string; + status: string; + title: string; + text: string; + trackingNumber: string; + summary: SummaryLine[]; + address: { lines: string[]; city: string; country: string }; + payment: { brand: string; ending: string; expires: string }; +} + +export const order: OrderContent = { + id: '0rd3r1d', + status: 'Payment successful', + title: 'Thanks for ordering', + text: "We appreciate your order, we're currently processing it. So hang tight and we'll send you confirmation very soon!", + trackingNumber: '51547878755545848512', + summary: [ + { label: 'Subtotal', property: 'subtotal', amount: 249, currency: 'EUR' }, + { label: 'Shipping', property: 'shipping', amount: 5 }, + { label: 'Taxes', property: 'taxes', amount: 5.52 }, + { label: 'Total', property: 'total', amount: 259.52, emphasis: true }, + ], + address: { + lines: ['elbwalker GmbH', 'Gerhofstraße 1-3'], + city: '20354 Hamburg', + country: 'Germany', + }, + payment: { brand: 'Visa', ending: '1337', expires: '12 / 42' }, +}; + +// The demo's footer links lead nowhere. +export const footerLinks: FooterLink[] = [ + 'About', + 'Blog', + 'Jobs', + 'Press', + 'Accessibility', + 'Partners', +].map((label) => ({ label, href: '#' })); + +export const copyright = '© 2024 Your Company, Inc. All rights reserved.'; diff --git a/apps/demos/storybook/src/demos/shop/index.ts b/apps/demos/storybook/src/demos/shop/index.ts new file mode 100644 index 000000000..620871302 --- /dev/null +++ b/apps/demos/storybook/src/demos/shop/index.ts @@ -0,0 +1,27 @@ +// Atoms (the shared ones live in src/shared/atoms) +export * from './atoms/ProductImage'; + +// Molecules +export * from './molecules/Breadcrumb'; +export * from './molecules/CartLineItem'; +export * from './molecules/CartLink'; +export * from './molecules/OrderItem'; +export * from './molecules/ProductCard'; +export * from './molecules/ProductFacts'; +export * from './molecules/PromotionCard'; +export * from './molecules/SummaryRow'; + +// Organisms +export * from './organisms/Checkout'; +export * from './organisms/OrderComplete'; +export * from './organisms/ProductDetail'; +export * from './organisms/PromotionHero'; +export * from './organisms/Recommendations'; + +// Pages +export * from './pages/ShopPage'; + +// Catalog data, the property tag helper and the "Add product" state +export * from './data'; +export * from './tagging'; +export * from './useDemoProducts'; diff --git a/apps/demos/storybook/src/demos/shop/language.test.tsx b/apps/demos/storybook/src/demos/shop/language.test.tsx new file mode 100644 index 000000000..61130b65c --- /dev/null +++ b/apps/demos/storybook/src/demos/shop/language.test.tsx @@ -0,0 +1,62 @@ +import { render } from '@testing-library/react'; +import { testControls } from '../__fixtures__/controls'; +import { elbAttributes } from '../__fixtures__/elbAttributes'; +import { taggedElements } from '../__fixtures__/taggedElements'; +import { elbish } from '../../shared/language'; +import { promotion } from './data'; +import { ShopPage } from './pages/ShopPage'; + +const page = (language: 'en' | 'elbish') => ( + +); + +test('the language changes no tag but the language global', () => { + const english = elbAttributes(page('en')); + const elbishTags = elbAttributes(page('elbish')); + + expect(english).toContain('data-elbglobals=language:en'); + expect(elbishTags).toContain('data-elbglobals=language:elbish'); + expect(elbishTags).toEqual( + english + .map((pair) => + pair === 'data-elbglobals=language:en' + ? 'data-elbglobals=language:elbish' + : pair, + ) + .sort(), + ); +}); + +test('a value read with #innerText follows the shown language', () => { + const { container } = render(page('elbish')); + const name = container.querySelector( + '[data-elb-promotion="name:#innerText"]', + ); + expect(name?.textContent).toBe(elbish(promotion.name)); +}); + +test('the visible text changes', () => { + const english = render(page('en')).container; + const translated = render(page('elbish')).container; + const headings = (container: HTMLElement) => + Array.from(container.querySelectorAll('h2'), (h) => h.textContent); + + expect(headings(english)).toContain('Recommendations'); + expect(headings(translated)).toContain(elbish('Recommendations')); + expect(headings(translated)).not.toContain('Recommendations'); +}); + +// The site switches the language without a reload, and walker.js keeps the +// triggers it registered on the tagged elements: a toggle must not remount +// them. +test('a language toggle keeps every tagged element', () => { + const { container, rerender } = render(page('en')); + const before = taggedElements(container); + expect(before.length).toBeGreaterThan(0); + + rerender(page('elbish')); + const after = taggedElements(container); + + expect(after).toHaveLength(before.length); + after.forEach((element, index) => expect(element).toBe(before[index])); +}); diff --git a/apps/demos/storybook/src/demos/shop/molecules/Breadcrumb/Breadcrumb.stories.ts b/apps/demos/storybook/src/demos/shop/molecules/Breadcrumb/Breadcrumb.stories.ts new file mode 100644 index 000000000..35f22e979 --- /dev/null +++ b/apps/demos/storybook/src/demos/shop/molecules/Breadcrumb/Breadcrumb.stories.ts @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Breadcrumb } from './Breadcrumb'; +import { productDetail } from '../../data'; + +const meta: Meta = { + title: 'Shop/Molecules/Breadcrumb', + component: Breadcrumb, + parameters: { + layout: 'centered', + }, + tags: ['autodocs', 'shop'], +}; + +export default meta; +type Story = StoryObj; + +export const Category: Story = { + args: { + items: productDetail.category, + }, +}; diff --git a/apps/demos/storybook/src/demos/shop/molecules/Breadcrumb/Breadcrumb.tsx b/apps/demos/storybook/src/demos/shop/molecules/Breadcrumb/Breadcrumb.tsx new file mode 100644 index 000000000..74dec703d --- /dev/null +++ b/apps/demos/storybook/src/demos/shop/molecules/Breadcrumb/Breadcrumb.tsx @@ -0,0 +1,26 @@ +import type { HTMLAttributes } from 'react'; +import { Icon } from '../../../../shared/atoms/Icon'; +import { Link } from '../../../../shared/atoms/Link'; +import { useText } from '../../../../shared/language'; + +export interface BreadcrumbProps extends HTMLAttributes { + items: string[]; +} + +export const Breadcrumb = ({ items, ...rest }: BreadcrumbProps) => { + const t = useText(); + return ( + + ); +}; diff --git a/apps/demos/storybook/src/demos/shop/molecules/Breadcrumb/index.ts b/apps/demos/storybook/src/demos/shop/molecules/Breadcrumb/index.ts new file mode 100644 index 000000000..11e0f1574 --- /dev/null +++ b/apps/demos/storybook/src/demos/shop/molecules/Breadcrumb/index.ts @@ -0,0 +1,2 @@ +export { Breadcrumb } from './Breadcrumb'; +export type { BreadcrumbProps } from './Breadcrumb'; diff --git a/apps/demos/storybook/src/demos/shop/molecules/CartLineItem/CartLineItem.stories.tsx b/apps/demos/storybook/src/demos/shop/molecules/CartLineItem/CartLineItem.stories.tsx new file mode 100644 index 000000000..6fcbfae45 --- /dev/null +++ b/apps/demos/storybook/src/demos/shop/molecules/CartLineItem/CartLineItem.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { CartLineItem } from './CartLineItem'; +import { cartItems } from '../../data'; + +const meta: Meta = { + title: 'Shop/Molecules/CartLineItem', + component: CartLineItem, + parameters: { + layout: 'centered', + }, + tags: ['autodocs', 'shop'], + decorators: [ + (Story) => ( +
    + +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + item: cartItems[0], + }, +}; + +export const OneSize: Story = { + args: { + item: cartItems[1], + }, +}; diff --git a/apps/demos/storybook/src/demos/shop/molecules/CartLineItem/CartLineItem.tsx b/apps/demos/storybook/src/demos/shop/molecules/CartLineItem/CartLineItem.tsx new file mode 100644 index 000000000..ed5619ef4 --- /dev/null +++ b/apps/demos/storybook/src/demos/shop/molecules/CartLineItem/CartLineItem.tsx @@ -0,0 +1,84 @@ +import { useId } from 'react'; +import { assign } from '@walkeros/core'; +import { Button } from '../../../../shared/atoms/Button'; +import { Icon } from '../../../../shared/atoms/Icon'; +import { Price } from '../../../../shared/atoms/Price'; +import { ProductImage } from '../../atoms/ProductImage'; +import { Select } from '../../../../shared/atoms/Select'; +import { useText } from '../../../../shared/language'; +import { ProductFacts } from '../ProductFacts'; +import type { CartItem } from '../../data'; +import { propertyProps } from '../../tagging'; +import { createTrackingProps, type DataElb } from '../../../../shared/tagger'; + +export interface CartLineItemProps { + item: CartItem; + onRemove?: () => void; + dataElb?: DataElb; +} + +const quantities = ['1', '2', '3', '4', '5', '6', '7', '8']; + +export const CartLineItem = ({ + item, + onRemove, + dataElb, +}: CartLineItemProps) => { + const quantityId = useId(); + const t = useText(); + const { name, color, size, price, currency, imageAlt } = item; + const trackingProps = createTrackingProps( + assign({ entity: 'product' }, dataElb), + ); + + return ( +
  • + +
    +
    + +
    + +
    +
    +
    + +
    + + } + +); + +export const Checkout = ({ + items = cartItems, + summary = checkoutSummary, + countries = checkoutFields.countries, + onRemove, + onConfirm, + dataElb, +}: CheckoutProps) => { + const t = useText(); + const trackingProps = createTrackingProps( + assign({ context: { shopping: 'checkout' } }, dataElb), + ); + + return ( +
    +

    {t('Checkout')}

    +
    +
    + + {t('Contact information')} + + + {(id) => ( + + )} + +
    + + {t('Shipping information')} + +
    + {addressFields.map(renderField)} + + {(id) => ( + +); diff --git a/apps/demos/storybook/src/shared/atoms/Input/index.ts b/apps/demos/storybook/src/shared/atoms/Input/index.ts new file mode 100644 index 000000000..791c745ee --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/Input/index.ts @@ -0,0 +1,2 @@ +export { Input, fieldClassName } from './Input'; +export type { InputProps } from './Input'; diff --git a/apps/demos/storybook/src/shared/atoms/Link/Link.stories.ts b/apps/demos/storybook/src/shared/atoms/Link/Link.stories.ts new file mode 100644 index 000000000..bd06b5a36 --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/Link/Link.stories.ts @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Link } from './Link'; + +const meta: Meta = { + title: 'Shared/Atoms/Link', + component: Link, + parameters: { + layout: 'centered', + }, + tags: ['autodocs', 'shop', 'media'], +}; + +export default meta; +type Story = StoryObj; + +export const Subtle: Story = { + args: { + className: 'text-ui', + children: 'Solutions', + }, +}; + +export const Primary: Story = { + args: { + variant: 'primary', + children: 'Get started', + }, +}; + +export const Text: Story = { + args: { + variant: 'link', + children: 'Learn more', + }, +}; diff --git a/apps/demos/storybook/src/shared/atoms/Link/Link.tsx b/apps/demos/storybook/src/shared/atoms/Link/Link.tsx new file mode 100644 index 000000000..9fdbcd6e9 --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/Link/Link.tsx @@ -0,0 +1,36 @@ +import type { AnchorHTMLAttributes, MouseEvent } from 'react'; +import { buttonVariants, type ButtonVariant } from '../Button'; + +export type LinkVariant = ButtonVariant | 'subtle'; + +const variants: Record = { + ...buttonVariants, + subtle: 'text-fg-2 transition hover:text-fg', +}; + +export interface LinkProps extends AnchorHTMLAttributes { + variant?: LinkVariant; +} + +export const Link = ({ + variant = 'subtle', + href = '#', + className = '', + onClick, + ...rest +}: LinkProps) => { + // The demo's links lead nowhere: keep the reader where they are. + const handleClick = (event: MouseEvent) => { + if (href === '#') event.preventDefault(); + onClick?.(event); + }; + + return ( + + ); +}; diff --git a/apps/demos/storybook/src/shared/atoms/Link/index.ts b/apps/demos/storybook/src/shared/atoms/Link/index.ts new file mode 100644 index 000000000..bc7adb75d --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/Link/index.ts @@ -0,0 +1,2 @@ +export { Link } from './Link'; +export type { LinkProps, LinkVariant } from './Link'; diff --git a/apps/demos/storybook/src/shared/atoms/Price/Price.stories.ts b/apps/demos/storybook/src/shared/atoms/Price/Price.stories.ts new file mode 100644 index 000000000..3b7ad468b --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/Price/Price.stories.ts @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Price } from './Price'; + +const meta: Meta = { + title: 'Shared/Atoms/Price', + component: Price, + parameters: { + layout: 'centered', + }, + tags: ['autodocs', 'shop', 'media'], +}; + +export default meta; +type Story = StoryObj; + +export const Whole: Story = { + args: { + amount: 140, + variant: 'body-lg', + }, +}; + +export const WithCents: Story = { + args: { + amount: 220, + fractionDigits: 2, + }, +}; + +export const Fraction: Story = { + args: { + amount: 5.52, + }, +}; diff --git a/apps/demos/storybook/src/shared/atoms/Price/Price.tsx b/apps/demos/storybook/src/shared/atoms/Price/Price.tsx new file mode 100644 index 000000000..92a2ba932 --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/Price/Price.tsx @@ -0,0 +1,25 @@ +import { Text, type TextProps } from '../Text'; + +/** Euro amount as the shop shows it: whole amounts plain, cents with two digits. */ +export function formatPrice(amount: number, fractionDigits?: 0 | 2): string { + const digits = fractionDigits ?? (Number.isInteger(amount) ? 0 : 2); + return `€${amount.toFixed(digits)}`; +} + +export interface PriceProps extends Omit { + amount: number; + fractionDigits?: 0 | 2; +} + +export const Price = ({ + amount, + fractionDigits, + as = 'p', + variant = 'ui', + tone = 'fg', + ...rest +}: PriceProps) => ( + + {formatPrice(amount, fractionDigits)} + +); diff --git a/apps/demos/storybook/src/shared/atoms/Price/index.ts b/apps/demos/storybook/src/shared/atoms/Price/index.ts new file mode 100644 index 000000000..d20e10207 --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/Price/index.ts @@ -0,0 +1,2 @@ +export { Price, formatPrice } from './Price'; +export type { PriceProps } from './Price'; diff --git a/apps/demos/storybook/src/shared/atoms/Select/Select.stories.ts b/apps/demos/storybook/src/shared/atoms/Select/Select.stories.ts new file mode 100644 index 000000000..0214515a2 --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/Select/Select.stories.ts @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Select } from './Select'; + +const meta: Meta = { + title: 'Shared/Atoms/Select', + component: Select, + parameters: { + layout: 'centered', + }, + tags: ['autodocs', 'shop', 'media'], +}; + +export default meta; +type Story = StoryObj; + +export const Country: Story = { + args: { + name: 'country', + autoComplete: 'country-name', + options: ['United States', 'Canada', 'Mexico'], + 'aria-label': 'Country', + }, +}; + +export const Quantity: Story = { + args: { + name: 'quantity', + options: ['1', '2', '3', '4', '5', '6', '7', '8'], + className: 'w-auto', + 'aria-label': 'Quantity', + }, +}; diff --git a/apps/demos/storybook/src/shared/atoms/Select/Select.tsx b/apps/demos/storybook/src/shared/atoms/Select/Select.tsx new file mode 100644 index 000000000..1fc8f3910 --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/Select/Select.tsx @@ -0,0 +1,28 @@ +import type { SelectHTMLAttributes } from 'react'; +import { fieldClassName } from '../Input'; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit< + SelectHTMLAttributes, + 'children' +> { + options: Array; +} + +export const Select = ({ options, className = '', ...rest }: SelectProps) => ( + +); diff --git a/apps/demos/storybook/src/shared/atoms/Select/index.ts b/apps/demos/storybook/src/shared/atoms/Select/index.ts new file mode 100644 index 000000000..8faae249d --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/Select/index.ts @@ -0,0 +1,2 @@ +export { Select } from './Select'; +export type { SelectOption, SelectProps } from './Select'; diff --git a/apps/demos/storybook/src/shared/atoms/StarRating/StarRating.stories.ts b/apps/demos/storybook/src/shared/atoms/StarRating/StarRating.stories.ts new file mode 100644 index 000000000..581d02f0d --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/StarRating/StarRating.stories.ts @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { StarRating } from './StarRating'; + +const meta: Meta = { + title: 'Shared/Atoms/StarRating', + component: StarRating, + parameters: { + layout: 'centered', + }, + tags: ['autodocs', 'shop', 'media'], +}; + +export default meta; +type Story = StoryObj; + +export const FourOfFive: Story = { + args: { + rating: 4, + }, +}; + +export const Full: Story = { + args: { + rating: 5, + }, +}; diff --git a/apps/demos/storybook/src/shared/atoms/StarRating/StarRating.tsx b/apps/demos/storybook/src/shared/atoms/StarRating/StarRating.tsx new file mode 100644 index 000000000..55987cc1e --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/StarRating/StarRating.tsx @@ -0,0 +1,29 @@ +import type { HTMLAttributes } from 'react'; +import { Icon } from '../Icon'; +import { useText } from '../../language'; + +export interface StarRatingProps extends HTMLAttributes { + rating: number; + max?: number; +} + +export const StarRating = ({ + rating, + max = 5, + className = '', + ...rest +}: StarRatingProps) => { + const t = useText(); + return ( +
    + {Array.from({ length: max }, (_, index) => ( + + ))} + {t(`${rating} out of ${max} stars`)} +
    + ); +}; diff --git a/apps/demos/storybook/src/shared/atoms/StarRating/index.ts b/apps/demos/storybook/src/shared/atoms/StarRating/index.ts new file mode 100644 index 000000000..d5bd58bd1 --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/StarRating/index.ts @@ -0,0 +1,2 @@ +export { StarRating } from './StarRating'; +export type { StarRatingProps } from './StarRating'; diff --git a/apps/demos/storybook/src/shared/atoms/Text/Text.stories.ts b/apps/demos/storybook/src/shared/atoms/Text/Text.stories.ts new file mode 100644 index 000000000..0b20a92f4 --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/Text/Text.stories.ts @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Text } from './Text'; + +const meta: Meta = { + title: 'Shared/Atoms/Text', + component: Text, + parameters: { + layout: 'centered', + }, + tags: ['autodocs', 'shop', 'media'], +}; + +export default meta; +type Story = StoryObj; + +export const Body: Story = { + args: { + children: + "Don't compromise on snack-carrying capacity with this lightweight and spacious bag.", + }, +}; + +export const Lead: Story = { + args: { + variant: 'lead', + children: 'Open your console to see how we measure.', + }, +}; + +export const Small: Story = { + args: { + variant: 'small', + tone: 'fg-3', + className: 'font-normal', + children: '1624 reviews', + }, +}; diff --git a/apps/demos/storybook/src/shared/atoms/Text/Text.tsx b/apps/demos/storybook/src/shared/atoms/Text/Text.tsx new file mode 100644 index 000000000..f15199a3d --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/Text/Text.tsx @@ -0,0 +1,50 @@ +import type { HTMLAttributes } from 'react'; + +export type TextVariant = + | 'lead' + | 'body-lg' + | 'body' + | 'ui' + | 'small' + | 'label' + | 'eyebrow'; + +export type TextTone = 'fg' | 'fg-2' | 'fg-3' | 'link'; + +export type TextElement = 'p' | 'span' | 'div' | 'dt' | 'dd' | 'strong'; + +const variants: Record = { + lead: 'text-lead', + 'body-lg': 'text-body-lg', + body: 'text-body', + ui: 'text-ui', + small: 'text-small', + label: 'text-label', + eyebrow: 'text-eyebrow', +}; + +const tones: Record = { + fg: 'text-fg', + 'fg-2': 'text-fg-2', + 'fg-3': 'text-fg-3', + link: 'text-link', +}; + +export interface TextProps extends HTMLAttributes { + as?: TextElement; + variant?: TextVariant; + tone?: TextTone; +} + +export const Text = ({ + as: Tag = 'p', + variant = 'body', + tone = 'fg-2', + className = '', + ...rest +}: TextProps) => ( + +); diff --git a/apps/demos/storybook/src/shared/atoms/Text/index.ts b/apps/demos/storybook/src/shared/atoms/Text/index.ts new file mode 100644 index 000000000..7759687b1 --- /dev/null +++ b/apps/demos/storybook/src/shared/atoms/Text/index.ts @@ -0,0 +1,2 @@ +export { Text } from './Text'; +export type { TextElement, TextProps, TextTone, TextVariant } from './Text'; diff --git a/apps/demos/storybook/src/shared/consent.test.tsx b/apps/demos/storybook/src/shared/consent.test.tsx new file mode 100644 index 000000000..fd904f3d7 --- /dev/null +++ b/apps/demos/storybook/src/shared/consent.test.tsx @@ -0,0 +1,264 @@ +import { act, renderHook } from '@testing-library/react'; +import type { Elb, WalkerOS } from '@walkeros/core'; +import { useDemoConsent } from './consent'; + +// A fake elb that records every call. It stands in for the page's elb (a +// queue or the browser source's push); nothing is delivered and no delivery +// is claimed, these tests check which commands the consent bar sends. +function fakeElb( + answer: () => Elb.PushResult | Promise = () => ({ + ok: true, + }), +) { + const calls: unknown[][] = []; + const elb: WalkerOS.Elb = async (...args: unknown[]) => { + calls.push(args); + return answer(); + }; + return { elb, calls }; +} + +// Lets every pending send settle (the hook uses no timers). +const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + +const accepted = ['walker consent', { functional: true, marketing: true }]; +const denied = ['walker consent', { functional: true, marketing: false }]; + +beforeEach(() => { + jest.useRealTimers(); + localStorage.clear(); +}); + +test('Accept sends the device, then the consent; a second Accept reuses the device', async () => { + jest.spyOn(Math, 'random').mockReturnValueOnce(0.1).mockReturnValueOnce(0.2); + const { elb, calls } = fakeElb(); + const { result } = renderHook(() => useDemoConsent(() => elb)); + + act(() => result.current.onAccept()); + await settle(); + + expect(result.current.state).toBe('accepted'); + expect(result.current.notice).toBeUndefined(); + expect(calls).toHaveLength(2); + const [userCall, consentCall] = calls; + expect(userCall[0]).toBe('walker user'); + expect(userCall[1]).toStrictEqual({ + device: expect.stringMatching(/^[0-9a-z]{5}$/), + }); + expect(consentCall).toStrictEqual(accepted); + + act(() => result.current.onAccept()); + await settle(); + expect(calls[2]).toStrictEqual(userCall); + expect(calls[3]).toStrictEqual(accepted); +}); + +test('Deny sends the consent, then removes the device', async () => { + const { elb, calls } = fakeElb(); + const { result } = renderHook(() => useDemoConsent(() => elb)); + + act(() => result.current.onDeny()); + await settle(); + + expect(result.current.state).toBe('denied'); + expect(calls).toStrictEqual([denied, ['walker user', { device: undefined }]]); +}); + +test('no consent command carries a user id or a session', async () => { + const { elb, calls } = fakeElb(); + const { result } = renderHook(() => useDemoConsent(() => elb)); + + act(() => result.current.onAccept()); + await settle(); + act(() => result.current.onDeny()); + await settle(); + act(() => result.current.onAccept()); + await settle(); + + expect(calls).toHaveLength(6); + for (const [command, data] of calls) { + if (command !== 'walker user') continue; + expect( + typeof data === 'object' && data !== null && Object.keys(data), + ).toEqual(['device']); + } +}); + +test('Reset forgets the choice and sends nothing', async () => { + const { elb, calls } = fakeElb(); + const { result } = renderHook(() => useDemoConsent(() => elb)); + + act(() => result.current.onAccept()); + await settle(); + act(() => result.current.onReset()); + await settle(); + + expect(result.current.state).toBe('unknown'); + expect(result.current.notice).toBeUndefined(); + expect(calls).toHaveLength(2); +}); + +test('without a running walkerOS the choice shows, with a notice', () => { + const { result } = renderHook(() => useDemoConsent(() => undefined)); + + act(() => result.current.onAccept()); + + expect(result.current.state).toBe('accepted'); + expect(result.current.notice).toBe('Not sent: walkerOS is not running.'); +}); + +test('a failed send shows its error', async () => { + const { elb } = fakeElb(() => ({ ok: false, error: 'consent rejected' })); + const { result } = renderHook(() => useDemoConsent(() => elb)); + + act(() => result.current.onDeny()); + await settle(); + + expect(result.current.state).toBe('denied'); + expect(result.current.notice).toBe('Not sent: consent rejected'); +}); + +test('a send that throws shows its message', async () => { + const { elb } = fakeElb(() => Promise.reject(new Error('queue closed'))); + const { result } = renderHook(() => useDemoConsent(() => elb)); + + act(() => result.current.onAccept()); + await settle(); + + expect(result.current.notice).toBe('Not sent: queue closed'); +}); + +test('an earlier send that settles late never overwrites a later choice', async () => { + let release: (result: Elb.PushResult) => void = () => {}; + const late = new Promise((resolve) => { + release = resolve; + }); + const answers: Array> = [late]; + const { elb, calls } = fakeElb(() => answers.shift() ?? { ok: true }); + const { result } = renderHook(() => useDemoConsent(() => elb)); + + act(() => result.current.onAccept()); // its first send waits + act(() => result.current.onDeny()); + await settle(); + release({ ok: false, error: 'too late' }); + await settle(); + + expect(result.current.state).toBe('denied'); + expect(result.current.notice).toBeUndefined(); + // The replaced Accept stops after its first command: Deny's stand last. + expect(calls).toHaveLength(3); + expect(calls.at(-1)).toStrictEqual(['walker user', { device: undefined }]); +}); + +test('a Reset stops a choice still on its way', async () => { + let release: (result: Elb.PushResult) => void = () => {}; + const late = new Promise((resolve) => { + release = resolve; + }); + const answers: Array> = [late]; + const { elb, calls } = fakeElb(() => answers.shift() ?? { ok: true }); + const { result } = renderHook(() => useDemoConsent(() => elb)); + + act(() => result.current.onAccept()); + act(() => result.current.onReset()); + release({ ok: true }); + await settle(); + + expect(result.current.state).toBe('unknown'); + expect(calls).toHaveLength(1); +}); + +describe('persist', () => { + test('stores the choice and the device id', async () => { + const { elb, calls } = fakeElb(); + const { result } = renderHook(() => + useDemoConsent(() => elb, { persist: true }), + ); + + act(() => result.current.onAccept()); + await settle(); + + expect(localStorage.getItem('consentState')).toBe('accepted'); + expect(calls[0]).toStrictEqual([ + 'walker user', + { device: localStorage.getItem('demoDeviceId') }, + ]); + }); + + test('re-applies a stored acceptance on mount with the stored device', async () => { + localStorage.setItem('consentState', 'accepted'); + localStorage.setItem('demoDeviceId', 'abcde'); + const { elb, calls } = fakeElb(); + const { result } = renderHook(() => + useDemoConsent(() => elb, { persist: true }), + ); + await settle(); + + expect(result.current.state).toBe('accepted'); + expect(calls).toStrictEqual([ + ['walker user', { device: 'abcde' }], + accepted, + ]); + }); + + test('re-applies a stored denial on mount', async () => { + localStorage.setItem('consentState', 'denied'); + const { elb, calls } = fakeElb(); + const { result } = renderHook(() => + useDemoConsent(() => elb, { persist: true }), + ); + await settle(); + + expect(result.current.state).toBe('denied'); + expect(calls).toStrictEqual([ + denied, + ['walker user', { device: undefined }], + ]); + }); + + test('Reset removes the choice and keeps the device id', async () => { + const { elb } = fakeElb(); + const { result } = renderHook(() => + useDemoConsent(() => elb, { persist: true }), + ); + + act(() => result.current.onAccept()); + await settle(); + const device = localStorage.getItem('demoDeviceId'); + act(() => result.current.onReset()); + + expect(localStorage.getItem('consentState')).toBeNull(); + expect(localStorage.getItem('demoDeviceId')).toBe(device); + }); + + test('works when storage is blocked', async () => { + const blocked = () => { + throw new Error('storage blocked'); + }; + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(blocked); + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(blocked); + const { elb, calls } = fakeElb(); + const { result } = renderHook(() => + useDemoConsent(() => elb, { persist: true }), + ); + + act(() => result.current.onAccept()); + await settle(); + + expect(result.current.state).toBe('accepted'); + expect(calls).toHaveLength(2); + }); + + test('without persist nothing is stored', async () => { + const { elb } = fakeElb(); + const { result } = renderHook(() => useDemoConsent(() => elb)); + + act(() => result.current.onAccept()); + await settle(); + + expect(localStorage.length).toBe(0); + }); +}); diff --git a/apps/demos/storybook/src/shared/consent.ts b/apps/demos/storybook/src/shared/consent.ts new file mode 100644 index 000000000..4d22c0e1d --- /dev/null +++ b/apps/demos/storybook/src/shared/consent.ts @@ -0,0 +1,176 @@ +import { useEffect, useRef, useState } from 'react'; +import type { Elb, WalkerOS } from '@walkeros/core'; +import { notRunning } from './controls'; + +export type ConsentState = 'unknown' | 'accepted' | 'denied'; + +/** What the consent bar shows and calls. */ +export interface DemoConsent { + state: ConsentState; + /** A problem with the last choice, such as consent that was not sent. */ + notice?: string; + onAccept(): void; + onDeny(): void; + onReset(): void; +} + +export interface DemoConsentOptions { + /** + * Remembers the choice and the device id in `localStorage` and applies a + * remembered choice again on mount, as a CMP does. For the demo site; the + * stories keep both in story state. + */ + persist?: boolean; +} + +const stateKey = 'consentState'; +// Not `elbDeviceId`: that key belongs to the session source. +const deviceKey = 'demoDeviceId'; + +// Storage can be missing or blocked (private mode, blocked site data); the +// demo then works without remembering. +function readStored(key: string): string | null { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} + +function writeStored(key: string, value: string): void { + try { + window.localStorage.setItem(key, value); + } catch { + // Not remembered; the choice still applies. + } +} + +function removeStored(key: string): void { + try { + window.localStorage.removeItem(key); + } catch { + // Nothing to forget. + } +} + +function storedState(): ConsentState { + const value = readStored(stateKey); + return value === 'accepted' || value === 'denied' ? value : 'unknown'; +} + +// The persona owns `user.id`; consent only ever touches `user.device`. +// A choice that a later one replaced while its first command was on the way +// stops there (`isCurrent`): the later choice's commands stand. +async function sendAccept( + elb: WalkerOS.Elb, + device: string, + isCurrent: () => boolean, +): Promise { + const results = [await elb('walker user', { device })]; + if (isCurrent()) + results.push( + await elb('walker consent', { functional: true, marketing: true }), + ); + return results; +} + +async function sendDeny( + elb: WalkerOS.Elb, + isCurrent: () => boolean, +): Promise { + const results = [ + await elb('walker consent', { functional: true, marketing: false }), + ]; + if (isCurrent()) + results.push(await elb('walker user', { device: undefined })); + return results; +} + +/** + * The demo CMP behind the consent bar. Accept sends `walker user` with a short + * random device id (made once, reused after) and `walker consent` with + * marketing; Deny sends `walker consent` without marketing and removes the + * device; Reset forgets the choice and sends nothing. A send that does not + * reach walkerOS shows as the notice. + */ +export function useDemoConsent( + getElb: () => WalkerOS.Elb | undefined, + options: DemoConsentOptions = {}, +): DemoConsent { + const persist = options.persist ?? false; + const [state, setState] = useState(() => + persist ? storedState() : 'unknown', + ); + const [notice, setNotice] = useState(); + const deviceRef = useRef(undefined); + // Only the latest choice may report: an earlier send that settles late + // must not overwrite the notice of a later one. + const attemptRef = useRef(0); + const appliedRef = useRef(false); + + const device = (): string => { + if (!deviceRef.current) { + deviceRef.current = + (persist ? readStored(deviceKey) : null) ?? + Math.random().toString(36).slice(2, 7); + if (persist) writeStored(deviceKey, deviceRef.current); + } + return deviceRef.current; + }; + + const send = (granted: boolean) => { + const attempt = ++attemptRef.current; + const elb = getElb(); + if (!elb) { + setNotice(`Not sent: ${notRunning}`); + return; + } + setNotice(undefined); + + const isCurrent = () => attempt === attemptRef.current; + (granted ? sendAccept(elb, device(), isCurrent) : sendDeny(elb, isCurrent)) + .then((results) => { + if (!isCurrent()) return; + const failed = results.find((result) => !result.ok); + if (failed) + setNotice( + `Not sent: ${failed.error ?? 'walkerOS rejected the command.'}`, + ); + }) + .catch((error: unknown) => { + if (!isCurrent()) return; + setNotice( + `Not sent: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + }; + + const choose = (granted: boolean) => { + const next = granted ? 'accepted' : 'denied'; + setState(next); + if (persist) writeStored(stateKey, next); + send(granted); + }; + + // A remembered choice applies again once, on mount (only with persist can + // the first state be other than unknown). + useEffect(() => { + if (appliedRef.current) return; + appliedRef.current = true; + if (state !== 'unknown') send(state === 'accepted'); + // Mount only: later choices send from their own callbacks. + }, []); + + return { + state, + notice, + onAccept: () => choose(true), + onDeny: () => choose(false), + onReset: () => { + attemptRef.current += 1; + setState('unknown'); + setNotice(undefined); + if (persist) removeStored(stateKey); + }, + }; +} diff --git a/apps/demos/storybook/src/shared/controls.test.ts b/apps/demos/storybook/src/shared/controls.test.ts new file mode 100644 index 000000000..f9ca3844f --- /dev/null +++ b/apps/demos/storybook/src/shared/controls.test.ts @@ -0,0 +1,20 @@ +import type { SourceBrowser } from '@walkeros/web-source-browser'; +import { elbAtApplyFrom } from './controls'; + +test('looks the elb up at each call, not when made', async () => { + let running: SourceBrowser.Push | undefined = undefined; + const elb = elbAtApplyFrom(() => running); + + expect(await elb('walker init', document.body)).toStrictEqual({ + ok: false, + error: 'walkerOS is not running.', + }); + + const calls: unknown[][] = []; + running = async (...args: unknown[]) => { + calls.push(args); + return { ok: true }; + }; + expect(await elb('walker init', document.body)).toStrictEqual({ ok: true }); + expect(calls).toStrictEqual([['walker init', document.body]]); +}); diff --git a/apps/demos/storybook/src/shared/controls.ts b/apps/demos/storybook/src/shared/controls.ts new file mode 100644 index 000000000..6fdd03f4c --- /dev/null +++ b/apps/demos/storybook/src/shared/controls.ts @@ -0,0 +1,43 @@ +import type { SourceBrowser } from '@walkeros/web-source-browser'; +import type { DemoConsent } from './consent'; +import type { Language } from './language'; +import type { PersonaKey } from './personas'; + +/** + * What a demo page needs from whoever runs it (the demo site or a story): + * the current choices and the callbacks the shared controls call. The page + * never knows which walkerOS runs it. + */ +export interface DemoControls { + persona: PersonaKey; + onPersonaSwitch: (key: PersonaKey) => void; + language: Language; + onLanguageToggle: (next: Language) => void; + consent: DemoConsent; + /** For ViewSource: looks the running elb up when an edit is applied. */ + elb: SourceBrowser.Push; +} + +export interface DemoPageProps { + controls: DemoControls; +} + +/** Why a command was not sent: no walkerOS runs on the page (yet). */ +export const notRunning = 'walkerOS is not running.'; + +/** + * A stable elb for `DemoControls.elb` that looks the running one up at each + * call, so a walkerOS that starts after the page's first render (the addon, + * a walker.js loaded later) still gets the call. Without one it resolves + * `{ ok: false }` with the reason, which ViewSource shows. + */ +export function elbAtApplyFrom( + getElb: () => SourceBrowser.Push | undefined, +): SourceBrowser.Push { + return (...args: Parameters) => { + const elb = getElb(); + return elb + ? elb(...args) + : Promise.resolve({ ok: false, error: notRunning }); + }; +} diff --git a/apps/demos/storybook/src/shared/language/LanguageContext.test.tsx b/apps/demos/storybook/src/shared/language/LanguageContext.test.tsx new file mode 100644 index 000000000..b928646b8 --- /dev/null +++ b/apps/demos/storybook/src/shared/language/LanguageContext.test.tsx @@ -0,0 +1,59 @@ +import { render } from '@testing-library/react'; +import { LanguageProvider, useLanguage, useText } from './LanguageContext'; + +const Probe = () => { + const t = useText(); + return

    {t('Add to cart')}

    ; +}; + +describe('LanguageContext', () => { + test('English shows the text as it is', () => { + const { container } = render( + + + , + ); + expect(container.textContent).toBe('Add to cart'); + }); + + test('Elbish shows the text in Elbish', () => { + const { container } = render( + + + , + ); + expect(container.textContent).toBe('Eff vu desv'); + expect(container.querySelector('p')?.dataset.language).toBe('elbish'); + }); + + test('without a provider the text function is the identity, in English', () => { + const { container } = render(); + expect(container.textContent).toBe('Add to cart'); + expect(container.querySelector('p')?.dataset.language).toBe('en'); + }); + + test('the text function stays the same while the language does', () => { + const seen: Array<(text: string) => string> = []; + const Collect = () => { + seen.push(useText()); + return null; + }; + const { rerender } = render( + + + , + ); + rerender( + + + , + ); + rerender( + + + , + ); + expect(seen[0]).toBe(seen[1]); + expect(seen[2]).not.toBe(seen[1]); + }); +}); diff --git a/apps/demos/storybook/src/shared/language/LanguageContext.tsx b/apps/demos/storybook/src/shared/language/LanguageContext.tsx new file mode 100644 index 000000000..2a85e68ad --- /dev/null +++ b/apps/demos/storybook/src/shared/language/LanguageContext.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, type ReactNode } from 'react'; +import { elbish, type Language } from './elbish'; + +const LanguageContext = createContext('en'); + +export interface LanguageProviderProps { + language: Language; + children: ReactNode; +} + +/** Sets the language every `useText()` below it shows. */ +export function LanguageProvider({ + language, + children, +}: LanguageProviderProps) { + return ( + + {children} + + ); +} + +/** The current language; English without a provider. */ +export function useLanguage(): Language { + return useContext(LanguageContext); +} + +const asIs = (text: string): string => text; + +/** + * The text function for visible text: the identity in English, `elbish()` in + * Elbish. One function per language, so it is stable across renders. Never + * for a `data-elb*` value: tags stay English. + */ +export function useText(): (text: string) => string { + return useLanguage() === 'elbish' ? elbish : asIs; +} diff --git a/apps/demos/storybook/src/shared/language/elbish.test.ts b/apps/demos/storybook/src/shared/language/elbish.test.ts new file mode 100644 index 000000000..8914feadd --- /dev/null +++ b/apps/demos/storybook/src/shared/language/elbish.test.ts @@ -0,0 +1,40 @@ +import { elbish, toEnglish } from './elbish'; + +const corpus = [ + 'Add to cart', + 'Everyday Ruck Snack', + 'Checkout: 3 items, 249,00 €', + 'MIXED Case words', + 'Grüße aus Hamburg 22767', + 'emoji 🎉 stays', + 'İstanbul at 300 \u212A', + '', +]; + +describe('elbish', () => { + test('maps letters by the fixed table and keeps case', () => { + expect(elbish('Add to cart')).toBe('Eff vu desv'); + expect(elbish('Elbish')).toBe('Imcotj'); + }); + + test('leaves everything that is not a to z unchanged', () => { + expect(elbish('249,00 € 🎉 ü')).toBe('249,00 € 🎉 ü'); + }); + + test.each(corpus)('round trip: %s', (text) => { + expect(toEnglish(elbish(text))).toBe(text); + }); + + test('maps every letter by the table, in both cases', () => { + const letters = 'abcdefghijklmnopqrstuvwxyz'; + expect(elbish(letters)).toBe('ecdfighjoklmnpuqrstvawxyzb'); + expect(elbish(letters.toUpperCase())).toBe('ECDFIGHJOKLMNPUQRSTVAWXYZB'); + }); + + test('the table is a permutation of a to z', () => { + const letters = 'abcdefghijklmnopqrstuvwxyz'; + const mapped = elbish(letters); + expect(new Set(mapped).size).toBe(26); + expect([...mapped].sort().join('')).toBe(letters); + }); +}); diff --git a/apps/demos/storybook/src/shared/language/elbish.ts b/apps/demos/storybook/src/shared/language/elbish.ts new file mode 100644 index 000000000..d01e8f9ff --- /dev/null +++ b/apps/demos/storybook/src/shared/language/elbish.ts @@ -0,0 +1,30 @@ +export type Language = 'en' | 'elbish'; + +const ENGLISH = 'abcdefghijklmnopqrstuvwxyz'; +// Vowels rotate a>e>i>o>u>a; consonants shift one step in bcdf...z, z>b. +const ELBISH = 'ecdfighjoklmnpuqrstvawxyzb'; + +function translate(text: string, from: string, to: string): string { + let out = ''; + for (const char of text) { + const lower = char.toLowerCase(); + const index = lower.length === 1 ? from.indexOf(lower) : -1; + // Only a to z and A to Z map. A character that merely lower-cases to one + // (İ, the Kelvin sign) passes unchanged, so the round trip holds. + if (index < 0 || (char !== lower && char !== lower.toUpperCase())) { + out += char; + continue; + } + const mapped = to.charAt(index); + out += char === lower ? mapped : mapped.toUpperCase(); + } + return out; +} + +/** English to Elbish: letters a to z by a fixed table, case kept, the rest as is. */ +export const elbish = (text: string): string => + translate(text, ENGLISH, ELBISH); + +/** Elbish back to English: `toEnglish(elbish(text)) === text` for every text. */ +export const toEnglish = (text: string): string => + translate(text, ELBISH, ENGLISH); diff --git a/apps/demos/storybook/src/shared/language/index.ts b/apps/demos/storybook/src/shared/language/index.ts new file mode 100644 index 000000000..115a72bb1 --- /dev/null +++ b/apps/demos/storybook/src/shared/language/index.ts @@ -0,0 +1,4 @@ +export { elbish, toEnglish } from './elbish'; +export type { Language } from './elbish'; +export { LanguageProvider, useLanguage, useText } from './LanguageContext'; +export type { LanguageProviderProps } from './LanguageContext'; diff --git a/apps/demos/storybook/src/shared/language/neverTranslated.test.tsx b/apps/demos/storybook/src/shared/language/neverTranslated.test.tsx new file mode 100644 index 000000000..266c2b9cc --- /dev/null +++ b/apps/demos/storybook/src/shared/language/neverTranslated.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render } from '@testing-library/react'; +import { elbish, LanguageProvider } from '.'; +import { LanguageToggle } from '../molecules/LanguageToggle'; +import { UserSwitch } from '../molecules/UserSwitch'; +import { ConsentBar } from '../organisms/ConsentBar'; + +const noop = () => {}; +const notice = 'Not sent: walkerOS is not running.'; + +// The way back and what went wrong stay readable: the language toggle's own +// labels, the persona names and system notices are never translated. +test('in Elbish, the controls and notices keep their English', () => { + const { getByRole, getAllByRole, getByText } = render( + + + + + , + ); + + // The page copy around them is in Elbish, so the provider is at work. + expect(getByRole('button', { name: elbish('Accept') })).toBeTruthy(); + + const toggle = getByRole('button', { + name: 'Language: Elbish. Switch to English.', + }); + expect(toggle.textContent).toBe('EnglishElbish'); + + fireEvent.click(getByRole('button', { name: 'Demo user: Anonymous' })); + expect(getByRole('menu', { name: 'Demo user' })).toBeTruthy(); + expect(getAllByRole('menuitemradio').map((item) => item.textContent)).toEqual( + ['Anonymous', 'Lisa Loyal', 'Sam Sales'], + ); + + expect(getByText(notice)).toBeTruthy(); +}); diff --git a/apps/demos/storybook/src/shared/molecules/ConsentControls/ConsentControls.stories.ts b/apps/demos/storybook/src/shared/molecules/ConsentControls/ConsentControls.stories.ts new file mode 100644 index 000000000..634ba4ad4 --- /dev/null +++ b/apps/demos/storybook/src/shared/molecules/ConsentControls/ConsentControls.stories.ts @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import { ConsentControls } from './ConsentControls'; + +const meta: Meta = { + title: 'Shared/Molecules/ConsentControls', + component: ConsentControls, + parameters: { + layout: 'centered', + }, + tags: ['autodocs', 'shop', 'media'], + args: { + onAccept: fn(), + onDeny: fn(), + onReset: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/demos/storybook/src/shared/molecules/ConsentControls/ConsentControls.tsx b/apps/demos/storybook/src/shared/molecules/ConsentControls/ConsentControls.tsx new file mode 100644 index 000000000..ee090590f --- /dev/null +++ b/apps/demos/storybook/src/shared/molecules/ConsentControls/ConsentControls.tsx @@ -0,0 +1,29 @@ +import { Button } from '../../atoms/Button'; +import { useText } from '../../language'; + +export interface ConsentControlsProps { + onAccept: () => void; + onDeny: () => void; + onReset: () => void; +} + +export const ConsentControls = ({ + onAccept, + onDeny, + onReset, +}: ConsentControlsProps) => { + const t = useText(); + return ( +
    + + + +
    + ); +}; diff --git a/apps/demos/storybook/src/shared/molecules/ConsentControls/index.ts b/apps/demos/storybook/src/shared/molecules/ConsentControls/index.ts new file mode 100644 index 000000000..9c4d12dac --- /dev/null +++ b/apps/demos/storybook/src/shared/molecules/ConsentControls/index.ts @@ -0,0 +1,2 @@ +export { ConsentControls } from './ConsentControls'; +export type { ConsentControlsProps } from './ConsentControls'; diff --git a/apps/demos/storybook/src/shared/molecules/Dropdown/Dropdown.stories.tsx b/apps/demos/storybook/src/shared/molecules/Dropdown/Dropdown.stories.tsx new file mode 100644 index 000000000..7cc624c8d --- /dev/null +++ b/apps/demos/storybook/src/shared/molecules/Dropdown/Dropdown.stories.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Dropdown } from './Dropdown'; + +const meta: Meta = { + title: 'Shared/Molecules/Dropdown', + component: Dropdown, + parameters: { + layout: 'centered', + }, + tags: ['autodocs', 'shop', 'media'], +}; + +export default meta; +type Story = StoryObj; + +const drinks = [ + { value: 'tea', label: 'Tea' }, + { value: 'coffee', label: 'Coffee' }, + { value: 'water', label: 'Water' }, +]; + +export const Default: Story = { + render: () => { + const [drink, setDrink] = useState('coffee'); + return ( + + ); + }, +}; + +export const WithIcon: Story = { + render: () => { + const [user, setUser] = useState('anonymous'); + return ( + + ); + }, +}; diff --git a/apps/demos/storybook/src/shared/molecules/Dropdown/Dropdown.test.tsx b/apps/demos/storybook/src/shared/molecules/Dropdown/Dropdown.test.tsx new file mode 100644 index 000000000..678c0f6e1 --- /dev/null +++ b/apps/demos/storybook/src/shared/molecules/Dropdown/Dropdown.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render } from '@testing-library/react'; +import { Dropdown } from './Dropdown'; + +const options = [ + { value: 'tea', label: 'Tea' }, + { value: 'coffee', label: 'Coffee' }, + { value: 'water', label: 'Water' }, +]; + +const renderDropdown = (onChange: (value: string) => void = () => {}) => + render( + , + ); + +test('the button names the choice and starts closed', () => { + const { getByRole, queryByRole } = renderDropdown(); + const button = getByRole('button', { name: 'Drink: Coffee' }); + expect(button.getAttribute('aria-haspopup')).toBe('menu'); + expect(button.getAttribute('aria-expanded')).toBe('false'); + expect(queryByRole('menu')).toBeNull(); +}); + +test('opening shows every option, the current one checked and focused', () => { + const { getByRole, getAllByRole } = renderDropdown(); + fireEvent.click(getByRole('button', { name: 'Drink: Coffee' })); + + expect(getByRole('menu', { name: 'Drink' })).toBeTruthy(); + const items = getAllByRole('menuitemradio'); + expect(items.map((item) => item.textContent)).toEqual([ + 'Tea', + 'Coffee', + 'Water', + ]); + expect(items.map((item) => item.getAttribute('aria-checked'))).toEqual([ + 'false', + 'true', + 'false', + ]); + expect(items[1].ownerDocument.activeElement).toBe(items[1]); +}); + +test('arrow keys, Home and End move through the menu', () => { + const { getByRole, getAllByRole } = renderDropdown(); + fireEvent.click(getByRole('button', { name: 'Drink: Coffee' })); + const menu = getByRole('menu'); + const items = getAllByRole('menuitemradio'); + const focused = () => + items.findIndex((item) => item === menu.ownerDocument.activeElement); + + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + expect(focused()).toBe(2); + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + expect(focused()).toBe(0); + fireEvent.keyDown(menu, { key: 'End' }); + expect(focused()).toBe(2); + fireEvent.keyDown(menu, { key: 'Home' }); + expect(focused()).toBe(0); + fireEvent.keyDown(menu, { key: 'ArrowUp' }); + expect(focused()).toBe(2); +}); + +test('the arrow keys open it from the button', () => { + const { getByRole } = renderDropdown(); + fireEvent.keyDown(getByRole('button', { name: 'Drink: Coffee' }), { + key: 'ArrowDown', + }); + expect(getByRole('menu')).toBeTruthy(); +}); + +test('a press outside closes it without a choice', () => { + const onChange = jest.fn(); + const { getByRole, queryByRole, container } = renderDropdown(onChange); + fireEvent.click(getByRole('button', { name: 'Drink: Coffee' })); + + fireEvent.mouseDown(container.ownerDocument.body); + + expect(queryByRole('menu')).toBeNull(); + expect(onChange).not.toHaveBeenCalled(); +}); + +test('choosing the current option closes it without a change', () => { + const onChange = jest.fn(); + const { getByRole, queryByRole } = renderDropdown(onChange); + const button = getByRole('button', { name: 'Drink: Coffee' }); + fireEvent.click(button); + + fireEvent.click(getByRole('menuitemradio', { name: 'Coffee' })); + + expect(queryByRole('menu')).toBeNull(); + expect(onChange).not.toHaveBeenCalled(); + expect(button.ownerDocument.activeElement).toBe(button); +}); diff --git a/apps/demos/storybook/src/shared/molecules/Dropdown/Dropdown.tsx b/apps/demos/storybook/src/shared/molecules/Dropdown/Dropdown.tsx new file mode 100644 index 000000000..4eac8ff3d --- /dev/null +++ b/apps/demos/storybook/src/shared/molecules/Dropdown/Dropdown.tsx @@ -0,0 +1,164 @@ +import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react'; +import { Icon, type IconName } from '../../atoms/Icon'; + +export interface DropdownOption { + value: T; + label: string; +} + +export interface DropdownProps { + value: T; + options: readonly DropdownOption[]; + onChange: (value: T) => void; + /** Names the choice for assistive tech, as in "Demo user: Lisa Loyal". */ + label: string; + /** A leading icon in the button. */ + icon?: IconName; + className?: string; +} + +const menuItems = (menu: HTMLElement | null): HTMLElement[] => + menu + ? Array.from(menu.querySelectorAll('[role="menuitemradio"]')) + : []; + +/** + * A compact dropdown: a button with the current option and a chevron, and + * below it a menu of every option with the current one checked. A choice, an + * Escape or a press outside closes it; arrow keys, Home and End move through + * the open menu. Labels show as given: the caller translates them, or not. + */ +export function Dropdown({ + value, + options, + onChange, + label, + icon, + className = '', +}: DropdownProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const buttonRef = useRef(null); + const menuRef = useRef(null); + const menuId = useId(); + const current = options.find((option) => option.value === value); + + // While open, the current option takes the focus and a press outside the + // dropdown closes it. + useEffect(() => { + const root = rootRef.current; + if (!open || !root) return; + menuItems(menuRef.current) + .find((item) => item.getAttribute('aria-checked') === 'true') + ?.focus(); + const onPress = (event: MouseEvent) => { + if (event.target instanceof Node && !root.contains(event.target)) + setOpen(false); + }; + root.ownerDocument.addEventListener('mousedown', onPress); + return () => root.ownerDocument.removeEventListener('mousedown', onPress); + }, [open]); + + const close = () => { + setOpen(false); + buttonRef.current?.focus(); + }; + + const choose = (next: T) => { + close(); + if (next !== value) onChange(next); + }; + + const onButtonKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + setOpen(true); + } + }; + + const onMenuKeyDown = (event: KeyboardEvent) => { + const items = menuItems(menuRef.current); + const index = items.findIndex( + (item) => item === event.currentTarget.ownerDocument.activeElement, + ); + const focus = (to: number) => { + event.preventDefault(); + items[(to + items.length) % items.length]?.focus(); + }; + switch (event.key) { + case 'Escape': + event.preventDefault(); + close(); + break; + case 'Tab': + setOpen(false); + break; + case 'ArrowDown': + focus(index + 1); + break; + case 'ArrowUp': + focus(index - 1); + break; + case 'Home': + focus(0); + break; + case 'End': + focus(items.length - 1); + break; + } + }; + + return ( +
    + + {open && ( + + )} +
    + ); +} diff --git a/apps/demos/storybook/src/shared/molecules/Dropdown/index.ts b/apps/demos/storybook/src/shared/molecules/Dropdown/index.ts new file mode 100644 index 000000000..368d23e72 --- /dev/null +++ b/apps/demos/storybook/src/shared/molecules/Dropdown/index.ts @@ -0,0 +1,2 @@ +export { Dropdown } from './Dropdown'; +export type { DropdownOption, DropdownProps } from './Dropdown'; diff --git a/apps/demos/storybook/src/shared/molecules/FormField/FormField.stories.tsx b/apps/demos/storybook/src/shared/molecules/FormField/FormField.stories.tsx new file mode 100644 index 000000000..5f749735d --- /dev/null +++ b/apps/demos/storybook/src/shared/molecules/FormField/FormField.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { FormField } from './FormField'; +import { Input } from '../../atoms/Input'; +import { Select } from '../../atoms/Select'; + +const meta: Meta = { + title: 'Shared/Molecules/FormField', + component: FormField, + parameters: { + layout: 'centered', + }, + tags: ['autodocs', 'shop', 'media'], +}; + +export default meta; +type Story = StoryObj; + +export const TextInput: Story = { + args: { + label: 'Email address', + children: (id) => ( + + ), + }, +}; + +export const Choice: Story = { + args: { + label: 'Country', + children: (id) => ( + - - - -
    -

    - € 2.50 € 3.14 -

    -
    - - -
    -
    -
    -`; - -const defaultCss = `* { - box-sizing: border-box; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -} - -.product-card { - width: 100%; - max-width: 400px; - margin: 0 auto; - background: #ffffff; - border-radius: 16px; - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); - overflow: hidden; -} - -.product-figure { - position: relative; - margin: 0; - padding: 0; - width: 100%; - height: 160px; - background: - linear-gradient(135deg, rgba(243, 244, 246, 0.9) 0%, rgba(229, 231, 235, 0.9) 100%), - repeating-linear-gradient( - 45deg, - #f9fafb, - #f9fafb 10px, - #f3f4f6 10px, - #f3f4f6 20px - ); - display: flex; - align-items: center; - justify-content: center; -} - -.product-figure::before { - content: '🍟'; - font-size: 8rem; - opacity: 0.8; -} - -.product-badge-container { - position: absolute; - top: 0.5rem; - right: 0.5rem; -} - -.product-badge { - background: #01b5e2; - color: white; - padding: 0.25rem 0.75rem; - border-radius: 9999px; - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.025em; -} - -.product-body { - padding: 1.5rem; -} - -.product-title { - font-size: 1.125rem; - font-weight: 700; - margin: 0 0 1rem 0; - color: #111827; -} - -.form-control { - margin-bottom: 1rem; -} - -.form-label { - display: block; - font-size: 0.875rem; - font-weight: 500; - color: #6b7280; - margin-bottom: 0.5rem; -} - -.form-select { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 0.875rem; - color: #111827; - background: white; - cursor: pointer; - transition: border-color 0.2s; -} - -.form-select:hover { - border-color: #9ca3af; -} - -.form-select:focus { - outline: none; - border-color: #01b5e2; - box-shadow: 0 0 0 3px rgba(1, 181, 226, 0.1); -} - -.product-price { - font-size: 1.25rem; - font-weight: 700; - color: #111827; - margin: 0 0 1rem 0; -} - -.product-old-price { - font-size: 1rem; - font-weight: 400; - color: #9ca3af; - text-decoration: line-through; - margin-left: 0.5rem; -} - -.product-actions { - display: flex; - justify-content: space-between; - gap: 0.5rem; -} - -.btn { - flex: 1; - padding: 0.75rem 1rem; - border: none; - border-radius: 8px; - font-size: 0.875rem; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - text-align: center; -} - -.btn:hover { - transform: translateY(-1px); - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); -} - -.btn:active { - transform: translateY(0); -} - -.btn-primary { - background: #01b5e2; - color: white; -} - -.btn-primary:hover { - background: #0195b8; -} - -.btn-secondary { - background: #ffffff; - color: #01b5e2; - border: 1px solid #01b5e2; -} - -.btn-secondary:hover { - background: #01b5e2; - color: #ffffff; -}`; - const defaultMapping = `{ "product": { "view": { @@ -308,27 +106,42 @@ const defaultMapping = `{ } }`; +/** The id of the mapped destination in the playground's collector. */ +const DESTINATION_ID = 'gtag'; + +/** A collector log line in words: where it came from, what failed, and why. */ +function logged( + message: string, + context: Logger.LogContext, + scope: string[], +): string { + const detail: unknown = context.error; + const cause = isString(detail) + ? detail + : isObject(detail) && isString(detail.message) + ? detail.message + : undefined; + return [...scope, message, ...(cause ? [cause] : [])].join(': '); +} + /** * PromotionPlayground - Full walkerOS demonstration with live code editing * - * Shows the complete chain: - * 1. Code Editor - Edit HTML/CSS/JS with walkerOS data attributes - * 2. Preview - Live rendered output that captures real events - * 3. Events - Real events captured from preview interactions - * 4. Mapping - Apply transformations and see destination output - * 5. Result - Final destination function calls + * Shows the complete chain, one box each: + * 1. Preview - the live page, with HTML/CSS/JS tabs to edit it; captures real events + * 2. Events - the events the preview sent + * 3. Mapping - editable rules for the destination + * 4. Result - the destination call each event becomes * - * Uses a single unified collector flow: - * - PromotionPlayground owns the collector with destinations - * - Preview initializes browser source using parent's elb - * - Events flow through one collector to all destinations + * One collector runs the flow: Preview binds a browser source to it, and its + * destinations fill the Events and Result boxes. Whatever fails says so in + * the box it concerns. */ export function PromotionPlayground({ - initialHtml = defaultHtml, - initialCss = defaultCss, + initialHtml = productCardHtml, + initialCss = productCardCss, initialJs = '', initialMapping = defaultMapping, - labelCode = 'Code', labelPreview = 'Preview', labelEvents = 'Events', labelMapping = 'Mapping', @@ -345,138 +158,162 @@ export function PromotionPlayground({ const [css, setCss] = useState(initialCss); const [js, setJs] = useState(initialJs); const [mappingInput, setMappingInput] = useState(initialMapping); - const [eventJson, setEventJson] = useState( - '// Click elements in the preview to see events', + const [initialRules] = useState(() => parseMapping(initialMapping)); + const [mappingError, setMappingError] = useState( + 'error' in initialRules ? initialRules.error : undefined, ); - const [outputString, setOutputString] = useState( - '// Click elements in the preview to see function call', + const [eventJson, setEventJson] = useState(''); + const [output, setOutput] = useState(''); + const [startError, setStartError] = useState(); + const [resultError, setResultError] = useState(); + const [collector, setCollector] = useState(); + + // The rules in force: the last valid mapping, read when the collector starts. + const rulesRef = useRef( + 'rules' in initialRules ? initialRules.rules : {}, ); - const collectorRef = useRef(null); - const elbRef = useRef(null); const lastEventRef = useRef(null); - const [isReady, setIsReady] = useState(false); + const mappingTimerRef = useRef | undefined>( + undefined, + ); - // Initialize collector once on mount + // One collector per destination, shut down when it goes useEffect(() => { - let mounted = true; - - const init = async () => { - try { - const parsedMapping = JSON.parse(initialMapping); - - const { collector, elb } = await startFlow({ - destinations: { - // Capture raw events for display in Events column - rawCapture: { - code: { - type: 'rawCapture', - config: {}, - push: async (event: WalkerOS.Event) => { - if (!mounted) return; - lastEventRef.current = event; - setEventJson(JSON.stringify(event, null, 2)); - }, - }, + let active = true; + let started: Collector.Instance | undefined; + + startFlow({ + destinations: { + // Capture raw events for display in Events column. A new event + // empties Result first: it shows this event's call or none, never + // the previous one. + rawCapture: { + code: { + type: 'rawCapture', + config: {}, + push: (event: WalkerOS.Event) => { + if (!active) return; + lastEventRef.current = event; + setEventJson(JSON.stringify(event, null, 2)); + setOutput(''); + setResultError(undefined); }, - // Transform and display formatted output in Result column - gtag: { - code: destination, - config: { - mapping: parsedMapping, - }, - env: { - elb: (output: string) => { - if (!mounted) return; - setOutputString(output); - }, - }, + }, + }, + // Transform and display formatted output in Result column + [DESTINATION_ID]: { + code: destination, + config: { mapping: rulesRef.current }, + env: { + elb: (formatted: string) => { + if (!active) return; + setOutput(formatted); + setResultError(undefined); }, }, - consent: { functional: true, marketing: true }, - user: { session: 'playground' }, - }); - - if (!mounted) return; - - collectorRef.current = collector; - elbRef.current = elb; - setIsReady(true); - } catch { - // Initialization failed - component will show placeholder - } - }; - - init(); + }, + }, + consent: { functional: true, marketing: true }, + user: { session: 'playground' }, + // A failure the collector logs (a destination push, a mapping) shows + // in the Result box, besides the console. + logger: { + handler: (level, message, context, scope, original) => { + original(level, message, context, scope); + if (active && level === Level.ERROR) + setResultError(logged(message, context, scope)); + }, + }, + }).then( + (flow) => { + started = flow.collector; + if (!active) { + void shutdown(flow.collector); + return; + } + // An edit applied while the collector started is in rulesRef only. + const mapped = flow.collector.destinations[DESTINATION_ID]; + if (mapped) mapped.config.mapping = rulesRef.current; + collectorRef.current = flow.collector; + setStartError(undefined); + setCollector(flow.collector); + }, + (thrown: unknown) => { + if (active) + setStartError( + `The collector did not start: ${getErrorMessage(thrown)}`, + ); + }, + ); return () => { - mounted = false; - // Cleanup collector - if (collectorRef.current) { - // Sources cleanup would happen here if needed - } + active = false; + collectorRef.current = null; + setCollector(undefined); + if (started) void shutdown(started); }; - }, [initialMapping, destination]); - - // Handle mapping changes - update collector destination config - const handleMappingChange = useCallback((newMapping: string) => { - setMappingInput(newMapping); - - // Debounced update to collector - const timeoutId = setTimeout(() => { - try { - const parsed = JSON.parse(newMapping); - // Update destination config directly - if (collectorRef.current?.destinations?.gtag?.config) { - collectorRef.current.destinations.gtag.config.mapping = parsed; - } - // Re-process last event to update Result column - if (lastEventRef.current && collectorRef.current) { - collectorRef.current.push(lastEventRef.current); - } - } catch { - // Invalid JSON - don't update + }, [destination]); + + // Mapping edits apply once typing pauses: the destination takes the new + // rules and the last event runs through it again, to that destination only. + const handleMappingChange = useCallback((text: string) => { + setMappingInput(text); + clearTimeout(mappingTimerRef.current); + mappingTimerRef.current = setTimeout(() => { + const parsed = parseMapping(text); + if ('error' in parsed) { + setMappingError(parsed.error); + return; } + setMappingError(undefined); + rulesRef.current = parsed.rules; + const current = collectorRef.current; + const mapped = current?.destinations[DESTINATION_ID]; + if (!current || !mapped) return; + mapped.config.mapping = parsed.rules; + const last = lastEventRef.current; + if (!last) return; + setOutput(''); + setResultError(undefined); + current + .push(last, { include: [DESTINATION_ID] }) + .catch((thrown: unknown) => + setResultError( + `The event did not run again: ${getErrorMessage(thrown)}`, + ), + ); }, 500); - - return () => clearTimeout(timeoutId); }, []); + // A pending edit goes with the playground. + useEffect(() => () => clearTimeout(mappingTimerRef.current), []); + return ( - - {/* Column 1: Code Editor with HTML/CSS/JS tabs */} - + - {/* Column 2: Preview - uses parent's elb for event capture */} - - - {/* Column 3: Events - raw captured events */} - {/* Column 4: Mapping - editable transformation rules */} - {/* Column 5: Result - transformed destination output */} ); } + +/** Shut a collector down; a failure goes to its own log. */ +async function shutdown(collector: Collector.Instance): Promise { + await collector.command('shutdown').catch((thrown: unknown) => { + collector.logger.error('playground shutdown failed', { + error: getErrorMessage(thrown), + }); + }); +} diff --git a/apps/explorer/src/components/demos/__tests__/PromotionPlayground.test.tsx b/apps/explorer/src/components/demos/__tests__/PromotionPlayground.test.tsx new file mode 100644 index 000000000..2e7016127 --- /dev/null +++ b/apps/explorer/src/components/demos/__tests__/PromotionPlayground.test.tsx @@ -0,0 +1,353 @@ +// Stub the Vite virtual module before any import of code.tsx. +jest.mock('virtual:walkeros-core-types', () => '', { virtual: true }); + +// The real CodeBox and Code render; only Monaco itself is replaced, by a +// textarea that shows the editor's value, its language and whether it is +// read-only, and reports edits like Monaco does. +jest.mock('@monaco-editor/react', () => { + // require inside the factory: jest hoists jest.mock above imports. + const ReactLocal = require('react'); + const Editor = ({ + defaultValue, + language, + onChange, + options, + }: { + defaultValue: string; + language: string; + onChange: (value: string | undefined) => void; + options: { readOnly?: boolean }; + }) => + ReactLocal.createElement('textarea', { + 'data-language': language, + value: defaultValue, + readOnly: options.readOnly, + onChange: (event: { target: { value: string } }) => + onChange(event.target.value), + }); + return { + Editor, + loader: { config: () => {}, init: () => Promise.resolve() }, + useMonaco: () => null, + }; +}); + +import React, { StrictMode } from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { PromotionPlayground } from '../PromotionPlayground'; +import { + createGtagDestination, + type DestinationCode, +} from '../../../helpers/destinations'; + +// Preview renders 200 ms after a change and binds its source 50 ms later. +const WAIT = { timeout: 3000 }; + +beforeEach(() => { + // The shared web setup turns fake timers on; the preview, the collector and + // the mapping debounce run on real ones here. + jest.useRealTimers(); +}); + +function box(container: HTMLElement, label: string): HTMLElement { + const found = Array.from( + container.querySelectorAll('.elb-explorer-box'), + ).find( + (element) => + element.querySelector('.elb-explorer-label')?.textContent === label, + ); + if (!found) throw new Error(`No box labelled ${label}`); + return found; +} + +function editor(container: HTMLElement, label: string): HTMLTextAreaElement { + const found = box(container, label).querySelector('textarea'); + if (!found) throw new Error(`The ${label} box has no editor`); + return found; +} + +const shown = (container: HTMLElement, label: string) => + editor(container, label).value; + +function frame(container: HTMLElement): Document { + const doc = container.querySelector('iframe')?.contentDocument; + if (!doc) throw new Error('The preview has no document'); + return doc; +} + +function button(doc: Document, text: string): HTMLButtonElement { + const found = Array.from(doc.querySelectorAll('button')).find( + (element) => element.textContent?.trim() === text, + ); + if (!found) throw new Error(`The preview has no ${text} button`); + return found; +} + +/** The demo gtag destination, recording each call it formats. */ +function recordingGtag(overrides: Partial = {}) { + const gtag = createGtagDestination(); + const pushes: string[] = []; + const destination: DestinationCode = { + ...gtag, + push: (event, context) => { + pushes.push(event.name); + return gtag.push(event, context); + }, + ...overrides, + }; + return { destination, pushes }; +} + +describe('PromotionPlayground', () => { + it('starts every JSON box empty or with valid JSON, and the output boxes read-only', async () => { + const { container, unmount } = render(); + + const json = Array.from( + container.querySelectorAll( + 'textarea[data-language="json"]', + ), + ).map((element) => element.value); + expect(json.length).toBeGreaterThan(0); + for (const code of json) { + if (code !== '') expect(() => JSON.parse(code)).not.toThrow(); + } + expect(editor(container, 'Events').readOnly).toBe(true); + expect(editor(container, 'Result').readOnly).toBe(true); + expect(editor(container, 'Mapping').readOnly).toBe(false); + expect(box(container, 'Events')).toHaveTextContent( + 'Click the preview to see its events.', + ); + + // Let the preview bind before it unmounts. + await waitFor(() => expect(shown(container, 'Events')).not.toBe(''), WAIT); + unmount(); + }); + + it('shows the load event and the mapped gtag call', async () => { + const { container, unmount } = render(); + + await waitFor( + () => expect(shown(container, 'Events')).toContain('"product view"'), + WAIT, + ); + await waitFor(() => + expect(shown(container, 'Result')).toContain("gtag('event', 'view_item'"), + ); + expect(shown(container, 'Result')).toContain('"item_variant": "sweet"'); + unmount(); + }); + + it('shows add with the selected taste and save on clicks in the preview', async () => { + const { container, unmount } = render(); + await waitFor( + () => expect(shown(container, 'Events')).toContain('"product view"'), + WAIT, + ); + const doc = frame(container); + + const select = doc.querySelector('select'); + if (!select) throw new Error('The preview has no taste select'); + select.value = 'spicy'; + fireEvent.click(button(doc, 'Add to Cart')); + + await waitFor(() => + expect(shown(container, 'Events')).toContain('"product add"'), + ); + expect(shown(container, 'Events')).toContain('"taste": "spicy"'); + await waitFor(() => + expect(shown(container, 'Result')).toContain( + "gtag('event', 'add_to_cart'", + ), + ); + expect(shown(container, 'Result')).toContain('"item_variant": "spicy"'); + + fireEvent.click(button(doc, 'Maybe later')); + await waitFor(() => + expect(shown(container, 'Events')).toContain('"product save"'), + ); + await waitFor(() => + expect(shown(container, 'Result')).toContain( + "gtag('event', 'add_to_wishlist'", + ), + ); + unmount(); + }); + + it('applies a mapping edit once, after typing stops', async () => { + const { destination, pushes } = recordingGtag(); + const { container, unmount } = render( + , + ); + await waitFor(() => expect(pushes).toEqual(['view_item']), WAIT); + + const mapping = editor(container, 'Mapping'); + for (const name of ['first', 'second', 'third']) { + fireEvent.change(mapping, { + target: { value: JSON.stringify({ product: { view: { name } } }) }, + }); + } + + await waitFor(() => expect(pushes).toEqual(['view_item', 'third']), WAIT); + expect(shown(container, 'Result')).toContain("gtag('event', 'third'"); + // The Events box shows what the preview sent, not the re-run. + expect(shown(container, 'Events')).toContain('"product view"'); + unmount(); + }); + + it('drops a pending mapping edit on unmount and shuts the collector down', async () => { + const destroy = jest.fn(); + const { destination, pushes } = recordingGtag({ destroy }); + const { container, unmount } = render( + , + ); + await waitFor(() => expect(pushes).toEqual(['view_item']), WAIT); + + fireEvent.change(editor(container, 'Mapping'), { + target: { + value: JSON.stringify({ product: { view: { name: 'late' } } }), + }, + }); + unmount(); + + await waitFor(() => expect(destroy).toHaveBeenCalledTimes(1)); + await act(() => new Promise((resolve) => setTimeout(resolve, 700))); + expect(pushes).toEqual(['view_item']); + }); + + it('names an invalid mapping in the Mapping box and keeps capturing', async () => { + const { container, unmount } = render( + , + ); + + // Input validation is announced politely, not as an alert. + expect( + box(container, 'Mapping').querySelector('[role="alert"]'), + ).toBeNull(); + expect( + box(container, 'Mapping').querySelector('[role="status"]'), + ).toHaveTextContent(/Error.*Mapping not applied/); + await waitFor( + () => expect(shown(container, 'Events')).toContain('"product view"'), + WAIT, + ); + unmount(); + }); + + it('names a destination failure in the Result box', async () => { + const { destination } = recordingGtag({ + push: () => { + throw new Error('gtag is not loaded'); + }, + }); + const { container, unmount } = render( + , + ); + + await waitFor( + () => + expect( + box(container, 'Result').querySelector('[role="alert"]'), + ).toHaveTextContent(/Error.*gtag is not loaded/), + WAIT, + ); + unmount(); + }); + + it('names a destination init failure in the Result box', async () => { + const { destination } = recordingGtag({ + init: () => { + throw new Error('gtag init failed'); + }, + }); + const { container, unmount } = render( + , + ); + + await waitFor( + () => + expect( + box(container, 'Result').querySelector('[role="alert"]'), + ).toHaveTextContent(/Error.*gtag init failed/), + WAIT, + ); + unmount(); + }); + + it('clears the Result when an event makes no gtag call', async () => { + const { container, unmount } = render(); + await waitFor( + () => + expect(shown(container, 'Result')).toContain( + "gtag('event', 'view_item'", + ), + WAIT, + ); + + fireEvent.change(editor(container, 'Mapping'), { + target: { + value: JSON.stringify({ product: { view: { ignore: true } } }), + }, + }); + + await waitFor(() => expect(shown(container, 'Result')).toBe(''), WAIT); + expect(box(container, 'Result')).toHaveTextContent( + 'No gtag call for this event.', + ); + unmount(); + }); + + it('starts one collector in strict mode and shuts the discarded one down', async () => { + const destroy = jest.fn(); + const { destination, pushes } = recordingGtag({ destroy }); + const { unmount } = render( + + + , + ); + + await waitFor(() => expect(pushes).toEqual(['view_item']), WAIT); + // The collector of the discarded first mount, never the running one. + await waitFor(() => expect(destroy).toHaveBeenCalledTimes(1)); + unmount(); + await waitFor(() => expect(destroy).toHaveBeenCalledTimes(2)); + }); + + it('keeps a mapping edit made while the collector starts', async () => { + jest.useFakeTimers(); + const { destination, pushes } = recordingGtag(); + const { container, unmount } = render( + , + ); + + fireEvent.change(editor(container, 'Mapping'), { + target: { + value: JSON.stringify({ product: { view: { name: 'early' } } }), + }, + }); + // The edit applies before the start resolves: no await in between. + act(() => { + jest.advanceTimersByTime(500); + }); + // Then the start, the preview and the load event, on the fake clock. + for (let step = 0; step < 20 && pushes.length === 0; step++) + await act(() => jest.advanceTimersByTimeAsync(250)); + + expect(pushes).toEqual(['early']); + unmount(); + }); + + it('names the preview by its first tab, not a second label', () => { + const { container, unmount } = render(); + + const labels = Array.from( + container.querySelectorAll('.elb-explorer-label'), + ).map((label) => label.textContent); + expect(labels).toEqual(['Events', 'Mapping', 'Result']); + expect( + Array.from(container.querySelectorAll('button')).some( + (button) => button.textContent === 'Preview', + ), + ).toBe(true); + unmount(); + }); +}); diff --git a/apps/explorer/src/components/demos/__tests__/product-card.demo.test.tsx b/apps/explorer/src/components/demos/__tests__/product-card.demo.test.tsx new file mode 100644 index 000000000..fc937b786 --- /dev/null +++ b/apps/explorer/src/components/demos/__tests__/product-card.demo.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { Button } from '../../../design/components/atoms/Button'; +import { Card } from '../../../design/components/atoms/Card'; +import { PhotoPlaceholder } from '../../../design/components/atoms/PhotoPlaceholder'; +import { Text } from '../../../design/components/atoms/Text'; +import { productCardHtml, productCardMarkup } from '../product-card.demo'; + +// The demo card is written into the preview as an HTML string, so its design +// markup is spelled out once there; each piece must stay what the design +// component renders. +describe('product card demo', () => { + it('opens with the Card atom', () => { + const card = renderToStaticMarkup( + + ~ + , + ); + expect(card).toBe(`${productCardMarkup.cardOpen}~
    `); + }); + + it.each<[keyof typeof productCardMarkup, React.ReactElement]>([ + [ + 'photo', + , + ], + [ + 'badge', + + Delicious + , + ], + [ + 'price', + + € 2.50 € 3.14 + , + ], + [ + 'add', + , + ], + [ + 'save', + , + ], + ])('spells %s as the design component renders it', (part, element) => { + expect(renderToStaticMarkup(element)).toBe(productCardMarkup[part]); + }); + + it('uses every piece of design markup in the card', () => { + for (const markup of Object.values(productCardMarkup)) + expect(productCardHtml).toContain(markup); + }); +}); diff --git a/apps/explorer/src/components/demos/playground/CodeEditor.tsx b/apps/explorer/src/components/demos/playground/CodeEditor.tsx new file mode 100644 index 000000000..5fd939dc1 --- /dev/null +++ b/apps/explorer/src/components/demos/playground/CodeEditor.tsx @@ -0,0 +1,82 @@ +import React, { forwardRef, useImperativeHandle, useRef } from 'react'; +import { cx } from '../../../design/components/cx'; +import { renderTokens, tokenize, type CodeKind } from './highlight'; + +export interface CodeEditorHandle { + /** Scroll so that a line sits near the top of the editor. */ + scrollToLine: (line: number) => void; +} + +export interface CodeEditorProps { + value: string; + kind: CodeKind; + label: string; + /** Without it the editor is read-only. */ + onChange?: (value: string) => void; + /** First and last line to mark, inclusive. */ + highlight?: [number, number]; + className?: string; +} + +/** + * A plain textarea laid over its own syntax-highlighted copy. The textarea + * owns typing, selection and scrolling; the copy below follows its scroll. + */ +export const CodeEditor = forwardRef( + function CodeEditor( + { value, kind, label, onChange, highlight, className }, + ref, + ) { + const areaRef = useRef(null); + const preRef = useRef(null); + + const follow = () => { + const area = areaRef.current; + const pre = preRef.current; + if (!area || !pre) return; + pre.scrollTop = area.scrollTop; + pre.scrollLeft = area.scrollLeft; + }; + + useImperativeHandle(ref, () => ({ + scrollToLine(line) { + const area = areaRef.current; + const row = preRef.current?.children[line]; + if (!area || !(row instanceof HTMLElement)) return; + area.scrollTop = Math.max(0, row.offsetTop - 24); + follow(); + }, + })); + + return ( +
    + +