astro-knots
Mode

← All notes

specs

Services Section as a Tiered, Categorized Card Catalog

A Sveltia-driven Services surface shaped Category → Card → Tier, where optional tags tick the price up before the order goes to WhatsApp.

arthouse-site Draft v0.0.0.3

Audience: the next agent (or human) wiring the Services page on arthouse-site. Read this before touching src/pages/services/ or the pricing collection in public/admin/config.yml.

TL;DR: the existing pricing collection is a flat list of tiers. The new shape is Category → Card → Tier, where Tier is a progressive reveal of the same Card's deeper offering (think: free / standard / premium versions of the same service idea), Categories are the top-nav swipe axis, and each Card composes its final price + WhatsApp message from a baseline plus the user's tag selections.

Related: Sveltia Constraints for CMS — what we cannot do in Sveltia (no custom media library plugins, relation widget is the load-bearing escape hatch).


1. Why this document exists

The current /services page is a stub. The current pricing collection (src/content/pricing/standard.md) is a flat single-axis tier list — it can't express:

  • Category (e.g., Portrait Sessions, Boudoir, Events, Couples) — the top-nav axis

  • Tier as progression within a Card (taste → standard → premium of the same service, swiped through one at a time, not a checkbox list of features)

  • Included tags as priced increments — visitor taps + to add an optional included_tag, the displayed price ticks up live, the WhatsApp deeplink sends the full composed order

  • Public vs guarded copy — public-by-default, with a "reveal" button that exposes more candid copy for visitors who actually want it

Sveltia is the editor. She must be able to add a new Category, a new Card in that Category, new Tiers on a Card, and new optional tags + their price deltas without touching code.


2. Domain model

Three nested collections; one optional shared vocabulary:

Category (top-nav)
└─ Card (one service idea)
└─ Tier (taste → standard → premium of THIS card)
└─ Optional Tags Catalog (per-card, with price deltas)
└─ Public Copy + Guarded Copy

Plus a shared catalog (promoted to first-class collection):

  • service-items — the named atomic offerings she sells (e.g., 10-Edited-Photos, Online-Gallery, Print-Release, Hair-and-Makeup, Second-Location, Outdoor-Permit). Each item has a slug (Train-Case), a label, a one-line description, an optional default price (used as a fallback when a Card surfaces it as an add-on without overriding), and a kind (see §6). Cards consume items in two roles:

    • Included — baked into a Tier (or into a custom_time Card baseline). Visitor doesn't pay extra; the chip is filled / non-interactive.

    • Available via + — surfaced as an outline chip with a price-delta. Visitor can opt in.

Same item can play either role on different Cards. That's the whole point — the catalog is the source of truth, the Card composes from it.

2.1 Category

  • Sits in the top nav of /services

  • Visitor swipes / arrow-taps between categories — only one Category's deck is on screen at a time

  • Has its own ordering (sort_order)

  • Slug drives the URL fragment: /services#portrait, /services#boudoir, etc.

2.2 Card

  • Belongs to exactly one Category

  • Has a title, a hero image (ImageKit path — same catalog pattern as images collection), public copy, guarded copy

  • Owns 1+ Tiers (a Card with one Tier is fine — it just doesn't progress)

  • Owns 0+ Optional Tags (per-card, because what's optional for "Portrait" isn't optional for "Event")

  • Owns 1 WhatsApp template (the message scaffold; price + selections substituted at click time)

2.3 Tier

  • A child of a Card. Ordered.

  • Has a label (Taste, Standard, Premium — or whatever she names them)

  • Has a base price (number, in EUR — display formatting handled at render)

  • Has its own included_tags (the things baked in at this Tier; visitor doesn't pay extra for these)

  • Has its own copy fragment (what makes this Tier different from the prior one)

  • The Card flips between Tiers — see §4.2

2.4 Billing Mode (per-Card)

Not every service is sold as a fixed-price package. Two billing modes coexist on the same /services page:

  • package (default) — the model already described: Tiers with base_price_eur, optional add-ons increment a fixed total.

  • custom_time — used when a proprietor books her by the hour / day / multi-day at a posted rate. Examples: studio residencies, art-direction days, multi-day shoots on location. No "Tiers" in the progressive sense; instead the Card exposes one or more rate units (e.g., per-hour, per-day, per-3-day-block) and a duration picker the visitor sets before requesting on WhatsApp.

A Card declares its mode via a billing_mode field. The two modes share the rest of the Card model (title, hero image, public/guarded copy, optional add-ons, WhatsApp template) — only the price-and-flip surface differs.

2.5 Optional Tags (per-Card add-ons)

  • Each entry: tag (must exist in the shared vocabulary), price_delta (number, EUR), optional note (one-line description)

  • Surfaced as + chips on the active Tier's card

  • Tapping a + chip increments the displayed price by price_delta and adds the tag to the composed order

  • Tapping again removes it


3. Sveltia collection design

Two new collections replace the current single pricing collection. The current pricing collection should be kept temporarily until the new shape is populated, then removed (don't migrate data automatically — she'll re-enter through the new UX since the new model is richer).

3.1 service-items (the catalog)

YAML
- name: service-items
label: "Service Items (Catalog)"
label_singular: "Item"
folder: src/content/service-items
extension: md
create: true
delete: true
identifier_field: slug
fields:
- { name: label, label: "Display Label", widget: string,
hint: "What the chip says. Example: 10 Edited Photos, Hair & Makeup" }
- { name: slug, label: "Slug (Train-Case)", widget: string,
hint: "Stable id. Train-Case per project convention. Example: 10-Edited-Photos, Hair-and-Makeup" }
- { name: description, label: "One-line description", widget: string, required: false,
hint: "Hover / accessibility text. Keep it short." }
- name: kind
label: "Kind"
widget: select
options: ["deliverable", "production", "logistic", "post-production"]
default: "deliverable"
required: false
hint: "Loose grouping. Deliverable = something she hands to the client. Production = something done during the shoot. Logistic = travel/permits. Post-production = editing/retouching."
- { name: default_price_eur, label: "Default Price (EUR, optional)", widget: number, required: false, min: 0,
hint: "If set, Cards offering this item as an add-on default to this price. They can override per Card." }
- { name: public, label: "Visible to public?", widget: boolean, default: true,
hint: "Uncheck to draft an item before exposing it." }

3.2 service-categories

YAML
- name: service-categories
label: "Service Categories"
label_singular: "Category"
folder: src/content/service-categories
extension: md
create: true
delete: true
identifier_field: slug
fields:
- { name: title, label: "Category Name", widget: string,
hint: "Shown in the top nav. Example: Portrait, Boudoir, Couples, Events" }
- { name: slug, label: "Slug", widget: string,
hint: "Lowercase, dash-separated. Drives /services#slug" }
- { name: sort_order, label: "Display Order", widget: number, default: 100 }
- { name: subhead, label: "One-line Subhead", widget: string, required: false,
hint: "Sits under the category title in the swipe nav." }
- { name: public, label: "Visible in nav?", widget: boolean, default: true,
hint: "Uncheck to draft a category without exposing it yet." }

3.3 service-cards

YAML
- name: service-cards
label: "Service Cards"
label_singular: "Card"
folder: src/content/service-cards
extension: md
create: true
delete: true
identifier_field: slug
fields:
- { name: title, label: "Card Title", widget: string }
- { name: slug, label: "Slug", widget: string }
- name: category
label: "Category"
widget: relation
collection: service-categories
search_fields: [title, slug]
value_field: slug
display_fields: [title]
- { name: sort_order, label: "Order within Category", widget: number, default: 100 }
- name: hero_image
label: "Hero Image"
widget: relation
collection: images
search_fields: [title, slug, tags]
value_field: slug
display_fields: [title]
hint: "Pick from the image catalog. Card uses this as its face."
- { name: public_copy, label: "Public Copy (SEO-visible)", widget: markdown,
hint: "Shown by default. Search engines + LLMs see this." }
- { name: guarded_copy, label: "Guarded Copy (reveal button)", widget: markdown,
required: false,
hint: "Hidden until the visitor taps Reveal. Not in SSR HTML — rendered client-side after consent click." }
- { name: whatsapp_template, label: "WhatsApp Message Template", widget: text,
hint: "Use {{tier}}, {{price}}, {{tags}}, {{card}}, {{duration}}, {{rate_unit}}. Example for package mode: 'Hi! I'm interested in {{card}} — {{tier}} ({{price}}) with {{tags}}.' Example for custom_time: 'Hi! Booking inquiry for {{card}}: {{duration}} × {{rate_unit}} = {{price}}. Add-ons: {{tags}}.'" }
- name: billing_mode
label: "Billing Mode"
widget: select
options: ["package", "custom_time"]
default: "package"
hint: "package = fixed-price Tiers (e.g., portrait packages). custom_time = booked by hour/day/multi-day at a rate."
- name: rate_units
label: "Rate Units (only used when Billing Mode = custom_time)"
widget: list
required: false
hint: "One entry per rate the visitor can pick. Example: per_hour at €250, per_day at €1500, per_3_days at €4000."
fields:
- { name: label, label: "Display Label", widget: string,
hint: "Example: Hourly, Day Rate, 3-Day Booking" }
- { name: unit, label: "Unit", widget: select,
options: ["hour", "half_day", "day", "multi_day"], default: "hour" }
- { name: multi_day_block, label: "If multi_day, block size (days)", widget: number, required: false, min: 2,
hint: "Only meaningful when unit = multi_day. Example: 3 means a 3-day block." }
- { name: rate_eur, label: "Rate (EUR, number)", widget: number, min: 0 }
- { name: min_quantity, label: "Minimum bookable quantity", widget: number, default: 1, min: 1,
hint: "Example: 4 means visitor must book at least 4 hours / days / blocks." }
- { name: max_quantity, label: "Maximum bookable quantity (optional)", widget: number, required: false, min: 1 }
- name: tiers
label: "Tiers (progressive reveal)"
widget: list
hint: "Order them from entry-level to premium. Visitor flips through one at a time."
fields:
- { name: label, label: "Tier Label", widget: string,
hint: "Example: Taste, Standard, Premium, Signature" }
- { name: base_price_eur, label: "Base Price (EUR, number)", widget: number, min: 0 }
- name: included_items
label: "What's Included (baked in, picked from the catalog)"
widget: list
hint: "Picked from service-items. Filled, non-interactive chips on this tier."
field:
name: item
label: "Item"
widget: relation
collection: service-items
search_fields: [label, slug]
value_field: slug
display_fields: [label]
- { name: tier_copy, label: "What makes THIS tier different", widget: markdown, required: false }
- name: hero_image
label: "Tier Hero Image (optional)"
widget: relation
collection: images
search_fields: [title, slug, tags]
value_field: slug
display_fields: [title]
required: false
hint: "Lets a more premium tier show a more enticing image. If empty, falls back to the Card's hero."
- name: optional_addons
label: "Optional Add-ons (the + chips, picked from catalog)"
widget: list
required: false
hint: "Picked from service-items. Renders as outline + chips on the active tier. Tap = add to order + increment price."
fields:
- name: item
label: "Item"
widget: relation
collection: service-items
search_fields: [label, slug]
value_field: slug
display_fields: [label]
- { name: price_delta_eur, label: "Price Delta (EUR) — overrides item's default_price_eur if set",
widget: number, required: false, min: 0,
hint: "Leave empty to use the item's default_price_eur from the catalog. Set a value to override per Card." }
- { name: note, label: "Per-Card note (optional)", widget: string, required: false,
hint: "Overrides the item's description if you want a Card-specific framing." }

3.4 Why three collections, not one nested

Sveltia's relation widget is the join. Items listed once in the catalog, Categories listed once in the nav, Cards reference both by slug. Keeps each surface lightweight and lets each evolve independently: she can rename a Category, retire an item, or restructure a Card without touching the others.


4. Page UX

4.1 Top nav — categories

  • Horizontal strip at the top of /services

  • Swipe left/right on touch, ←/→ arrow keys on desktop, click directly to jump

  • Active Category's slug becomes the URL fragment (/services#portrait) so deep-links work and back-button history feels right

  • Categories with public: false don't render

4.2 Card deck — the overlap + tap-to-reveal motion

Within the active Category:

  • Cards render as a stack, slightly fanned (the 2nd and 3rd peek behind the front one)

  • The front card is interactive; tap it to "open" — animates to a focused state, others slide aside

  • Inside an open Card, Tiers are a horizontal carousel — left/right arrows on the card flip between Taste, Standard, Premium

  • Tier flip animates content (price + included chips + copy) but keeps the Card frame stable so the visitor knows they're on the same service

Stacked preview face (decided): behind the front card, each rear card shows roughly its top 2/3 image + bottom 1/3 preview strip. The preview strip contains:

  • Card title

  • Baked-in included_tags for the default Tier (Tier 1 by convention — the entry-level offering)

  • A Reveal affordance (chevron / arrow / "Open")

Rationale: the image is the seduction, the tags are the substance, the reveal is the invitation. No price on the stacked face — price lives in the opened state where it can be composed dynamically. The 2/3-image / 1/3-text ratio means the visual still leads.

Per-tier images (decided): each Tier may carry its own hero image, not just the Card. This lets a premium Tier present a more enticing / suggestive frame than the entry Tier — same service, more aspirational visual. If a Tier omits hero_image, it falls back to the Card-level hero. The stacked-card face uses the Card-level (default) hero; opened Tier carousel swaps the image as the visitor flips. See §3.2 for the field addition.

4.3 Included chips + optional add-ons

On the active Tier face:

  • Baked-in tags render as filled chips (already paid for at this tier — visual only, not interactive)

  • Optional tags render as outline chips with a +

  • Tap an optional chip: it fills in, the + flips to −, the displayed price increments by its delta with a tick animation

  • Tap again: it reverts

  • Selections persist while the visitor flips Tiers — but each Tier may have a different baked-in set, so on Tier change, any selected optional that's baked in at the new tier should auto-collapse into the baked set (no double-charging)

4.4 Custom-time billing — duration picker

When a Card's billing_mode is custom_time, the opened-card surface looks different:

  • No Tier carousel. Instead, a rate unit selector (segmented control or pill row): Hourly | Day | 3-Day Booking — whatever the Card declares

  • A quantity stepper next to it: − 4 + (defaulting to min_quantity, capped at max_quantity if set)

  • Live price = selected rate × quantity + sum of selected add-ons

  • The same + chips optional add-ons system works unchanged (they're modeled at the Card level, billing-mode-agnostic)

  • The stacked-card preview face for a custom_time Card shows: title + a "from €X / unit" line (using the cheapest declared rate) + reveal — no included_tags on the preview, since there are no Tiers to bake them into

WhatsApp template gains two substitutions for this mode:

  • {{duration}} → e.g., "4 hours", "2 days", "two 3-day blocks (6 days)"

  • {{rate_unit}} → display label of the selected rate unit

  • {{price}} → composed total

  • {{tags}} → selected add-ons (still works)

4.5 Public vs guarded copy

  • public_copy renders in SSR HTML — search engines, LLMs, scrapers see it

  • guarded_copy does not render in initial HTML. There's a Reveal more button (or similar — see §9.2 for naming) that, when clicked, fetches/reveals the guarded markdown client-side

  • "Guarded" here means not in the initial DOM. It is not security. If she wants real access control, that's a separate layer (passcode gate, GitHub OAuth, etc.) — out of scope for this spec.

  • Implementation: ship guarded_copy as a data-* attribute (base64 or as a hidden <template>), reveal on click. Keeps it out of the SSR-rendered HTML stream that bots typically scrape — but anyone viewing source can find it. Document that limitation explicitly in the CMS hint copy.

On the active Tier, a primary CTA Request on WhatsApp:

  • Compiles the whatsapp_template substituting:

    • {{card}} → Card title

    • {{tier}} → Tier label

    • {{price}} → composed total (base + sum of selected add-ons), formatted as €X

    • {{tags}} → comma-joined list of all tags in the order (baked + selected optional)

  • Opens https://wa.me/<PHONE>?text=<encoded> in a new tab

  • Phone number comes from a site-wide setting — propose adding it to src/content/theme/ or a new src/content/contact.md single-file collection. Don't hardcode.


5. Routing + rendering

  • /services renders all Categories + their Cards in one page (SPA-feel within the section)

  • URL fragment reflects active Category: /services#portrait

  • We do not create per-card or per-category routes. Cards aren't standalone destinations.

  • The page is server-rendered with the public copy fully inlined for SEO. Tier flip, chip toggle, price math, guarded reveal — all client-side (Svelte component, per the Arthouse stack).


6. The service-items catalog — first-class, not a tag dictionary

Decision (was an open question, now resolved): the shared vocabulary is promoted to a real collection (service-items, §3.1). Reasons:

  • The same atomic offering (Hair-and-Makeup, Print-Release, Second-Location) genuinely repeats across Cards — sometimes baked in, sometimes optional, sometimes priced differently. A flat tag string can't carry that.

  • Sveltia's relation widget makes picking from the catalog as fast as typing a tag, with the bonus of typeahead and zero drift.

  • A central default_price_eur per item gives her one place to update an across-the-board rate (e.g., raising the H&M fee everywhere) — Cards that override stay overridden; Cards that didn't auto-pick up the new default.

Item kind — loose grouping, not enforcement

The kind select (deliverable | production | logistic | post-production) is for her benefit when scanning the catalog. The renderer may or may not use it for visual grouping of chips. Open question §9.3.

Migration

When seeding the first Card, she creates the items first (one short Sveltia entry each), then composes the Card by picking from them. The friction is one-time per item; afterwards the catalog grows additively.


7. SEO + LLM-visibility implications

  • public_copy per Card lands in the SSR HTML → indexable

  • Each Category becomes an anchor (#portrait, etc.) — link to those from internal nav + sitemap entries

  • Per the open-graph-share-seo-geo skill: if we want Services as a notable surface in /llms.txt, add a line per Category linking to /services#<slug>

  • guarded_copy is intentionally not SEO-visible. That's the point. Document this so future-her doesn't put load-bearing keywords inside the guarded block expecting them to rank.


8. Migration of existing pricing collection

The existing src/content/pricing/standard.md should be:

  1. Translated by hand into ONE Card in ONE new Category during the first dogfood pass — confirms the model holds

  2. Old pricing collection left in config.yml but its folder eventually emptied and the collection removed in a follow-up commit

  3. Any references in src/pages/ to pricing content updated to point at the new collections

Do not write a programmatic migration. Volume is tiny; manual re-entry through the new Sveltia UX validates the editor flow.


9. Open questions (decide before build)

9.1 Stacked-card preview face — RESOLVED

See §4.2: title + included_tags + reveal affordance on bottom 1/3, image dominates the top 2/3. Per-Tier images supported with fallback to Card hero.

9.2 What do we call the reveal button?

Reveal more is generic. Options aligned to Arthouse's voice:

  • Tell me more

  • The full story

  • Behind the curtain

  • Read on

Pick one with her. The CMS hint should warn that this text is the same across all Cards (site-level, not per-Card) — or, if she wants per-Card variation, we add a reveal_label field on the Card.

9.3 Group chips visually by kind?

The service-items catalog gives every item a kind (deliverable | production | logistic | post-production). On the opened Card, should the chips group/sort by kind (so all "deliverables" cluster together, then "production", etc.)? Or just render in the order she listed them on the Card? Default proposal: respect her ordering — kind is for catalog organization, not render grouping. Revisit if chip walls get cluttered.

9.4 Currency + locale

All prices in EUR. Display format: €150 (no decimals for whole-euro prices, €150.50 if a delta produces one). Confirm that's the desired format.

9.5 Hero image required?

Should a Card without hero_image fail to render, or fall back to a Category default? Default proposal: render a styled placeholder using the Category's color so missing images don't break the deck.

9.6 Tier label set — free or constrained?

She types Tier labels freely (Taste, Signature, etc.). Should we constrain to a fixed select (Taste / Standard / Premium) for visual consistency across Categories? Default proposal: free-text — each Category may have a different progression metaphor, that's expressive, not chaotic.

9.7 What goes in whatsapp_template by default

Suggest a default template string she can copy when creating a new Card:

Hi! I'm interested in {{card}} — {{tier}} ({{price}}). Included: {{tags}}.

She edits to taste per Card.


10. Build order (when we move to implementation)

  1. Add service-categories and service-cards collections to public/admin/config.yml

  2. Add Astro content collection definitions in src/content/config.ts (or wherever schemas live)

  3. Seed one Category + one Card + one Tier via hand-written markdown so the page can render before Sveltia commits exist

  4. Build the Svelte component for the deck (one component, accepts Categories array) — server-renders public copy, hydrates for interactivity

  5. Wire WhatsApp deeplink composition

  6. Wire guarded reveal

  7. Style pass — chip animation, price tick, card overlap

  8. Dogfood: she enters a second Category through Sveltia end-to-end. Iterate on field hints based on what confuses her.

Component placement follows the Arthouse stack — Svelte component (this is a UI surface, not markdown processing), styled with site tokens, no React. Per astro knots philosophy and the Arthouse-site CLAUDE.md.


11. References

  • Sveltia Constraints for CMS — what Sveltia can and can't do; why relation is load-bearing

  • public/admin/config.yml — current Sveltia collections

  • src/pages/services/index.astro — current stub

  • src/content/pricing/standard.md — current flat tier (to be migrated by hand)

  • WhatsApp deeplink format: https://wa.me/<E.164 phone>?text=<URL-encoded message>