srcdev-nuxt-components 9.1.58 → 9.1.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +4 -1
- package/.claude/skills/components/services-section.md +62 -6
- package/.claude/skills/components/tab-navigation.md +49 -0
- package/.claude/skills/index.md +1 -1
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +15 -15
- package/app/components/03.organisms/services/services-section/CONSUMER-STYLING.md +149 -0
- package/app/components/03.organisms/services/services-section/ServicesSection.vue +83 -59
- package/app/components/03.organisms/services/services-section/stories/ServicesSection.stories.ts +186 -32
- package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +71 -0
- package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +10 -10
- package/package.json +1 -1
package/.claude/settings.json
CHANGED
|
@@ -54,7 +54,10 @@
|
|
|
54
54
|
"Bash(SRCDEV_STANDALONE=true npx vitest run app/components/01.atoms/toast)",
|
|
55
55
|
"Bash(SRCDEV_STANDALONE=true npx vitest run app/components/02.molecules/alert-content)",
|
|
56
56
|
"Bash(SRCDEV_STANDALONE=true npx vitest run app/components/01.atoms/prompt)",
|
|
57
|
-
"Bash(git fetch *)"
|
|
57
|
+
"Bash(git fetch *)",
|
|
58
|
+
"Bash(python3 -m json.tool /Users/simoncornforth/websites/nuxt-components/.vscode/srcdev-component-services-section.code-snippets)",
|
|
59
|
+
"Bash(node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('/Users/simoncornforth/websites/nuxt-components/.vscode/srcdev-component-services-section.code-snippets','utf8'\\)\\); console.log\\('valid json'\\)\")",
|
|
60
|
+
"WebFetch(domain:luxury-locs-by-natasha-nuxt3.vercel.app)"
|
|
58
61
|
],
|
|
59
62
|
"additionalDirectories": [
|
|
60
63
|
"/Users/simoncornforth/websites/instepreflexology",
|
|
@@ -15,14 +15,40 @@ All routing and CTA decisions are delegated to the consumer via slots.
|
|
|
15
15
|
|------|------|---------|----------|
|
|
16
16
|
| `serviceData` | `Service` | — | **yes** |
|
|
17
17
|
| `tag` | `"div" \| "section" \| "article" \| "main"` | `"div"` | no |
|
|
18
|
+
| `headerTag` | `"h1" \| "h2" \| "h3"` | `"h2"` | no |
|
|
18
19
|
| `index` | `number` | `0` | no |
|
|
19
20
|
| `isSummary` | `boolean` | `false` | no |
|
|
20
21
|
| `summaryAlignment` | `"start" \| "center" \| "end"` | `"center"` | no |
|
|
21
22
|
| `reverse` | `boolean` | `false` | no |
|
|
22
23
|
| `durationIcon` | `string` | `"mdi:clock-time-four-outline"` | no |
|
|
23
24
|
| `priceIcon` | `string` | `"mdi:currency-gbp"` | no |
|
|
25
|
+
| `processHeading` | `string` | `"The Process"` | no |
|
|
26
|
+
| `idealForHeading` | `string` | `"Ideal For"` | no |
|
|
27
|
+
| `maintenanceHeading` | `string` | `"Aftercare & Maintenance"` | no |
|
|
28
|
+
| `faqsHeading` | `string` | `"Frequently Asked Questions"` | no |
|
|
29
|
+
| `ctaHeading` | `string` | `"Ready to book your appointment?"` | no |
|
|
30
|
+
| `ctaBody` | `string` | `"Get in touch to book your appointment."` | no |
|
|
24
31
|
| `styleClassPassthrough` | `string \| string[]` | `[]` | no |
|
|
25
32
|
|
|
33
|
+
### Heading and CTA copy props
|
|
34
|
+
|
|
35
|
+
All section subheadings and the default CTA panel's heading/body are props, not hardcoded
|
|
36
|
+
text — override them with wording specific to the consuming business:
|
|
37
|
+
|
|
38
|
+
```vue
|
|
39
|
+
<ServicesSection
|
|
40
|
+
:service-data="service"
|
|
41
|
+
process-heading="How It Works"
|
|
42
|
+
cta-heading="Ready to book your colour appointment?"
|
|
43
|
+
cta-body="Mobile service across Bath — I come to you."
|
|
44
|
+
/>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Name the actual service in `ctaHeading` (rather than a generic phrase) if every service page
|
|
48
|
+
shares one CTA wrapper — a single hardcoded heading applied to every service previously caused
|
|
49
|
+
a real bug in production, where a "Colour" service page's CTA read "Ready to book your
|
|
50
|
+
**highlights** appointment?" regardless of which service was being viewed.
|
|
51
|
+
|
|
26
52
|
### Icon customisation
|
|
27
53
|
|
|
28
54
|
The two icons in the price/duration row default to `mdi:clock-time-four-outline` and `mdi:currency-gbp`. Override them for a different currency or icon style:
|
|
@@ -51,8 +77,12 @@ Flips the image to the right column and content to the left (CSS `order: 2` on t
|
|
|
51
77
|
|------|-----------|---------------|---------|
|
|
52
78
|
| `summary-link` | `{ serviceData: Service }` | `isSummary` is `true` | Navigation link below the `whatIsIt` text in summary mode |
|
|
53
79
|
| `cta` | `{ serviceData: Service }` | `isSummary` is `false` | CTA button/link inside the closing `GlassPanel` in full mode |
|
|
80
|
+
| `cta-panel` | `{ serviceData: Service, ctaHeading: string, ctaBody: string }` | `isSummary` is `false` | Replaces the entire default `GlassPanel` CTA block — use when a consumer needs a different component or layout, not just different copy |
|
|
54
81
|
|
|
55
|
-
|
|
82
|
+
All three slots receive `serviceData` as a scoped prop. `cta-panel`'s default content *is*
|
|
83
|
+
the `GlassPanel` + `ctaHeading`/`ctaBody` + `cta` slot combination described above — providing
|
|
84
|
+
`cta-panel` replaces that whole block, so the `ctaHeading`/`ctaBody` props and `cta` slot are
|
|
85
|
+
only useful if the `cta-panel` slot fallback (or the consumer's own override) still renders them.
|
|
56
86
|
|
|
57
87
|
## Summary mode usage
|
|
58
88
|
|
|
@@ -102,6 +132,21 @@ Both slots receive `serviceData` as a scoped prop.
|
|
|
102
132
|
</ServicesSection>
|
|
103
133
|
```
|
|
104
134
|
|
|
135
|
+
## Replacing the whole CTA panel (slot override)
|
|
136
|
+
|
|
137
|
+
Use `cta-panel` instead of `ctaHeading`/`ctaBody` when the CTA needs a different component
|
|
138
|
+
entirely — not just different text inside `GlassPanel`:
|
|
139
|
+
|
|
140
|
+
```vue
|
|
141
|
+
<ServicesSection :service-data="service">
|
|
142
|
+
<template #cta-panel="{ serviceData, ctaHeading, ctaBody }">
|
|
143
|
+
<MyPromoBanner :heading="ctaHeading" :body="ctaBody">
|
|
144
|
+
<template #cta><InputButtonCore button-text="Enquire Now" href="/contact" /></template>
|
|
145
|
+
</MyPromoBanner>
|
|
146
|
+
</template>
|
|
147
|
+
</ServicesSection>
|
|
148
|
+
```
|
|
149
|
+
|
|
105
150
|
## Rendering a list (summary mode)
|
|
106
151
|
|
|
107
152
|
```vue
|
|
@@ -122,9 +167,12 @@ Both slots receive `serviceData` as a scoped prop.
|
|
|
122
167
|
|
|
123
168
|
## Local style override scaffold
|
|
124
169
|
|
|
125
|
-
When consuming this component,
|
|
170
|
+
When consuming this component, prefer the `--services-section-*` CSS custom properties
|
|
171
|
+
documented in `CONSUMER-STYLING.md` (in the component's own folder) over raw class overrides.
|
|
172
|
+
For anything the tokens don't cover, scaffold a style block using `styleClassPassthrough`.
|
|
173
|
+
Delete the block if unused.
|
|
126
174
|
|
|
127
|
-
See [component-local-style-override.md](../component-local-style-override.md) for the
|
|
175
|
+
See [component-local-style-override.md](../component-local-style-override.md) for the general pattern.
|
|
128
176
|
|
|
129
177
|
```vue
|
|
130
178
|
<ServicesSection :style-class-passthrough="['my-section']" :service-data="service">
|
|
@@ -138,8 +186,10 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
|
|
|
138
186
|
─────────────────────────────────────────────────────────────────── */
|
|
139
187
|
.services-section {
|
|
140
188
|
&.my-section {
|
|
141
|
-
|
|
142
|
-
|
|
189
|
+
--services-section-image-border-radius: 1.2rem;
|
|
190
|
+
|
|
191
|
+
/* Deeper overrides target the BEM element classes directly, e.g.: */
|
|
192
|
+
/* .services-section__faq { } */
|
|
143
193
|
}
|
|
144
194
|
}
|
|
145
195
|
</style>
|
|
@@ -150,6 +200,12 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
|
|
|
150
200
|
- Component is auto-imported in Nuxt — no import needed.
|
|
151
201
|
- The `Service` type is imported from `~/types/types.services`.
|
|
152
202
|
- `summary-link` slot is guarded by `v-if="isSummary"` — it will not render in full mode even if provided.
|
|
153
|
-
- `cta`
|
|
203
|
+
- `cta` and `cta-panel` slots live inside `v-if="!isSummary"` — neither renders in summary mode.
|
|
204
|
+
- Internal element classes are BEM-namespaced: `services-section__image-wrapper`,
|
|
205
|
+
`services-section__info-wrapper`, `services-section__price-duration`,
|
|
206
|
+
`services-section__decorator`, `services-section__faq`, `services-section__faq-answer`,
|
|
207
|
+
`services-section__glass-panel`. If a consuming app has CSS overrides referencing older
|
|
208
|
+
unprefixed names (`.image-wrapper`, `.price-duration`, `.services-faq`, etc. — from before
|
|
209
|
+
this component's classnames were namespaced), those selectors need updating to match.
|
|
154
210
|
- The section gets `aria-labelledby` automatically when `tag` is `"section"` or `"article"`, pointing to the internal heading id.
|
|
155
211
|
- `summaryAlignment` only has effect when `isSummary` is `true` — it aligns the info-wrapper content vertically within the grid cell.
|
|
@@ -220,3 +220,52 @@ the component source folder for the full token reference.
|
|
|
220
220
|
stacking-context constraint.
|
|
221
221
|
- `--tab-nav-panel-bg` should match `--page-bg` for a seamless panel appearance in the collapsed
|
|
222
222
|
state.
|
|
223
|
+
|
|
224
|
+
## Known issue: SSR/hydration layout shift when the nav needs to collapse (2026-07-07)
|
|
225
|
+
|
|
226
|
+
**Symptom:** on first page load (hard reload/direct navigation), the header briefly renders taller
|
|
227
|
+
than its settled state, then shrinks — visibly shifting all content below it up. Most noticeable on
|
|
228
|
+
short pages (little content below the fold makes the jump occupy a large fraction of the viewport),
|
|
229
|
+
but the same underlying reflow happens on every page where the nav actually needs to collapse at
|
|
230
|
+
the viewport's width; it's just less visible on long pages or when the timing happens to resolve
|
|
231
|
+
before first paint.
|
|
232
|
+
|
|
233
|
+
**Root cause:** `useNavCollapse`'s `isLoaded` ref (and therefore `isCollapsed`) can only be
|
|
234
|
+
determined client-side — the server has no way to measure real DOM/viewport widths. So:
|
|
235
|
+
|
|
236
|
+
```vue
|
|
237
|
+
<ul v-if="!isCollapsed || !isLoaded" ...>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
SSR and the very first client paint always render the **full, uncollapsed** `<ul>` (since
|
|
241
|
+
`!isLoaded` is true pre-mount), regardless of whether it will actually fit. Once mounted,
|
|
242
|
+
`checkOverflow()` runs (in `onMounted`, after `nextTick()`), and if the nav overflows, sets
|
|
243
|
+
`isCollapsed = true` — swapping the full list out for the shorter collapsed/burger variant. That
|
|
244
|
+
swap is what shrinks the header and shifts everything below it.
|
|
245
|
+
|
|
246
|
+
Confirmed via `PerformanceObserver({type: "layout-shift"})` plus DOM `MutationObserver` tracing in a
|
|
247
|
+
consuming app (`luxury-locs-by-natasha-nuxt3`): the class mutation on `.tab-navigation` (adding
|
|
248
|
+
`is-loaded is-animated`) lines up exactly with `.main-content`'s bounding-rect top moving by the
|
|
249
|
+
header's full height delta, at a variable point (roughly 100ms–800ms after first paint depending on
|
|
250
|
+
hydration timing) after first paint.
|
|
251
|
+
|
|
252
|
+
**Candidate fixes (not yet implemented — pick up later):**
|
|
253
|
+
|
|
254
|
+
1. **CSS-only collapse instead of JS-measured overflow.** Use a `@container` or `@media` breakpoint
|
|
255
|
+
to switch between the full nav and the burger button, so the correct variant renders on the very
|
|
256
|
+
first paint (both server and client agree, no measurement round-trip needed). Loses the
|
|
257
|
+
"collapse exactly when it overflows, regardless of item count" precision of the current
|
|
258
|
+
JS-measured approach, gains zero-reflow correctness. Probably the most robust fix if a
|
|
259
|
+
reasonably conservative breakpoint can be chosen.
|
|
260
|
+
2. **Reserve height instead of eliminating the swap.** Give the header a `min-height` matching the
|
|
261
|
+
taller (uncollapsed) state so the swap doesn't change the header's box size — trades a visible
|
|
262
|
+
"jump" for a slightly awkward gap during the pre-collapse window, but never moves content below
|
|
263
|
+
it. Cheap, but doesn't fix the actual root cause, just its visible symptom.
|
|
264
|
+
3. **Suppress the flash rather than the shift**, e.g. hide the header's nav content entirely
|
|
265
|
+
(`visibility: hidden` or `opacity: 0`) until `isLoaded` is true, then fade in — avoids showing
|
|
266
|
+
the "wrong" state at all, but delays when the nav becomes visible/interactive, and would need
|
|
267
|
+
care to avoid its own CLS/accessibility issues (e.g. focus order, screen readers encountering
|
|
268
|
+
hidden nav).
|
|
269
|
+
4. Investigate whether a plausible default guess for `isCollapsed` could be derived from something
|
|
270
|
+
SSR does have access to (e.g. a `Sec-CH-Viewport-Width` client hint header, if the deployment
|
|
271
|
+
target reliably sends one) — likely not worth the complexity/fragility versus option 1.
|
package/.claude/skills/index.md
CHANGED
|
@@ -67,7 +67,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
67
67
|
├── services-card.md — ServicesCard props (incl. eyebrowConfig/heroConfig), actions slot, CSS tokens, page boilerplate
|
|
68
68
|
├── services-card-grid.md — ServicesCardGrid props, config pass-through, CSS tokens, full page boilerplate
|
|
69
69
|
├── services-section-grid.md — ServicesSectionGrid props, useAlternateReverse zigzag layout, page boilerplate
|
|
70
|
-
├── services-section.md — ServicesSection props, summary-link/cta slots, summary vs full mode
|
|
70
|
+
├── services-section.md — ServicesSection props (incl. heading/CTA copy), summary-link/cta/cta-panel slots, summary vs full mode
|
|
71
71
|
├── contact-section.md — ContactSection props (stepperIndicatorSize pass-through), 3-item info+form layout, slot API
|
|
72
72
|
├── stepper-list.md — StepperList dynamic slots (item-{n}/indicator-{n}), props, connector behaviour
|
|
73
73
|
├── expanding-panel.md — ExpandingPanel v-model, forceOpened, slots (summary/icon/content), ARIA wiring
|
|
@@ -4,15 +4,15 @@ exports[`ServicesSectionGrid > renders correct HTML structure 1`] = `
|
|
|
4
4
|
"<div class="services-grid">
|
|
5
5
|
<div class="services-section">
|
|
6
6
|
<div class="services-section__grid">
|
|
7
|
-
<div class="
|
|
8
|
-
<div class="
|
|
7
|
+
<div class="services-section__image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-installation.jpg 1x, /_ipx/_/images/locs-installation.jpg 2x" alt="Locs Installation" loading="eager" fetchpriority="high" class="services-section__image" src="/_ipx/_/images/locs-installation.jpg"></div>
|
|
8
|
+
<div class="services-section__info-wrapper services-section__info-wrapper--summary services-section__info-wrapper--align-center">
|
|
9
9
|
<div class="eyebrow-text large">Subtitle for Locs Installation</div>
|
|
10
10
|
<h2 class="hero-text mb-20 title axis-horizontal">
|
|
11
11
|
<!--v-if--><span class="text-block-0 normal">Locs Installation</span>
|
|
12
12
|
</h2>
|
|
13
|
-
<div class="
|
|
14
|
-
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline
|
|
15
|
-
<div class="flex-row"><span class="iconify i-mdi:currency-gbp
|
|
13
|
+
<div class="services-section__price-duration">
|
|
14
|
+
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline services-section__decorator" aria-hidden="true"></span><span>60 mins</span></div>
|
|
15
|
+
<div class="flex-row"><span class="iconify i-mdi:currency-gbp services-section__decorator" aria-hidden="true"></span><span>£50</span></div>
|
|
16
16
|
</div>
|
|
17
17
|
<!--v-if-->
|
|
18
18
|
<!--v-if-->
|
|
@@ -31,15 +31,15 @@ exports[`ServicesSectionGrid > renders correct HTML structure 1`] = `
|
|
|
31
31
|
</div>
|
|
32
32
|
<div class="services-section">
|
|
33
33
|
<div class="services-section__grid">
|
|
34
|
-
<div class="
|
|
35
|
-
<div class="
|
|
34
|
+
<div class="services-section__image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-retwist.jpg 1x, /_ipx/_/images/locs-retwist.jpg 2x" alt="Locs Retwist" loading="eager" fetchpriority="auto" class="services-section__image" src="/_ipx/_/images/locs-retwist.jpg"></div>
|
|
35
|
+
<div class="services-section__info-wrapper services-section__info-wrapper--summary services-section__info-wrapper--align-center">
|
|
36
36
|
<div class="eyebrow-text large">Subtitle for Locs Retwist</div>
|
|
37
37
|
<h2 class="hero-text mb-20 title axis-horizontal">
|
|
38
38
|
<!--v-if--><span class="text-block-0 normal">Locs Retwist</span>
|
|
39
39
|
</h2>
|
|
40
|
-
<div class="
|
|
41
|
-
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline
|
|
42
|
-
<div class="flex-row"><span class="iconify i-mdi:currency-gbp
|
|
40
|
+
<div class="services-section__price-duration">
|
|
41
|
+
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline services-section__decorator" aria-hidden="true"></span><span>60 mins</span></div>
|
|
42
|
+
<div class="flex-row"><span class="iconify i-mdi:currency-gbp services-section__decorator" aria-hidden="true"></span><span>£50</span></div>
|
|
43
43
|
</div>
|
|
44
44
|
<!--v-if-->
|
|
45
45
|
<!--v-if-->
|
|
@@ -58,15 +58,15 @@ exports[`ServicesSectionGrid > renders correct HTML structure 1`] = `
|
|
|
58
58
|
</div>
|
|
59
59
|
<div class="services-section">
|
|
60
60
|
<div class="services-section__grid">
|
|
61
|
-
<div class="
|
|
62
|
-
<div class="
|
|
61
|
+
<div class="services-section__image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/colour-treatment.jpg 1x, /_ipx/_/images/colour-treatment.jpg 2x" alt="Colour Treatment" loading="lazy" fetchpriority="auto" class="services-section__image" src="/_ipx/_/images/colour-treatment.jpg"></div>
|
|
62
|
+
<div class="services-section__info-wrapper services-section__info-wrapper--summary services-section__info-wrapper--align-center">
|
|
63
63
|
<div class="eyebrow-text large">Subtitle for Colour Treatment</div>
|
|
64
64
|
<h2 class="hero-text mb-20 title axis-horizontal">
|
|
65
65
|
<!--v-if--><span class="text-block-0 normal">Colour Treatment</span>
|
|
66
66
|
</h2>
|
|
67
|
-
<div class="
|
|
68
|
-
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline
|
|
69
|
-
<div class="flex-row"><span class="iconify i-mdi:currency-gbp
|
|
67
|
+
<div class="services-section__price-duration">
|
|
68
|
+
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline services-section__decorator" aria-hidden="true"></span><span>60 mins</span></div>
|
|
69
|
+
<div class="flex-row"><span class="iconify i-mdi:currency-gbp services-section__decorator" aria-hidden="true"></span><span>£50</span></div>
|
|
70
70
|
</div>
|
|
71
71
|
<!--v-if-->
|
|
72
72
|
<!--v-if-->
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# ServicesSection — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
All `--services-section-*` tokens are the stable override surface. Set them globally in a
|
|
6
|
+
theme file, scoped to a page wrapper, or per-instance via `styleClassPassthrough`.
|
|
7
|
+
|
|
8
|
+
### Layout & sizing
|
|
9
|
+
|
|
10
|
+
| Token | Default | Controls |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| `--services-section-grid-gap` | `2rem` | Gap between image and content columns below the 768px breakpoint |
|
|
13
|
+
| `--services-section-grid-gap-desktop` | `3rem` | Gap between columns at 768px and above |
|
|
14
|
+
| `--services-section-image-border-radius` | `0.8rem` | Corner rounding on the service image |
|
|
15
|
+
| `--services-section-price-duration-gap` | `3rem` | Gap between the duration and price rows |
|
|
16
|
+
| `--services-section-price-duration-item-gap` | `0.8rem` | Gap between icon and text within a single row |
|
|
17
|
+
| `--services-section-price-duration-margin-block-end` | `2rem` | Space below the price/duration row |
|
|
18
|
+
| `--services-section-decorator-size` | `2rem` | Width/height of the duration and price icons |
|
|
19
|
+
| `--services-section-faq-margin-block-end` | `2.4rem` | Space below the FAQ accordion |
|
|
20
|
+
| `--services-section-faq-answer-line-height` | `1.6` | Line height of FAQ answer text |
|
|
21
|
+
|
|
22
|
+
### Colours
|
|
23
|
+
|
|
24
|
+
| Token | Default | Controls |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `--services-section-faq-divider-color` | `currentColor` | Border colour between FAQ accordion items |
|
|
27
|
+
| `--services-section-faq-divider-opacity` | `0.7` | Opacity of each FAQ accordion item's border |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Text content — props, not CSS
|
|
32
|
+
|
|
33
|
+
Section headings and the default CTA panel copy are **props**, not hardcoded strings, so
|
|
34
|
+
there is nothing to override in CSS for these:
|
|
35
|
+
|
|
36
|
+
| Prop | Default |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `processHeading` | `"The Process"` |
|
|
39
|
+
| `idealForHeading` | `"Ideal For"` |
|
|
40
|
+
| `maintenanceHeading` | `"Aftercare & Maintenance"` |
|
|
41
|
+
| `faqsHeading` | `"Frequently Asked Questions"` |
|
|
42
|
+
| `ctaHeading` | `"Ready to book your appointment?"` |
|
|
43
|
+
| `ctaBody` | `"Get in touch to book your appointment."` |
|
|
44
|
+
|
|
45
|
+
```vue
|
|
46
|
+
<ServicesSection
|
|
47
|
+
:service-data="service"
|
|
48
|
+
cta-heading="Ready to book your locs consultation?"
|
|
49
|
+
cta-body="Mobile service across Bath — I come to you."
|
|
50
|
+
/>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If the CTA needs a different component entirely (not just different text), replace it with
|
|
54
|
+
the `cta-panel` scoped slot instead of styling around the default `GlassPanel`:
|
|
55
|
+
|
|
56
|
+
```vue
|
|
57
|
+
<ServicesSection :service-data="service">
|
|
58
|
+
<template #cta-panel="{ serviceData, ctaHeading, ctaBody }">
|
|
59
|
+
<MyCustomPanel :heading="ctaHeading" :body="ctaBody">
|
|
60
|
+
<template #cta><button>Enquire Now</button></template>
|
|
61
|
+
</MyCustomPanel>
|
|
62
|
+
</template>
|
|
63
|
+
</ServicesSection>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Global theming — recommended approach
|
|
69
|
+
|
|
70
|
+
Create `assets/styles/setup/07.components/services-section.css` in the consuming app and
|
|
71
|
+
set tokens on `:root`. This applies to every `ServicesSection` across the site.
|
|
72
|
+
|
|
73
|
+
```css
|
|
74
|
+
/* assets/styles/setup/07.components/services-section.css */
|
|
75
|
+
:root {
|
|
76
|
+
--services-section-grid-gap: 2.4rem;
|
|
77
|
+
--services-section-grid-gap-desktop: 4rem;
|
|
78
|
+
--services-section-image-border-radius: 1.2rem;
|
|
79
|
+
--services-section-faq-divider-color: var(--brand-border);
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Page-scoped overrides
|
|
86
|
+
|
|
87
|
+
Override tokens for a specific section by scoping them under the page or layout wrapper.
|
|
88
|
+
No `:deep()` is required (component styles are unscoped).
|
|
89
|
+
|
|
90
|
+
```css
|
|
91
|
+
/* In the consuming page's unscoped <style> block */
|
|
92
|
+
.our-services-page {
|
|
93
|
+
.services-section {
|
|
94
|
+
--services-section-decorator-size: 2.4rem;
|
|
95
|
+
--services-section-faq-answer-line-height: 1.8;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Per-instance overrides via styleClassPassthrough
|
|
103
|
+
|
|
104
|
+
Use sparingly — prefer global or page-scoped CSS. When a single instance needs a distinct
|
|
105
|
+
visual style, pass a modifier class:
|
|
106
|
+
|
|
107
|
+
```vue
|
|
108
|
+
<ServicesSection :style-class-passthrough="['highlight-service']" :service-data="service" />
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```css
|
|
112
|
+
.services-section.highlight-service {
|
|
113
|
+
--services-section-image-border-radius: 2rem;
|
|
114
|
+
--services-section-faq-divider-color: var(--color-accent);
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## FAQ accordion styling
|
|
121
|
+
|
|
122
|
+
The `AccordianCore` inside this component receives `services-section__faq` via
|
|
123
|
+
`styleClassPassthrough`. To target the accordion's own internals (summary, icon rotation,
|
|
124
|
+
content padding), style through that class rather than reaching into `AccordianCore`
|
|
125
|
+
directly — see `.services-section__faq` in the component's own `<style>` block for the
|
|
126
|
+
selectors already scoped this way (`.accordian-item.expanding-panel`,
|
|
127
|
+
`.expanding-panel-summary`, `.services-section__faq-answer`).
|
|
128
|
+
|
|
129
|
+
```css
|
|
130
|
+
.services-section {
|
|
131
|
+
.services-section__faq {
|
|
132
|
+
.expanding-panel-details .expanding-panel-summary {
|
|
133
|
+
padding-block: 1.6rem;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Notes
|
|
142
|
+
|
|
143
|
+
- `--services-section-grid-gap-desktop` only applies at `768px` and above — the breakpoint
|
|
144
|
+
itself is not a token, since changing it would affect the grid's `minmax()` column sizing
|
|
145
|
+
too, not just spacing.
|
|
146
|
+
- The CTA panel is rendered inside a default `<slot name="cta-panel">` — passing that slot
|
|
147
|
+
bypasses `GlassPanel` and all of its tokens (`--glass-panel-*`) entirely, along with the
|
|
148
|
+
`ctaHeading`/`ctaBody` props, which are handed to the slot for reuse rather than applied
|
|
149
|
+
automatically.
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<component :is="tag" class="services-section" :class="[elementClasses]" :aria-labelledby="ariaLabelledby">
|
|
3
3
|
<div class="services-section__grid" :class="{ 'services-section__grid--reverse': reverse }">
|
|
4
|
-
<div class="
|
|
5
|
-
<NuxtImg
|
|
4
|
+
<div class="services-section__image-wrapper">
|
|
5
|
+
<NuxtImg
|
|
6
|
+
:src="serviceData.image"
|
|
7
|
+
:alt="serviceData.title"
|
|
8
|
+
:loading="imageLoading"
|
|
9
|
+
:fetchpriority="imageFetchPriority"
|
|
10
|
+
class="services-section__image"
|
|
11
|
+
/>
|
|
6
12
|
</div>
|
|
7
|
-
<div class="
|
|
13
|
+
<div class="services-section__info-wrapper" :class="infoWrapperClasses">
|
|
8
14
|
<EyebrowText font-size="large" :text-content="serviceData.subtitle" />
|
|
9
15
|
<HeroText
|
|
10
16
|
:tag="headerTag"
|
|
@@ -18,13 +24,13 @@
|
|
|
18
24
|
:style-class-passthrough="['mb-20']"
|
|
19
25
|
/>
|
|
20
26
|
|
|
21
|
-
<div class="
|
|
27
|
+
<div class="services-section__price-duration">
|
|
22
28
|
<div class="flex-row">
|
|
23
|
-
<Icon :name="durationIcon" class="
|
|
29
|
+
<Icon :name="durationIcon" class="services-section__decorator" />
|
|
24
30
|
<span>{{ serviceData.duration }}</span>
|
|
25
31
|
</div>
|
|
26
32
|
<div class="flex-row">
|
|
27
|
-
<Icon :name="priceIcon" class="
|
|
33
|
+
<Icon :name="priceIcon" class="services-section__decorator" />
|
|
28
34
|
<span>{{ serviceData.price }}</span>
|
|
29
35
|
</div>
|
|
30
36
|
</div>
|
|
@@ -50,7 +56,7 @@
|
|
|
50
56
|
:tag="headerTag"
|
|
51
57
|
axis="horizontal"
|
|
52
58
|
font-size="subheading"
|
|
53
|
-
:text-content="[{ text:
|
|
59
|
+
:text-content="[{ text: processHeading, styleClass: 'normal' }]"
|
|
54
60
|
:style-class-passthrough="['mb-20']"
|
|
55
61
|
/>
|
|
56
62
|
|
|
@@ -72,7 +78,7 @@
|
|
|
72
78
|
:tag="headerTag"
|
|
73
79
|
axis="horizontal"
|
|
74
80
|
font-size="subheading"
|
|
75
|
-
:text-content="[{ text:
|
|
81
|
+
:text-content="[{ text: idealForHeading, styleClass: 'normal' }]"
|
|
76
82
|
:style-class-passthrough="['mb-20']"
|
|
77
83
|
/>
|
|
78
84
|
|
|
@@ -90,7 +96,7 @@
|
|
|
90
96
|
:tag="headerTag"
|
|
91
97
|
axis="horizontal"
|
|
92
98
|
font-size="subheading"
|
|
93
|
-
:text-content="[{ text:
|
|
99
|
+
:text-content="[{ text: maintenanceHeading, styleClass: 'normal' }]"
|
|
94
100
|
:style-class-passthrough="['mb-20']"
|
|
95
101
|
/>
|
|
96
102
|
|
|
@@ -101,7 +107,7 @@
|
|
|
101
107
|
:tag="headerTag"
|
|
102
108
|
axis="horizontal"
|
|
103
109
|
font-size="subheading"
|
|
104
|
-
:text-content="[{ text:
|
|
110
|
+
:text-content="[{ text: faqsHeading, styleClass: 'normal' }]"
|
|
105
111
|
:style-class-passthrough="['mb-20']"
|
|
106
112
|
/>
|
|
107
113
|
|
|
@@ -110,7 +116,7 @@
|
|
|
110
116
|
id="faq"
|
|
111
117
|
:item-count="serviceData.faqs.length"
|
|
112
118
|
:name="`faq-${useId()}`"
|
|
113
|
-
:style-class-passthrough="['services-
|
|
119
|
+
:style-class-passthrough="['services-section__faq']"
|
|
114
120
|
>
|
|
115
121
|
<template v-for="(_, key) in serviceData.faqs" :key="`summary-${key}`" #[`accordian-${key}-summary`]>
|
|
116
122
|
{{ serviceData.faqs[key]?.question }}
|
|
@@ -119,21 +125,29 @@
|
|
|
119
125
|
<Icon name="mdi:chevron-down" />
|
|
120
126
|
</template>
|
|
121
127
|
<template v-for="(_, key) in serviceData.faqs" :key="`content-${key}`" #[`accordian-${key}-content`]>
|
|
122
|
-
<p>{{ serviceData.faqs[key]?.answer }}</p>
|
|
128
|
+
<p class="services-section__faq-answer">{{ serviceData.faqs[key]?.answer }}</p>
|
|
123
129
|
</template>
|
|
124
130
|
</AccordianCore>
|
|
125
131
|
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
132
|
+
<slot
|
|
133
|
+
v-if="!isSummary"
|
|
134
|
+
name="cta-panel"
|
|
135
|
+
:service-data="serviceData"
|
|
136
|
+
:cta-heading="ctaHeading"
|
|
137
|
+
:cta-body="ctaBody"
|
|
138
|
+
>
|
|
139
|
+
<GlassPanel :style-class-passthrough="['services-section__glass-panel', 'p-24']">
|
|
140
|
+
<HeroText
|
|
141
|
+
tag="h2"
|
|
142
|
+
axis="horizontal"
|
|
143
|
+
font-size="subheading"
|
|
144
|
+
:text-content="[{ text: ctaHeading, styleClass: 'normal' }]"
|
|
145
|
+
:style-class-passthrough="['mbs-0', 'mbe-20']"
|
|
146
|
+
/>
|
|
147
|
+
<p class="page-body-normal">{{ ctaBody }}</p>
|
|
148
|
+
<slot name="cta" :service-data="serviceData"></slot>
|
|
149
|
+
</GlassPanel>
|
|
150
|
+
</slot>
|
|
137
151
|
</div>
|
|
138
152
|
</div>
|
|
139
153
|
</component>
|
|
@@ -152,6 +166,12 @@ interface Props {
|
|
|
152
166
|
reverse?: boolean;
|
|
153
167
|
durationIcon?: string;
|
|
154
168
|
priceIcon?: string;
|
|
169
|
+
processHeading?: string;
|
|
170
|
+
idealForHeading?: string;
|
|
171
|
+
maintenanceHeading?: string;
|
|
172
|
+
faqsHeading?: string;
|
|
173
|
+
ctaHeading?: string;
|
|
174
|
+
ctaBody?: string;
|
|
155
175
|
styleClassPassthrough?: string | string[];
|
|
156
176
|
}
|
|
157
177
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -162,6 +182,12 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
162
182
|
summaryAlignment: "center",
|
|
163
183
|
durationIcon: "mdi:clock-time-four-outline",
|
|
164
184
|
priceIcon: "mdi:currency-gbp",
|
|
185
|
+
processHeading: "The Process",
|
|
186
|
+
idealForHeading: "Ideal For",
|
|
187
|
+
maintenanceHeading: "Aftercare & Maintenance",
|
|
188
|
+
faqsHeading: "Frequently Asked Questions",
|
|
189
|
+
ctaHeading: "Ready to book your appointment?",
|
|
190
|
+
ctaBody: "Get in touch to book your appointment.",
|
|
165
191
|
styleClassPassthrough: () => [],
|
|
166
192
|
});
|
|
167
193
|
|
|
@@ -169,10 +195,10 @@ const { ariaLabelledby } = useAriaLabelledById(() => props.tag);
|
|
|
169
195
|
|
|
170
196
|
const infoWrapperClasses = computed(() => {
|
|
171
197
|
return {
|
|
172
|
-
"
|
|
173
|
-
"align-start": props.isSummary && props.summaryAlignment === "start",
|
|
174
|
-
"align-center": props.isSummary && props.summaryAlignment === "center",
|
|
175
|
-
"align-end": props.isSummary && props.summaryAlignment === "end",
|
|
198
|
+
"services-section__info-wrapper--summary": props.isSummary,
|
|
199
|
+
"services-section__info-wrapper--align-start": props.isSummary && props.summaryAlignment === "start",
|
|
200
|
+
"services-section__info-wrapper--align-center": props.isSummary && props.summaryAlignment === "center",
|
|
201
|
+
"services-section__info-wrapper--align-end": props.isSummary && props.summaryAlignment === "end",
|
|
176
202
|
};
|
|
177
203
|
});
|
|
178
204
|
|
|
@@ -199,44 +225,47 @@ watch(
|
|
|
199
225
|
container-name: services-section;
|
|
200
226
|
|
|
201
227
|
.services-section__grid {
|
|
228
|
+
--_grid-gap: var(--services-section-grid-gap, 2rem);
|
|
229
|
+
|
|
202
230
|
display: grid;
|
|
203
231
|
grid-template-columns: 1fr;
|
|
204
|
-
gap:
|
|
232
|
+
gap: var(--_grid-gap);
|
|
205
233
|
|
|
206
234
|
@media (width >= 768px) {
|
|
235
|
+
--_grid-gap: var(--services-section-grid-gap-desktop, 3rem);
|
|
236
|
+
|
|
207
237
|
grid-template-columns: repeat(auto-fit, minmax(446px, 1fr));
|
|
208
|
-
gap: 3rem;
|
|
209
238
|
|
|
210
239
|
&.services-section__grid--reverse {
|
|
211
|
-
.
|
|
212
|
-
/* The order: 2 on .
|
|
240
|
+
.services-section__image-wrapper {
|
|
241
|
+
/* The order: 2 on .services-section__image-wrapper pushes it after the content div, since by default both children have order: 0 and source order wins — so the content div naturally sits first. */
|
|
213
242
|
order: 2;
|
|
214
243
|
}
|
|
215
244
|
}
|
|
216
245
|
}
|
|
217
246
|
|
|
218
|
-
.
|
|
219
|
-
&.
|
|
247
|
+
.services-section__info-wrapper {
|
|
248
|
+
&.services-section__info-wrapper--summary {
|
|
220
249
|
display: grid;
|
|
221
250
|
|
|
222
|
-
&.align-start {
|
|
251
|
+
&.services-section__info-wrapper--align-start {
|
|
223
252
|
align-content: start;
|
|
224
253
|
}
|
|
225
|
-
&.align-center {
|
|
254
|
+
&.services-section__info-wrapper--align-center {
|
|
226
255
|
align-content: center;
|
|
227
256
|
}
|
|
228
|
-
&.align-end {
|
|
257
|
+
&.services-section__info-wrapper--align-end {
|
|
229
258
|
align-content: end;
|
|
230
259
|
}
|
|
231
260
|
}
|
|
232
261
|
}
|
|
233
262
|
|
|
234
|
-
.
|
|
263
|
+
.services-section__image-wrapper {
|
|
235
264
|
align-self: start;
|
|
236
|
-
border-radius:
|
|
265
|
+
border-radius: var(--services-section-image-border-radius, 0.8rem);
|
|
237
266
|
overflow: hidden;
|
|
238
267
|
|
|
239
|
-
.
|
|
268
|
+
.services-section__image {
|
|
240
269
|
display: block;
|
|
241
270
|
width: 100%;
|
|
242
271
|
height: 100%;
|
|
@@ -244,45 +273,38 @@ watch(
|
|
|
244
273
|
}
|
|
245
274
|
}
|
|
246
275
|
|
|
247
|
-
.
|
|
276
|
+
.services-section__price-duration {
|
|
248
277
|
display: flex;
|
|
249
278
|
flex-direction: row;
|
|
250
|
-
gap: 3rem;
|
|
279
|
+
gap: var(--services-section-price-duration-gap, 3rem);
|
|
251
280
|
align-items: center;
|
|
252
|
-
margin-block-end: 2rem;
|
|
281
|
+
margin-block-end: var(--services-section-price-duration-margin-block-end, 2rem);
|
|
253
282
|
|
|
254
283
|
> div {
|
|
255
284
|
display: flex;
|
|
256
285
|
flex-direction: row;
|
|
257
|
-
gap: 0.8rem;
|
|
286
|
+
gap: var(--services-section-price-duration-item-gap, 0.8rem);
|
|
258
287
|
align-items: center;
|
|
259
288
|
|
|
260
|
-
.
|
|
261
|
-
width: 2rem;
|
|
262
|
-
height: 2rem;
|
|
289
|
+
.services-section__decorator {
|
|
290
|
+
width: var(--services-section-decorator-size, 2rem);
|
|
291
|
+
height: var(--services-section-decorator-size, 2rem);
|
|
263
292
|
}
|
|
264
293
|
}
|
|
265
294
|
}
|
|
266
295
|
|
|
267
|
-
.
|
|
268
|
-
|
|
269
|
-
border-radius: 1rem;
|
|
270
|
-
backdrop-filter: blur(10px);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
.services-faq {
|
|
274
|
-
margin-block-end: 24px;
|
|
296
|
+
.services-section__faq {
|
|
297
|
+
margin-block-end: var(--services-section-faq-margin-block-end, 2.4rem);
|
|
275
298
|
|
|
276
299
|
&.display-accordian {
|
|
277
300
|
max-width: none;
|
|
278
|
-
/* margin: 0; */
|
|
279
301
|
|
|
280
302
|
.accordian-item.expanding-panel {
|
|
281
|
-
border-block-end: 1px solid currentColor;
|
|
282
|
-
opacity: 0.7;
|
|
303
|
+
border-block-end: 1px solid var(--services-section-faq-divider-color, currentColor);
|
|
304
|
+
opacity: var(--services-section-faq-divider-opacity, 0.7);
|
|
283
305
|
|
|
284
306
|
&:first-child {
|
|
285
|
-
border-block-start: 1px solid currentColor;
|
|
307
|
+
border-block-start: 1px solid var(--services-section-faq-divider-color, currentColor);
|
|
286
308
|
}
|
|
287
309
|
|
|
288
310
|
.expanding-panel-details .expanding-panel-summary {
|
|
@@ -290,7 +312,9 @@ watch(
|
|
|
290
312
|
}
|
|
291
313
|
|
|
292
314
|
.expanding-panel-content .inner {
|
|
293
|
-
|
|
315
|
+
.services-section__faq-answer {
|
|
316
|
+
line-height: var(--services-section-faq-answer-line-height, 1.6);
|
|
317
|
+
}
|
|
294
318
|
}
|
|
295
319
|
}
|
|
296
320
|
}
|
package/app/components/03.organisms/services/services-section/stories/ServicesSection.stories.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import ServicesSection from "../ServicesSection.vue";
|
|
2
|
+
import PageRow from "../../../../01.atoms/page-row/PageRow.vue";
|
|
3
|
+
import TextBlock from "../../../../01.atoms/text-block/TextBlock.vue";
|
|
4
|
+
import EyebrowText from "../../../../01.atoms/text-blocks/eyebrow-text/EyebrowText.vue";
|
|
5
|
+
import HeroText from "../../../../01.atoms/text-blocks/hero-text/HeroText.vue";
|
|
6
|
+
import InputButtonCore from "../../../../05.forms/input-button/InputButtonCore.vue";
|
|
2
7
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
8
|
import type { Service } from "~/types/types.services";
|
|
4
9
|
|
|
@@ -37,6 +42,30 @@ const meta: Meta<typeof ServicesSection> = {
|
|
|
37
42
|
control: { type: "text" },
|
|
38
43
|
description: "Icon name for the price row (any Iconify icon)",
|
|
39
44
|
},
|
|
45
|
+
processHeading: {
|
|
46
|
+
control: { type: "text" },
|
|
47
|
+
description: "Heading text for the process section",
|
|
48
|
+
},
|
|
49
|
+
idealForHeading: {
|
|
50
|
+
control: { type: "text" },
|
|
51
|
+
description: "Heading text for the ideal-for section",
|
|
52
|
+
},
|
|
53
|
+
maintenanceHeading: {
|
|
54
|
+
control: { type: "text" },
|
|
55
|
+
description: "Heading text for the maintenance/aftercare section",
|
|
56
|
+
},
|
|
57
|
+
faqsHeading: {
|
|
58
|
+
control: { type: "text" },
|
|
59
|
+
description: "Heading text for the FAQs section",
|
|
60
|
+
},
|
|
61
|
+
ctaHeading: {
|
|
62
|
+
control: { type: "text" },
|
|
63
|
+
description: "Heading text inside the default CTA glass panel — ignored if the cta-panel slot is used",
|
|
64
|
+
},
|
|
65
|
+
ctaBody: {
|
|
66
|
+
control: { type: "text" },
|
|
67
|
+
description: "Body text inside the default CTA glass panel — ignored if the cta-panel slot is used",
|
|
68
|
+
},
|
|
40
69
|
styleClassPassthrough: {
|
|
41
70
|
control: "object",
|
|
42
71
|
description: "Additional CSS classes applied to the root element",
|
|
@@ -56,7 +85,7 @@ const meta: Meta<typeof ServicesSection> = {
|
|
|
56
85
|
docs: {
|
|
57
86
|
description: {
|
|
58
87
|
component:
|
|
59
|
-
"Renders a single service as a two-column section: image on one side, detailed content on the other. Two modes: summary (compact, with navigation slot) and full (complete content with CTA slot). Icon names for the price/duration row are configurable via `durationIcon` and `priceIcon` props.",
|
|
88
|
+
"Renders a single service as a two-column section: image on one side, detailed content on the other. Two modes: summary (compact, with navigation slot) and full (complete content with CTA slot). Icon names for the price/duration row are configurable via `durationIcon` and `priceIcon` props. Section headings and the CTA panel copy are configurable via props (`processHeading`, `idealForHeading`, `maintenanceHeading`, `faqsHeading`, `ctaHeading`, `ctaBody`); the whole CTA panel can be replaced with the `cta-panel` scoped slot.",
|
|
60
89
|
},
|
|
61
90
|
},
|
|
62
91
|
},
|
|
@@ -68,65 +97,77 @@ type Story = StoryObj<typeof ServicesSection>;
|
|
|
68
97
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
69
98
|
|
|
70
99
|
const sampleService: Service = {
|
|
71
|
-
slug: "
|
|
72
|
-
category: "
|
|
73
|
-
title: "
|
|
74
|
-
subtitle: "
|
|
75
|
-
price: "£
|
|
76
|
-
duration: "
|
|
77
|
-
image: "https://picsum.photos/seed/
|
|
78
|
-
shortDescription: "
|
|
100
|
+
slug: "colour",
|
|
101
|
+
category: "colour",
|
|
102
|
+
title: "Full Colour",
|
|
103
|
+
subtitle: "Rich, even colour from root to tip",
|
|
104
|
+
price: "From £75",
|
|
105
|
+
duration: "1.5–2.5 hours",
|
|
106
|
+
image: "https://picsum.photos/seed/colour/800/600",
|
|
107
|
+
shortDescription: "Full colour coverage in permanent, semi-permanent, or demi-permanent formulas.",
|
|
79
108
|
longDescription:
|
|
80
|
-
"
|
|
109
|
+
"Full colour service covering the entire head in a single, even shade — whether you're covering grey, going darker for the season, or refreshing your natural colour. We work with permanent, semi-permanent, and demi-permanent formulas depending on the level of commitment and colour result you're after.",
|
|
81
110
|
heroHeading: [
|
|
82
|
-
{ text: "
|
|
83
|
-
{ text: "
|
|
111
|
+
{ text: "Full colour vs. ", styleClass: "normal" },
|
|
112
|
+
{ text: "highlights?", styleClass: "accent" },
|
|
84
113
|
],
|
|
85
114
|
whatIsIt:
|
|
86
|
-
"
|
|
115
|
+
"Full colour applies a single, even shade across the entire head, unlike highlights or balayage which lighten select sections. It's the go-to choice for full grey coverage, a complete colour change, or a rich, uniform base tone.",
|
|
87
116
|
process: [
|
|
88
|
-
"Consultation
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
117
|
+
"Consultation and shade selection",
|
|
118
|
+
"Strand test for new clients",
|
|
119
|
+
"Even application, root to tip",
|
|
120
|
+
"Processing time — 25–45 minutes depending on the formula",
|
|
121
|
+
"Rinse and conditioning treatment",
|
|
122
|
+
"Blow-dry and finish",
|
|
92
123
|
],
|
|
93
124
|
idealFor: [
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
125
|
+
"Full grey coverage",
|
|
126
|
+
"Going darker for the season",
|
|
127
|
+
"Fashion colours needing a uniform base",
|
|
128
|
+
"Root touch-ups between full appointments",
|
|
97
129
|
],
|
|
98
130
|
maintenance:
|
|
99
|
-
"
|
|
131
|
+
"Root regrowth typically becomes visible after 4–6 weeks. Book a maintenance appointment around this point, and use a colour-safe shampoo and conditioner to protect vibrancy between visits.",
|
|
100
132
|
faqs: [
|
|
101
133
|
{
|
|
102
|
-
question: "
|
|
103
|
-
answer:
|
|
134
|
+
question: "What's the difference between permanent and semi-permanent colour?",
|
|
135
|
+
answer:
|
|
136
|
+
"Permanent colour lifts and deposits pigment for long-lasting, fade-resistant results. Semi-permanent colour sits on the hair's surface and gradually washes out over 4–8 weeks — a lower-commitment option if you're not sure about a change.",
|
|
104
137
|
},
|
|
105
138
|
{
|
|
106
|
-
question: "Can
|
|
107
|
-
answer:
|
|
139
|
+
question: "Can full colour lighten my hair significantly?",
|
|
140
|
+
answer:
|
|
141
|
+
"Full colour can lighten by a few shades, but for a dramatic lift (going several shades lighter) a lightening service or highlights are usually a better fit — we'll advise at consultation.",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
question: "How effective is full colour at covering grey?",
|
|
145
|
+
answer:
|
|
146
|
+
"Very effective — permanent colour gives the most complete, long-lasting grey coverage. We'll match a shade that blends naturally with your regrowth pattern.",
|
|
108
147
|
},
|
|
109
148
|
],
|
|
110
|
-
seoTitle: "
|
|
149
|
+
seoTitle: "Full Colour | Luxury Locs by Natasha",
|
|
111
150
|
seoDescription:
|
|
112
|
-
"
|
|
151
|
+
"Full colour hair service — permanent, semi-permanent, and demi-permanent options for grey coverage, seasonal colour changes, and fashion shades. Mobile service across Bath.",
|
|
113
152
|
};
|
|
114
153
|
|
|
115
154
|
// ─── Stories ──────────────────────────────────────────────────────────────────
|
|
116
155
|
|
|
117
156
|
export const FullMode: Story = {
|
|
118
157
|
name: "Full Mode",
|
|
158
|
+
args: {
|
|
159
|
+
ctaHeading: "Ready to book your appointment?",
|
|
160
|
+
ctaBody: "Mobile service across Bath — I come to you.",
|
|
161
|
+
},
|
|
119
162
|
render: (args) => ({
|
|
120
|
-
components: { ServicesSection },
|
|
163
|
+
components: { ServicesSection, InputButtonCore },
|
|
121
164
|
setup() {
|
|
122
165
|
return { args, sampleService };
|
|
123
166
|
},
|
|
124
167
|
template: `
|
|
125
168
|
<ServicesSection v-bind="args" :service-data="sampleService">
|
|
126
169
|
<template #cta>
|
|
127
|
-
<
|
|
128
|
-
Book Now
|
|
129
|
-
</button>
|
|
170
|
+
<InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" />
|
|
130
171
|
</template>
|
|
131
172
|
</ServicesSection>
|
|
132
173
|
`,
|
|
@@ -135,7 +176,7 @@ export const FullMode: Story = {
|
|
|
135
176
|
docs: {
|
|
136
177
|
description: {
|
|
137
178
|
story:
|
|
138
|
-
"Full mode (isSummary: false) — renders all content including process, ideal-for, FAQs, and the CTA
|
|
179
|
+
"Full mode (isSummary: false) — renders all content including a six-step process, ideal-for list, FAQs, and the CTA panel, matching the real service detail page content shape.",
|
|
139
180
|
},
|
|
140
181
|
},
|
|
141
182
|
},
|
|
@@ -213,3 +254,116 @@ export const CustomIcons: Story = {
|
|
|
213
254
|
},
|
|
214
255
|
},
|
|
215
256
|
};
|
|
257
|
+
|
|
258
|
+
export const CustomTextAndCta: Story = {
|
|
259
|
+
name: "Custom Headings & CTA Copy",
|
|
260
|
+
args: {
|
|
261
|
+
processHeading: "How It Works",
|
|
262
|
+
idealForHeading: "Who Is This For",
|
|
263
|
+
maintenanceHeading: "Aftercare",
|
|
264
|
+
faqsHeading: "FAQs",
|
|
265
|
+
ctaHeading: "Ready to book your colour appointment?",
|
|
266
|
+
ctaBody: "Mobile service across Bath — I come to you.",
|
|
267
|
+
},
|
|
268
|
+
render: (args) => ({
|
|
269
|
+
components: { ServicesSection, InputButtonCore },
|
|
270
|
+
setup() {
|
|
271
|
+
return { args, sampleService };
|
|
272
|
+
},
|
|
273
|
+
template: `
|
|
274
|
+
<ServicesSection v-bind="args" :service-data="sampleService">
|
|
275
|
+
<template #cta>
|
|
276
|
+
<InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" />
|
|
277
|
+
</template>
|
|
278
|
+
</ServicesSection>
|
|
279
|
+
`,
|
|
280
|
+
}),
|
|
281
|
+
parameters: {
|
|
282
|
+
docs: {
|
|
283
|
+
description: {
|
|
284
|
+
story:
|
|
285
|
+
"Section headings and CTA panel copy overridden via props — useful for wording specific to the consuming business rather than the library defaults. Note the heading names the actual service, avoiding the old bug where every service page said 'highlights appointment' regardless of which service it was.",
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export const RealisticPageContext: Story = {
|
|
292
|
+
name: "Realistic Page Context",
|
|
293
|
+
args: {
|
|
294
|
+
ctaHeading: "Ready to book your appointment?",
|
|
295
|
+
ctaBody: "Mobile service across Bath — I come to you.",
|
|
296
|
+
},
|
|
297
|
+
render: (args) => ({
|
|
298
|
+
components: { ServicesSection, PageRow, TextBlock, EyebrowText, HeroText, InputButtonCore },
|
|
299
|
+
setup() {
|
|
300
|
+
return { args, sampleService };
|
|
301
|
+
},
|
|
302
|
+
template: `
|
|
303
|
+
<div>
|
|
304
|
+
<PageRow tag="div" variant="content" :style-class-passthrough="['mbe-20']">
|
|
305
|
+
<TextBlock tag="div" :style-class-passthrough="['page-lead']">
|
|
306
|
+
<EyebrowText font-size="large" text-content="Services" />
|
|
307
|
+
<HeroText
|
|
308
|
+
tag="h1"
|
|
309
|
+
axis="vertical"
|
|
310
|
+
font-size="display"
|
|
311
|
+
:text-content="[
|
|
312
|
+
{ text: 'Expert colour & ', styleClass: 'normal' },
|
|
313
|
+
{ text: 'styling', styleClass: 'accent' },
|
|
314
|
+
{ text: 'at your door', styleClass: 'normal' },
|
|
315
|
+
]"
|
|
316
|
+
:style-class-passthrough="['mb-20']"
|
|
317
|
+
/>
|
|
318
|
+
</TextBlock>
|
|
319
|
+
</PageRow>
|
|
320
|
+
|
|
321
|
+
<PageRow tag="div" variant="content" :style-class-passthrough="['mbe-20']">
|
|
322
|
+
<ServicesSection v-bind="args" :service-data="sampleService">
|
|
323
|
+
<template #cta>
|
|
324
|
+
<InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" />
|
|
325
|
+
</template>
|
|
326
|
+
</ServicesSection>
|
|
327
|
+
</PageRow>
|
|
328
|
+
</div>
|
|
329
|
+
`,
|
|
330
|
+
}),
|
|
331
|
+
parameters: {
|
|
332
|
+
docs: {
|
|
333
|
+
description: {
|
|
334
|
+
story:
|
|
335
|
+
"Matches the real service detail page (app/pages/services/[slug].vue in the luxury-locs-by-natasha-nuxt3 project): a page hero (EyebrowText + HeroText inside PageRow/TextBlock) followed by ServicesSection with a real 'Book now' CTA button. Use this story to sanity-check layout and spacing changes against the actual consuming page rather than the bare component.",
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
export const CustomCtaPanel: Story = {
|
|
342
|
+
name: "Custom CTA Panel (slot override)",
|
|
343
|
+
render: (args) => ({
|
|
344
|
+
components: { ServicesSection },
|
|
345
|
+
setup() {
|
|
346
|
+
return { args, sampleService };
|
|
347
|
+
},
|
|
348
|
+
template: `
|
|
349
|
+
<ServicesSection v-bind="args" :service-data="sampleService">
|
|
350
|
+
<template #cta-panel="{ serviceData }">
|
|
351
|
+
<div style="padding:2.4rem;border:2px dashed #333;border-radius:1rem;text-align:center;">
|
|
352
|
+
<p style="margin:0 0 1.2rem;">Fully custom CTA block for {{ serviceData.title }} — not a GlassPanel at all.</p>
|
|
353
|
+
<button style="padding:1.2rem 2.4rem;background:#333;color:#fff;border:none;border-radius:0.4rem;cursor:pointer;">
|
|
354
|
+
Enquire Now
|
|
355
|
+
</button>
|
|
356
|
+
</div>
|
|
357
|
+
</template>
|
|
358
|
+
</ServicesSection>
|
|
359
|
+
`,
|
|
360
|
+
}),
|
|
361
|
+
parameters: {
|
|
362
|
+
docs: {
|
|
363
|
+
description: {
|
|
364
|
+
story:
|
|
365
|
+
"The cta-panel scoped slot replaces the default GlassPanel entirely — use when a consumer needs a different component or layout for the closing call-to-action, not just different copy.",
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
};
|
|
@@ -211,4 +211,75 @@ describe("ServicesSection", () => {
|
|
|
211
211
|
expect(wrapper.classes()).not.toContain("original");
|
|
212
212
|
expect(wrapper.classes()).toContain("updated");
|
|
213
213
|
});
|
|
214
|
+
|
|
215
|
+
// ─── Headings ───────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
it("renders default section headings", async () => {
|
|
218
|
+
const wrapper = await mountSuspended(ServicesSection, {
|
|
219
|
+
props: { serviceData: mockService },
|
|
220
|
+
});
|
|
221
|
+
expect(wrapper.text()).toContain("The Process");
|
|
222
|
+
expect(wrapper.text()).toContain("Ideal For");
|
|
223
|
+
expect(wrapper.text()).toContain("Aftercare & Maintenance");
|
|
224
|
+
expect(wrapper.text()).toContain("Frequently Asked Questions");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("overrides section headings via props", async () => {
|
|
228
|
+
const wrapper = await mountSuspended(ServicesSection, {
|
|
229
|
+
props: {
|
|
230
|
+
serviceData: mockService,
|
|
231
|
+
processHeading: "How It Works",
|
|
232
|
+
idealForHeading: "Who Is This For",
|
|
233
|
+
maintenanceHeading: "Aftercare",
|
|
234
|
+
faqsHeading: "FAQs",
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
expect(wrapper.text()).toContain("How It Works");
|
|
238
|
+
expect(wrapper.text()).toContain("Who Is This For");
|
|
239
|
+
expect(wrapper.text()).toContain("Aftercare");
|
|
240
|
+
expect(wrapper.text()).toContain("FAQs");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ─── CTA panel ──────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
it("renders default CTA heading and body", async () => {
|
|
246
|
+
const wrapper = await mountSuspended(ServicesSection, {
|
|
247
|
+
props: { serviceData: mockService },
|
|
248
|
+
});
|
|
249
|
+
expect(wrapper.text()).toContain("Ready to book your appointment?");
|
|
250
|
+
expect(wrapper.text()).toContain("Get in touch to book your appointment.");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("overrides CTA heading and body via props", async () => {
|
|
254
|
+
const wrapper = await mountSuspended(ServicesSection, {
|
|
255
|
+
props: {
|
|
256
|
+
serviceData: mockService,
|
|
257
|
+
ctaHeading: "Book your locs consultation",
|
|
258
|
+
ctaBody: "Mobile service across Bath — I come to you.",
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
expect(wrapper.text()).toContain("Book your locs consultation");
|
|
262
|
+
expect(wrapper.text()).toContain("Mobile service across Bath — I come to you.");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("replaces the whole CTA panel via the cta-panel slot", async () => {
|
|
266
|
+
const wrapper = await mountSuspended(ServicesSection, {
|
|
267
|
+
props: { serviceData: mockService },
|
|
268
|
+
slots: {
|
|
269
|
+
"cta-panel": '<div class="test-custom-cta">Custom CTA</div>',
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
expect(wrapper.find(".test-custom-cta").exists()).toBe(true);
|
|
273
|
+
expect(wrapper.text()).not.toContain("Ready to book your appointment?");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("does not render cta-panel slot in summary mode", async () => {
|
|
277
|
+
const wrapper = await mountSuspended(ServicesSection, {
|
|
278
|
+
props: { serviceData: mockService, isSummary: true },
|
|
279
|
+
slots: {
|
|
280
|
+
"cta-panel": '<div class="test-custom-cta">Custom CTA</div>',
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
expect(wrapper.find(".test-custom-cta").exists()).toBe(false);
|
|
284
|
+
});
|
|
214
285
|
});
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
exports[`ServicesSection > renders correct HTML structure 1`] = `
|
|
4
4
|
"<div class="services-section">
|
|
5
5
|
<div class="services-section__grid">
|
|
6
|
-
<div class="
|
|
7
|
-
<div class="
|
|
6
|
+
<div class="services-section__image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/test.jpg 1x, /_ipx/_/images/test.jpg 2x" alt="Test Service" loading="eager" fetchpriority="high" class="services-section__image" src="/_ipx/_/images/test.jpg"></div>
|
|
7
|
+
<div class="services-section__info-wrapper">
|
|
8
8
|
<div class="eyebrow-text large">A subtitle</div>
|
|
9
9
|
<h2 class="hero-text mb-20 title axis-horizontal">
|
|
10
10
|
<!--v-if--><span class="text-block-0 normal">Test Service</span>
|
|
11
11
|
</h2>
|
|
12
|
-
<div class="
|
|
13
|
-
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline
|
|
14
|
-
<div class="flex-row"><span class="iconify i-mdi:currency-gbp
|
|
12
|
+
<div class="services-section__price-duration">
|
|
13
|
+
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline services-section__decorator" aria-hidden="true"></span><span>60 mins</span></div>
|
|
14
|
+
<div class="flex-row"><span class="iconify i-mdi:currency-gbp services-section__decorator" aria-hidden="true"></span><span>£50</span></div>
|
|
15
15
|
</div>
|
|
16
16
|
<p class="page-body-normal">Long description text</p>
|
|
17
17
|
<h2 class="hero-text mb-20 subheading axis-horizontal">
|
|
@@ -60,23 +60,23 @@ exports[`ServicesSection > renders correct HTML structure 1`] = `
|
|
|
60
60
|
<h2 class="hero-text mb-20 subheading axis-horizontal">
|
|
61
61
|
<!--v-if--><span class="text-block-0 normal">Frequently Asked Questions</span>
|
|
62
62
|
</h2>
|
|
63
|
-
<div class="display-accordian services-
|
|
63
|
+
<div class="display-accordian services-section__faq" id="faq">
|
|
64
64
|
<div class="expanding-panel accordian-item" icon-size="medium">
|
|
65
65
|
<details class="expanding-panel-details" name="faq-v-0-0-0">
|
|
66
66
|
<summary id="id-faq-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-faq-v-0-0-0-content" aria-expanded="false"><span class="label-wrapper">FAQ question?</span><span class="icon-wrapper"><span class="iconify i-mdi:chevron-down" aria-hidden="true"></span></span></summary>
|
|
67
67
|
</details>
|
|
68
68
|
<div id="id-faq-v-0-0-0-content" class="expanding-panel-content" aria-labelledby="id-faq-v-0-0-0-trigger" role="region">
|
|
69
69
|
<div class="inner">
|
|
70
|
-
<p>FAQ answer.</p>
|
|
70
|
+
<p class="services-section__faq-answer">FAQ answer.</p>
|
|
71
71
|
</div>
|
|
72
72
|
</div>
|
|
73
73
|
</div>
|
|
74
74
|
</div>
|
|
75
|
-
<div class="glass-panel p-24">
|
|
75
|
+
<div class="glass-panel services-section__glass-panel p-24">
|
|
76
76
|
<h2 class="hero-text mbs-0 mbe-20 subheading axis-horizontal">
|
|
77
|
-
<!--v-if--><span class="text-block-0 normal">Ready to book your
|
|
77
|
+
<!--v-if--><span class="text-block-0 normal">Ready to book your appointment?</span>
|
|
78
78
|
</h2>
|
|
79
|
-
<p class="page-body-normal">
|
|
79
|
+
<p class="page-body-normal">Get in touch to book your appointment.</p>
|
|
80
80
|
</div>
|
|
81
81
|
</div>
|
|
82
82
|
</div>
|