srcdev-nuxt-components 9.3.8 → 9.3.9
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/skills/components/service-detail.md +26 -1
- package/.vscode/srcdev-component-service-detail.code-snippets +10 -0
- package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +60 -7
- package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +267 -211
- package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +24 -0
- package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +44 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +93 -83
- package/package.json +1 -1
|
@@ -10,6 +10,17 @@ related-services list), and a closing full-width CTA banner. The process steps r
|
|
|
10
10
|
markup — Ideal For and FAQs are still bespoke (2-column card grid, plain Q&A list) since neither
|
|
11
11
|
matches an existing molecule as directly.
|
|
12
12
|
|
|
13
|
+
The hero is a [GridStack](grid-stack.md) of two [PageRow](page-row.md) layers — an `image` layer
|
|
14
|
+
(default `variant="full"`, prop `heroImageVariant`) behind a `content` layer (default
|
|
15
|
+
`variant="content"`, prop `heroContentVariant`) — so the photo can bleed to whatever width
|
|
16
|
+
`ServiceDetail` itself is placed at (true edge-to-edge if there's no content-constraining wrapper
|
|
17
|
+
around it) while the breadcrumb/title/pills stay aligned to the page's normal content column by
|
|
18
|
+
default, independent of how wide the photo bleeds. The two-column body and the closing full-width
|
|
19
|
+
CTA banner are each their own `PageRow` too, with their own variant props (`bodyVariant`,
|
|
20
|
+
`finalCtaVariant`). All four variant props default to the values described above — override them
|
|
21
|
+
if a particular page wants a section to sit in a different track (e.g.
|
|
22
|
+
`heroContentVariant="inset-content"` for a narrower hero text column).
|
|
23
|
+
|
|
13
24
|
It is a different shape from [ServicesSection](services-section.md): `ServicesSection` renders
|
|
14
25
|
image-beside-content (used both as a compact summary card in a list and, in full mode, as a
|
|
15
26
|
same-page detail block); `ServiceDetail` is meant to *be* the whole page for one service —
|
|
@@ -26,6 +37,10 @@ rendered.
|
|
|
26
37
|
| `headerTag` | `"h1" \| "h2" \| "h3"` | `"h1"` | no |
|
|
27
38
|
| `subheadingTag` | `"h2" \| "h3"` | `"h2"` | no |
|
|
28
39
|
| `breadcrumbItems` | `BreadcrumbItem[]` | auto-built from `serviceData` | no |
|
|
40
|
+
| `heroImageVariant` | `"full" \| "popout" \| "content" \| "inset-content"` | `"full"` | no |
|
|
41
|
+
| `heroContentVariant` | `"full" \| "popout" \| "content" \| "inset-content"` | `"content"` | no |
|
|
42
|
+
| `bodyVariant` | `"full" \| "popout" \| "content" \| "inset-content"` | `"content"` | no |
|
|
43
|
+
| `finalCtaVariant` | `"full" \| "popout" \| "content" \| "inset-content"` | `"content"` | no |
|
|
29
44
|
| `processHeading` | `string` | `"The Process"` | no |
|
|
30
45
|
| `idealForHeading` | `string` | `"Ideal For"` | no |
|
|
31
46
|
| `maintenanceHeading` | `string` | `"Aftercare & Maintenance"` | no |
|
|
@@ -80,7 +95,7 @@ Defaults to a plain, non-linked two-item trail built from `serviceData.category`
|
|
|
80
95
|
|
|
81
96
|
| Slot | Slot props | Purpose |
|
|
82
97
|
|------|-----------|---------|
|
|
83
|
-
| `book-cta` | `{ serviceData: Service }` | Booking button inside the sidebar's booking card |
|
|
98
|
+
| `book-cta` | `{ serviceData: Service }` | Booking button inside the sidebar's booking card — its row (`v-if="$slots['book-cta']"`) only renders when content is passed |
|
|
84
99
|
| `sidebar-note` | — | Freeform note under the booking button (e.g. patch-test wording) — empty by default, deliberately not hardcoded since this library also serves non-hair-and-beauty apps |
|
|
85
100
|
| `related-service` | `{ service: Service, index: number }` | Replaces one related-service item's whole default (non-clickable) thumbnail/title/price markup — use to wrap it in a real link |
|
|
86
101
|
| `final-cta` | `{ serviceData: Service }` | Button in the closing full-width CTA banner |
|
|
@@ -150,6 +165,16 @@ pattern, and `CONSUMER-STYLING.md` in the component's own folder for the full to
|
|
|
150
165
|
- Internal element classes are BEM-namespaced under `service-detail__*` (`service-detail__hero`,
|
|
151
166
|
`service-detail__process`, `service-detail__sidebar`, `service-detail__related`, etc.) — see
|
|
152
167
|
`CONSUMER-STYLING.md` for the full list alongside their tokens.
|
|
168
|
+
- `bodyVariant` (default `"content"`) sets the `PageRow` variant wrapping the two-column body, and
|
|
169
|
+
`finalCtaVariant` (default `"content"`) sets the `PageRow` variant wrapping the closing CTA
|
|
170
|
+
banner — a consumer no longer needs to wrap `ServiceDetail` in its own outer `PageRow` purely to
|
|
171
|
+
constrain either section's width, though wrapping it in a wider outer `PageRow` is still the way
|
|
172
|
+
to unlock a genuinely full-bleed hero image (see the GridStack/PageRow note above).
|
|
173
|
+
- `heroImageVariant` (default `"full"`) and `heroContentVariant` (default `"content"`) are each a
|
|
174
|
+
`PageRow` variant in their own right, not a wrapper around one — a `PageRow`'s own box always
|
|
175
|
+
spans the full width of its parent regardless of variant, only its children default into the
|
|
176
|
+
variant's grid column, so `.service-detail__hero-overlay` (the `heroContentVariant` element) can
|
|
177
|
+
carry the full-bleed scrim while its child still lands in the configured column.
|
|
153
178
|
- The section gets `aria-labelledby` automatically when `tag` is `"section"`, `"article"`, or
|
|
154
179
|
`"aside"`, pointing at the id `ServiceDetail` binds to its own title `HeroText` internally —
|
|
155
180
|
same mechanism as `ServicesSection`. `tag="main"` renders a `<main>` element but is never
|
|
@@ -37,6 +37,16 @@
|
|
|
37
37
|
"</ServiceDetail>"
|
|
38
38
|
]
|
|
39
39
|
},
|
|
40
|
+
"SRCDEV ServiceDetail Layout Variants": {
|
|
41
|
+
"description": "ServiceDetail PageRow variant props — hero image/content layers, the two-column body, and the closing CTA banner",
|
|
42
|
+
"scope": "vue,html",
|
|
43
|
+
"body": [
|
|
44
|
+
"hero-image-variant=\"$1full\"",
|
|
45
|
+
"hero-content-variant=\"$2content\"",
|
|
46
|
+
"body-variant=\"$3content\"",
|
|
47
|
+
"final-cta-variant=\"$4content\""
|
|
48
|
+
]
|
|
49
|
+
},
|
|
40
50
|
"SRCDEV ServiceDetail Related Service Link Slot": {
|
|
41
51
|
"description": "ServiceDetail related-service scoped slot — wraps each related item in a real link",
|
|
42
52
|
"scope": "vue,html",
|
|
@@ -1,14 +1,47 @@
|
|
|
1
1
|
# ServiceDetail — Consumer Styling Guide
|
|
2
2
|
|
|
3
|
+
## Layout building blocks
|
|
4
|
+
|
|
5
|
+
The hero banner is a [GridStack](../../../../../.claude/skills/components/grid-stack.md) with two
|
|
6
|
+
stacked layers — an `image` layer (the service photo) behind a `content` layer (the scrim +
|
|
7
|
+
breadcrumb/title/pills). Each layer's root element is itself a
|
|
8
|
+
[PageRow](../../../../../.claude/skills/components/page-row.md): `.service-detail__hero-image-row`
|
|
9
|
+
(prop `heroImageVariant`, default `"full"`) wraps the photo, and `.service-detail__hero-overlay`
|
|
10
|
+
(prop `heroContentVariant`, default `"content"`) *is* the scrim/alignment element, not a wrapper
|
|
11
|
+
around one — a `PageRow`'s own box always spans the full width of its parent regardless of variant,
|
|
12
|
+
only its children default into the variant's grid column, so the one element can carry the
|
|
13
|
+
full-bleed scrim while its child (`.service-detail__hero-content`) still gets pulled to the
|
|
14
|
+
configured column. This means the photo bleeds to whatever width `.service-detail` itself is allowed
|
|
15
|
+
(edge-to-edge if `ServiceDetail` is placed without a content-constraining wrapper around it), while
|
|
16
|
+
the breadcrumb/title/pills always stay pulled into the page's normal content-width column,
|
|
17
|
+
regardless of how wide the photo bleeds. `--service-detail-hero-padding` now only controls
|
|
18
|
+
**block** (vertical) padding on the scrim — horizontal alignment of the text is handled entirely by
|
|
19
|
+
`.service-detail__hero-overlay`'s own `PageRow` grid, not by padding, so it can't fight the page's
|
|
20
|
+
content-grid gutters.
|
|
21
|
+
|
|
22
|
+
The two-column body (main content + sticky sidebar) is wrapped in its own `PageRow`, whose variant
|
|
23
|
+
is a prop: `bodyVariant` (`"full" | "popout" | "content" | "inset-content"`, default `"content"`).
|
|
24
|
+
The closing full-width CTA banner is wrapped the same way via `finalCtaVariant` (same type, same
|
|
25
|
+
`"content"` default). This means a consumer no longer needs to wrap `ServiceDetail` itself in an
|
|
26
|
+
outer `PageRow` purely to constrain either section's width — pass `body-variant`/`final-cta-variant`
|
|
27
|
+
directly. It's still fine (and necessary for a genuine full-bleed hero) to place `ServiceDetail`
|
|
28
|
+
inside an outer, wider `PageRow` — see
|
|
29
|
+
[services-section.md](../../../../../.claude/skills/components/services-section.md)-style nesting
|
|
30
|
+
notes in [page-row.md](../../../../../.claude/skills/components/page-row.md) for how nested page-rows
|
|
31
|
+
compose.
|
|
32
|
+
|
|
3
33
|
## Public token API
|
|
4
34
|
|
|
5
35
|
### Hero banner
|
|
6
36
|
|
|
7
37
|
| Token | Default | Controls |
|
|
8
38
|
|---|---|---|
|
|
9
|
-
| `--service-detail-hero-border-radius` | `0.8rem` | Corner rounding on the hero image |
|
|
10
|
-
| `--service-detail-hero-
|
|
11
|
-
| `--service-detail-hero-
|
|
39
|
+
| `--service-detail-hero-border-radius` | `0.8rem` | Corner rounding on the hero (clips the image/content layers — set to `0` for a true edge-to-edge bleed hero) |
|
|
40
|
+
| `--service-detail-hero-content-align` | `end` | `align-content` of the content overlay grid — where the breadcrumb/title/pills sit within the hero's block axis (`end` = bottom-anchored) |
|
|
41
|
+
| `--service-detail-hero-min-height-mobile` | `32rem` | Hero banner height below the `768px` container breakpoint. The hero image always fills this height (`object-fit`/`object-position` control cropping) |
|
|
42
|
+
| `--service-detail-hero-min-height-tablet` | `36rem` | Hero banner height from the `768px` container breakpoint |
|
|
43
|
+
| `--service-detail-hero-min-height-desktop` | `42rem` | Hero banner height from the `1024px` container breakpoint |
|
|
44
|
+
| `--service-detail-hero-padding` | `3.2rem` | **Block-axis (vertical)** padding around the breadcrumb/title/pills content — horizontal alignment comes from the nested content `PageRow`, not this token |
|
|
12
45
|
| `--service-detail-hero-scrim` | `linear-gradient(0deg, rgb(0 0 0 / 70%) 0%, rgb(0 0 0 / 10%) 60%, transparent 100%)` | Overlay gradient behind the hero text, for legibility over the image |
|
|
13
46
|
| `--service-detail-hero-text-colour` | `white` | Colour of the breadcrumb, eyebrow, and title over the hero image |
|
|
14
47
|
| `--service-detail-breadcrumb-margin-block-end` | `0.8rem` | Space below the breadcrumb |
|
|
@@ -16,6 +49,16 @@
|
|
|
16
49
|
| `--service-detail-hero-pill-bg` | `transparent` | Background of the price/duration pills (via `DisplayPill`'s `--theme-pill-bg`) |
|
|
17
50
|
| `--service-detail-hero-pill-border-colour` | `currentColor` | Border colour of the price/duration pills |
|
|
18
51
|
|
|
52
|
+
The hero image's crop behaviour also changes responsively (position only, not tokened as fit
|
|
53
|
+
itself): below the `768px` container breakpoint it's `object-fit: cover` anchored with
|
|
54
|
+
`--service-detail-hero-image-position-small` (default `bottom`); from `768px` up it switches to
|
|
55
|
+
`object-fit: contain` anchored with `--service-detail-hero-image-position-medium` (default
|
|
56
|
+
`center`) so the full photo stays visible once there's room for it, rather than continuing to crop.
|
|
57
|
+
|
|
58
|
+
For anything not covered by these (e.g. the `full`/`content` variant tracks themselves), target
|
|
59
|
+
`--page-row-*` tokens under `.service-detail__hero-image-row`/`.service-detail__hero-overlay` — see
|
|
60
|
+
[page-row.md](../../../../../.claude/skills/components/page-row.md).
|
|
61
|
+
|
|
19
62
|
### Body layout
|
|
20
63
|
|
|
21
64
|
| Token | Default | Controls |
|
|
@@ -25,7 +68,9 @@
|
|
|
25
68
|
| `--service-detail-main-section-gap` | `2.4rem` | Gap between stacked sections in the main column |
|
|
26
69
|
|
|
27
70
|
The body becomes two columns at a **900px container width** (`@container`, not viewport) — the
|
|
28
|
-
main column is flexible, the sidebar is `minmax(28rem, 34rem)`.
|
|
71
|
+
main column is flexible, the sidebar is `minmax(28rem, 34rem)`. The body's own breakout width is
|
|
72
|
+
controlled by the `bodyVariant` prop (default `"content"`), not a token — see Layout building
|
|
73
|
+
blocks above.
|
|
29
74
|
|
|
30
75
|
### Process / Ideal For / FAQs
|
|
31
76
|
|
|
@@ -55,6 +100,7 @@ target `--stepper-list-*` directly under `.service-detail__process` — see
|
|
|
55
100
|
| `--service-detail-ideal-for-item-border-radius` | `0.4rem` | Card corner rounding |
|
|
56
101
|
| `--service-detail-ideal-for-icon-size` | `1.6rem` | Icon size |
|
|
57
102
|
| `--service-detail-ideal-for-icon-colour` | `var(--colour-text-accent)` | Icon colour |
|
|
103
|
+
| `--service-detail-ideal-for-icon-transform` | `translateY(0.4rem)` | Vertical nudge on the icon to optically align it with the first line of text |
|
|
58
104
|
| `--service-detail-faq-padding-block` | `1.6rem` | Vertical padding per FAQ entry |
|
|
59
105
|
| `--service-detail-faq-divider-colour` | `currentColor` | Divider line between FAQ entries |
|
|
60
106
|
| `--service-detail-faq-question-font-size` | `1.6rem` | FAQ question font size |
|
|
@@ -75,6 +121,8 @@ The ideal-for grid becomes 2 columns at a **500px container width**.
|
|
|
75
121
|
| `--service-detail-sidebar-row-gap` | `1rem` | Gap between a row's label and value |
|
|
76
122
|
| `--service-detail-sidebar-row-padding-block` | `1rem` | Vertical padding per price/duration/location row |
|
|
77
123
|
| `--service-detail-sidebar-row-divider-colour` | `currentColor` | Divider line above each price/duration/location row |
|
|
124
|
+
| `--service-detail-sidebar-row-book-cta-padding-block` | `2rem` | Vertical padding on the `book-cta` slot's own row (only renders when the slot has content) |
|
|
125
|
+
| `--service-detail-sidebar-row-book-cta-justify-content` | `end` | Horizontal alignment of the `book-cta` slot content within its row |
|
|
78
126
|
| `--service-detail-sidebar-row-label-font-size` | `1.2rem` | Font size of a row's label (e.g. "Price") |
|
|
79
127
|
| `--service-detail-sidebar-note-font-size` | `1.2rem` | Font size of the `sidebar-note` slot content |
|
|
80
128
|
| `--service-detail-sidebar-note-margin-block-start` | `1.2rem` | Space above the `sidebar-note` slot content |
|
|
@@ -87,6 +135,7 @@ The ideal-for grid becomes 2 columns at a **500px container width**.
|
|
|
87
135
|
| `--service-detail-related-image-size` | `5.6rem` | Width/height of a related-service thumbnail |
|
|
88
136
|
| `--service-detail-related-image-border-radius` | `0.4rem` | Corner rounding of a related-service thumbnail |
|
|
89
137
|
| `--service-detail-related-price-font-size` | `1.2rem` | Font size of a related item's price |
|
|
138
|
+
| `--service-detail-related-price-margin-block-start` | `0.6rem` | Space between a related item's title and its price |
|
|
90
139
|
| `--service-detail-muted-opacity` | `0.7` | Opacity shared by every secondary/muted text element (sidebar label, sidebar note, related price, final CTA body) |
|
|
91
140
|
|
|
92
141
|
The booking card is a `GlassPanel` — its own `--glass-panel-*` tokens also apply.
|
|
@@ -96,11 +145,13 @@ The booking card is a `GlassPanel` — its own `--glass-panel-*` tokens also app
|
|
|
96
145
|
| Token | Default | Controls |
|
|
97
146
|
|---|---|---|
|
|
98
147
|
| `--service-detail-final-cta-gap` | `1.6rem` | Gap between the copy and the `final-cta` slot content |
|
|
99
|
-
| `--service-detail-final-cta-margin-block-start` | `
|
|
100
|
-
| `--service-detail-final-cta-padding-block
|
|
148
|
+
| `--service-detail-final-cta-margin-block-start` | `3.2rem` | Space above the banner |
|
|
149
|
+
| `--service-detail-final-cta-padding-block` | `3.2rem 3.2rem` | Padding (block-start block-end) around the banner content, below its divider |
|
|
101
150
|
| `--service-detail-final-cta-divider-colour` | `currentColor` | Divider line above the banner |
|
|
102
151
|
|
|
103
|
-
The banner becomes a single row (copy left, CTA right) at a **700px container width**.
|
|
152
|
+
The banner becomes a single row (copy left, CTA right) at a **700px container width**. Its own
|
|
153
|
+
breakout width is controlled by the `finalCtaVariant` prop (default `"content"`), not a token —
|
|
154
|
+
see Layout building blocks above.
|
|
104
155
|
|
|
105
156
|
---
|
|
106
157
|
|
|
@@ -167,6 +218,8 @@ Section headings, sidebar labels, and CTA copy are **props**, not hardcoded stri
|
|
|
167
218
|
- The `location` row and the entire "You May Also Like" block only render when `location` /
|
|
168
219
|
`relatedServices` are actually passed — neither exists on the shared `Service` type, so nothing
|
|
169
220
|
breaks for consumer apps that don't have this data.
|
|
221
|
+
- The `book-cta` slot's own sidebar row also only renders when that slot actually has content
|
|
222
|
+
(`v-if="$slots['book-cta']"`) — it no longer reserves an empty row when the slot is unused.
|
|
170
223
|
- `breadcrumbItems` defaults to a plain, non-linked `[category, title]` trail built from
|
|
171
224
|
`serviceData` — pass real `to` routes once your app's URL structure is known.
|
|
172
225
|
- `headerTag` (default `"h1"`) controls only the hero title; `subheadingTag` (default `"h2"`)
|
|
@@ -1,165 +1,181 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<component :is="tag" class="service-detail" :class="[elementClasses]" :aria-labelledby="ariaLabelledby">
|
|
3
|
-
<div class="service-detail__hero">
|
|
4
|
-
<
|
|
5
|
-
:
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
3
|
+
<GridStack tag="div" class="service-detail__hero">
|
|
4
|
+
<template #image>
|
|
5
|
+
<PageRow tag="div" :variant="heroImageVariant" class="service-detail__hero-image-row">
|
|
6
|
+
<NuxtImg
|
|
7
|
+
:src="serviceData.image"
|
|
8
|
+
:alt="serviceData.title"
|
|
9
|
+
:loading="imageLoading"
|
|
10
|
+
:fetchpriority="imageFetchPriority"
|
|
11
|
+
class="service-detail__hero-image"
|
|
12
|
+
/>
|
|
13
|
+
</PageRow>
|
|
14
|
+
</template>
|
|
15
|
+
<template #content>
|
|
16
|
+
<PageRow tag="div" :variant="heroContentVariant" class="service-detail__hero-overlay">
|
|
17
|
+
<div class="service-detail__hero-content">
|
|
18
|
+
<Breadcrumb :items="resolvedBreadcrumbItems" class="service-detail__breadcrumb" />
|
|
19
|
+
<EyebrowText font-size="large" :text-content="serviceData.subtitle" />
|
|
20
|
+
<HeroText
|
|
21
|
+
:id="headingId"
|
|
22
|
+
:tag="headerTag"
|
|
23
|
+
font-size="display"
|
|
24
|
+
:text-content="[{ text: serviceData.title, styleClass: 'normal' }]"
|
|
25
|
+
:style-class-passthrough="['mb-20']"
|
|
26
|
+
/>
|
|
27
|
+
<div class="service-detail__hero-pills">
|
|
28
|
+
<DisplayPill :label="serviceData.duration" size="md" variant="neutral" />
|
|
29
|
+
<DisplayPill :label="`From ${serviceData.price}`" size="md" variant="neutral" />
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</PageRow>
|
|
33
|
+
</template>
|
|
34
|
+
</GridStack>
|
|
35
|
+
|
|
36
|
+
<PageRow tag="div" :variant="bodyVariant" class="service-detail__body-row">
|
|
37
|
+
<div class="service-detail__body">
|
|
38
|
+
<div class="service-detail__main">
|
|
39
|
+
<p class="page-body-normal">{{ serviceData.longDescription }}</p>
|
|
40
|
+
|
|
15
41
|
<HeroText
|
|
16
|
-
:
|
|
17
|
-
|
|
18
|
-
font-size="
|
|
19
|
-
:text-content="
|
|
42
|
+
:tag="subheadingTag"
|
|
43
|
+
axis="horizontal"
|
|
44
|
+
font-size="subheading"
|
|
45
|
+
:text-content="serviceData.heroHeading"
|
|
20
46
|
:style-class-passthrough="['mb-20']"
|
|
21
47
|
/>
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
axis="horizontal"
|
|
80
|
-
font-size="subheading"
|
|
81
|
-
:text-content="[{ text: maintenanceHeading, styleClass: 'normal' }]"
|
|
82
|
-
:style-class-passthrough="['mb-20']"
|
|
83
|
-
/>
|
|
84
|
-
<p class="page-body-normal">{{ serviceData.maintenance }}</p>
|
|
85
|
-
|
|
86
|
-
<HeroText
|
|
87
|
-
:tag="subheadingTag"
|
|
88
|
-
axis="horizontal"
|
|
89
|
-
font-size="subheading"
|
|
90
|
-
:text-content="[{ text: faqsHeading, styleClass: 'normal' }]"
|
|
91
|
-
:style-class-passthrough="['mb-20']"
|
|
92
|
-
/>
|
|
93
|
-
<div class="service-detail__faqs">
|
|
94
|
-
<div v-for="(faq, i) in serviceData.faqs" :key="i" class="service-detail__faq">
|
|
95
|
-
<h3 class="service-detail__faq-question">{{ faq.question }}</h3>
|
|
96
|
-
<p class="page-body-normal">{{ faq.answer }}</p>
|
|
48
|
+
<p class="page-body-normal">{{ serviceData.whatIsIt }}</p>
|
|
49
|
+
|
|
50
|
+
<HeroText
|
|
51
|
+
:tag="subheadingTag"
|
|
52
|
+
axis="horizontal"
|
|
53
|
+
font-size="subheading"
|
|
54
|
+
:text-content="[{ text: processHeading, styleClass: 'normal' }]"
|
|
55
|
+
:style-class-passthrough="['mb-20']"
|
|
56
|
+
/>
|
|
57
|
+
<StepperList
|
|
58
|
+
tag="ol"
|
|
59
|
+
indicator-alignment="top"
|
|
60
|
+
indicator-variant="circle"
|
|
61
|
+
:connected="true"
|
|
62
|
+
:item-count="serviceData.process.length"
|
|
63
|
+
class="service-detail__process"
|
|
64
|
+
>
|
|
65
|
+
<template v-for="(step, i) in serviceData.process" :key="i" #[`item-${i}`]>
|
|
66
|
+
<p class="page-body-normal">{{ step }}</p>
|
|
67
|
+
</template>
|
|
68
|
+
</StepperList>
|
|
69
|
+
|
|
70
|
+
<HeroText
|
|
71
|
+
:tag="subheadingTag"
|
|
72
|
+
axis="horizontal"
|
|
73
|
+
font-size="subheading"
|
|
74
|
+
:text-content="[{ text: idealForHeading, styleClass: 'normal' }]"
|
|
75
|
+
:style-class-passthrough="['mb-20']"
|
|
76
|
+
/>
|
|
77
|
+
<ul class="service-detail__ideal-for">
|
|
78
|
+
<li v-for="(item, i) in serviceData.idealFor" :key="i" class="service-detail__ideal-for-item">
|
|
79
|
+
<Icon name="mdi:diamond-stone" class="service-detail__ideal-for-icon" />
|
|
80
|
+
<p class="page-body-normal">{{ item }}</p>
|
|
81
|
+
</li>
|
|
82
|
+
</ul>
|
|
83
|
+
|
|
84
|
+
<HeroText
|
|
85
|
+
:tag="subheadingTag"
|
|
86
|
+
axis="horizontal"
|
|
87
|
+
font-size="subheading"
|
|
88
|
+
:text-content="[{ text: maintenanceHeading, styleClass: 'normal' }]"
|
|
89
|
+
:style-class-passthrough="['mb-20']"
|
|
90
|
+
/>
|
|
91
|
+
<p class="page-body-normal">{{ serviceData.maintenance }}</p>
|
|
92
|
+
|
|
93
|
+
<HeroText
|
|
94
|
+
:tag="subheadingTag"
|
|
95
|
+
axis="horizontal"
|
|
96
|
+
font-size="subheading"
|
|
97
|
+
:text-content="[{ text: faqsHeading, styleClass: 'normal' }]"
|
|
98
|
+
:style-class-passthrough="['mb-20']"
|
|
99
|
+
/>
|
|
100
|
+
<div class="service-detail__faqs">
|
|
101
|
+
<div v-for="(faq, i) in serviceData.faqs" :key="i" class="service-detail__faq">
|
|
102
|
+
<h3 class="service-detail__faq-question">{{ faq.question }}</h3>
|
|
103
|
+
<p class="page-body-normal">{{ faq.answer }}</p>
|
|
104
|
+
</div>
|
|
97
105
|
</div>
|
|
98
106
|
</div>
|
|
99
|
-
</div>
|
|
100
107
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
<aside class="service-detail__sidebar">
|
|
109
|
+
<GlassPanel :style-class-passthrough="['service-detail__booking-card']">
|
|
110
|
+
<p class="service-detail__sidebar-label">{{ bookingHeading }}</p>
|
|
104
111
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
<div class="service-detail__sidebar-row">
|
|
113
|
+
<span>{{ priceLabel }}</span>
|
|
114
|
+
<span>{{ serviceData.price }}</span>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="service-detail__sidebar-row">
|
|
117
|
+
<span>{{ durationLabel }}</span>
|
|
118
|
+
<span>{{ serviceData.duration }}</span>
|
|
119
|
+
</div>
|
|
120
|
+
<div v-if="location" class="service-detail__sidebar-row">
|
|
121
|
+
<span>{{ locationLabel }}</span>
|
|
122
|
+
<span>{{ location }}</span>
|
|
123
|
+
</div>
|
|
117
124
|
|
|
118
|
-
|
|
125
|
+
<div v-if="$slots['book-cta']" class="service-detail__sidebar-row book-cta">
|
|
126
|
+
<slot name="book-cta" :service-data="serviceData"></slot>
|
|
127
|
+
</div>
|
|
119
128
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
129
|
+
<div class="service-detail__sidebar-note">
|
|
130
|
+
<slot name="sidebar-note"></slot>
|
|
131
|
+
</div>
|
|
132
|
+
</GlassPanel>
|
|
133
|
+
|
|
134
|
+
<div v-if="relatedServices.length" class="service-detail__related">
|
|
135
|
+
<p class="service-detail__sidebar-label">{{ relatedServicesHeading }}</p>
|
|
136
|
+
|
|
137
|
+
<div class="service-detail__related-items">
|
|
138
|
+
<div
|
|
139
|
+
v-for="(related, i) in relatedServices"
|
|
140
|
+
:key="related.slug"
|
|
141
|
+
class="service-detail__related-item-slot"
|
|
142
|
+
>
|
|
143
|
+
<slot name="related-service" :service="related" :index="i">
|
|
144
|
+
<div class="service-detail__related-item">
|
|
145
|
+
<NuxtImg
|
|
146
|
+
:src="related.image"
|
|
147
|
+
:alt="related.title"
|
|
148
|
+
loading="lazy"
|
|
149
|
+
class="service-detail__related-image"
|
|
150
|
+
/>
|
|
151
|
+
<div class="service-detail__related-details">
|
|
152
|
+
<p class="service-detail__related-title">{{ related.title }}</p>
|
|
153
|
+
<p class="service-detail__related-price">{{ related.price }}</p>
|
|
154
|
+
</div>
|
|
141
155
|
</div>
|
|
142
|
-
</
|
|
143
|
-
</
|
|
156
|
+
</slot>
|
|
157
|
+
</div>
|
|
144
158
|
</div>
|
|
145
159
|
</div>
|
|
160
|
+
</aside>
|
|
161
|
+
</div>
|
|
162
|
+
</PageRow>
|
|
163
|
+
|
|
164
|
+
<PageRow tag="div" :variant="finalCtaVariant" class="service-detail__final-cta-row">
|
|
165
|
+
<div class="service-detail__final-cta">
|
|
166
|
+
<div class="service-detail__final-cta-copy">
|
|
167
|
+
<HeroText
|
|
168
|
+
:tag="subheadingTag"
|
|
169
|
+
axis="horizontal"
|
|
170
|
+
font-size="subheading"
|
|
171
|
+
:text-content="[{ text: finalCtaHeading, styleClass: 'normal' }]"
|
|
172
|
+
:style-class-passthrough="['mbs-0', 'mbe-8']"
|
|
173
|
+
/>
|
|
174
|
+
<p class="page-body-normal">{{ finalCtaBody }}</p>
|
|
146
175
|
</div>
|
|
147
|
-
|
|
148
|
-
</div>
|
|
149
|
-
|
|
150
|
-
<div class="service-detail__final-cta">
|
|
151
|
-
<div class="service-detail__final-cta-copy">
|
|
152
|
-
<HeroText
|
|
153
|
-
:tag="subheadingTag"
|
|
154
|
-
axis="horizontal"
|
|
155
|
-
font-size="subheading"
|
|
156
|
-
:text-content="[{ text: finalCtaHeading, styleClass: 'normal' }]"
|
|
157
|
-
:style-class-passthrough="['mbs-0', 'mbe-8']"
|
|
158
|
-
/>
|
|
159
|
-
<p class="page-body-normal">{{ finalCtaBody }}</p>
|
|
176
|
+
<slot name="final-cta" :service-data="serviceData"></slot>
|
|
160
177
|
</div>
|
|
161
|
-
|
|
162
|
-
</div>
|
|
178
|
+
</PageRow>
|
|
163
179
|
</component>
|
|
164
180
|
</template>
|
|
165
181
|
|
|
@@ -173,6 +189,10 @@ interface Props {
|
|
|
173
189
|
subheadingTag?: "h2" | "h3";
|
|
174
190
|
serviceData: Service;
|
|
175
191
|
breadcrumbItems?: BreadcrumbItem[];
|
|
192
|
+
heroImageVariant?: "full" | "popout" | "content" | "inset-content";
|
|
193
|
+
heroContentVariant?: "full" | "popout" | "content" | "inset-content";
|
|
194
|
+
bodyVariant?: "full" | "popout" | "content" | "inset-content";
|
|
195
|
+
finalCtaVariant?: "full" | "popout" | "content" | "inset-content";
|
|
176
196
|
processHeading?: string;
|
|
177
197
|
idealForHeading?: string;
|
|
178
198
|
maintenanceHeading?: string;
|
|
@@ -196,6 +216,10 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
196
216
|
headerTag: "h1",
|
|
197
217
|
subheadingTag: "h2",
|
|
198
218
|
breadcrumbItems: undefined,
|
|
219
|
+
heroImageVariant: "full",
|
|
220
|
+
heroContentVariant: "content",
|
|
221
|
+
bodyVariant: "content",
|
|
222
|
+
finalCtaVariant: "content",
|
|
199
223
|
processHeading: "The Process",
|
|
200
224
|
idealForHeading: "Ideal For",
|
|
201
225
|
maintenanceHeading: "Aftercare & Maintenance",
|
|
@@ -239,49 +263,71 @@ watch(
|
|
|
239
263
|
container-name: service-detail;
|
|
240
264
|
|
|
241
265
|
.service-detail__hero {
|
|
242
|
-
position: relative;
|
|
243
266
|
border-radius: var(--service-detail-hero-border-radius, 0.8rem);
|
|
244
267
|
overflow: hidden;
|
|
245
|
-
|
|
268
|
+
height: var(--service-detail-hero-min-height-mobile, 32rem);
|
|
269
|
+
|
|
270
|
+
@container (width >= 768px) {
|
|
271
|
+
height: var(--service-detail-hero-min-height-tablet, 36rem);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
@container (width >= 1024px) {
|
|
275
|
+
height: var(--service-detail-hero-min-height-desktop, 42rem);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.grid-stack__layer {
|
|
279
|
+
height: 100%;
|
|
280
|
+
overflow: hidden;
|
|
281
|
+
|
|
282
|
+
.service-detail__hero-image-row,
|
|
283
|
+
.service-detail__hero-overlay {
|
|
284
|
+
height: 100%;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
246
287
|
|
|
247
288
|
.service-detail__hero-image {
|
|
248
289
|
display: block;
|
|
249
290
|
width: 100%;
|
|
250
291
|
height: 100%;
|
|
251
292
|
object-fit: cover;
|
|
252
|
-
position:
|
|
253
|
-
|
|
293
|
+
object-position: var(--service-detail-hero-image-position-small, bottom);
|
|
294
|
+
|
|
295
|
+
@container (width >= 768px) {
|
|
296
|
+
object-fit: contain;
|
|
297
|
+
object-position: var(--service-detail-hero-image-position-medium, center);
|
|
298
|
+
}
|
|
254
299
|
}
|
|
255
300
|
|
|
256
301
|
.service-detail__hero-overlay {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
align-
|
|
260
|
-
|
|
261
|
-
padding: var(--service-detail-hero-padding, 2.4rem);
|
|
302
|
+
display: grid;
|
|
303
|
+
height: 100%;
|
|
304
|
+
align-content: var(--service-detail-hero-content-align, end);
|
|
305
|
+
padding-block: var(--service-detail-hero-padding, 3.2rem);
|
|
262
306
|
background: var(
|
|
263
307
|
--service-detail-hero-scrim,
|
|
264
308
|
linear-gradient(0deg, rgb(0 0 0 / 70%) 0%, rgb(0 0 0 / 10%) 60%, transparent 100%)
|
|
265
309
|
);
|
|
266
310
|
|
|
267
|
-
.service-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
311
|
+
.service-detail__hero-content {
|
|
312
|
+
.service-detail__breadcrumb {
|
|
313
|
+
--breadcrumb-colour: var(--service-detail-hero-text-colour, white);
|
|
314
|
+
margin-block-end: var(--service-detail-breadcrumb-margin-block-end, 0.8rem);
|
|
315
|
+
}
|
|
271
316
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
317
|
+
.eyebrow-text,
|
|
318
|
+
.hero-text {
|
|
319
|
+
color: var(--service-detail-hero-text-colour, white);
|
|
320
|
+
}
|
|
276
321
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
322
|
+
.service-detail__hero-pills {
|
|
323
|
+
display: flex;
|
|
324
|
+
flex-wrap: wrap;
|
|
325
|
+
gap: var(--service-detail-hero-pills-gap, 0.8rem);
|
|
281
326
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
327
|
+
--theme-pill-bg: var(--service-detail-hero-pill-bg, transparent);
|
|
328
|
+
--theme-pill-color: var(--service-detail-hero-text-colour, white);
|
|
329
|
+
--theme-pill-border-color: var(--service-detail-hero-pill-border-colour, currentColor);
|
|
330
|
+
}
|
|
285
331
|
}
|
|
286
332
|
}
|
|
287
333
|
}
|
|
@@ -339,6 +385,7 @@ watch(
|
|
|
339
385
|
width: var(--service-detail-ideal-for-icon-size, 1.6rem);
|
|
340
386
|
height: var(--service-detail-ideal-for-icon-size, 1.6rem);
|
|
341
387
|
color: var(--service-detail-ideal-for-icon-colour, var(--colour-text-accent));
|
|
388
|
+
transform: var(--service-detail-ideal-for-icon-transform, translateY(0.4rem));
|
|
342
389
|
}
|
|
343
390
|
|
|
344
391
|
p {
|
|
@@ -396,6 +443,12 @@ watch(
|
|
|
396
443
|
padding-block: var(--service-detail-sidebar-row-padding-block, 1rem);
|
|
397
444
|
border-block-start: 1px solid var(--service-detail-sidebar-row-divider-colour, currentColor);
|
|
398
445
|
|
|
446
|
+
&.book-cta {
|
|
447
|
+
padding-block: var(--service-detail-sidebar-row-book-cta-padding-block, 2rem);
|
|
448
|
+
border-block-start: none;
|
|
449
|
+
justify-content: var(--service-detail-sidebar-row-book-cta-justify-content, end);
|
|
450
|
+
}
|
|
451
|
+
|
|
399
452
|
span:first-child {
|
|
400
453
|
text-transform: uppercase;
|
|
401
454
|
font-size: var(--service-detail-sidebar-row-label-font-size, 1.2rem);
|
|
@@ -423,55 +476,58 @@ watch(
|
|
|
423
476
|
padding: var(--service-detail-related-item-padding, 1rem);
|
|
424
477
|
border: 1px solid var(--service-detail-related-item-border-colour, var(--slate-06));
|
|
425
478
|
border-radius: var(--service-detail-related-item-border-radius, 0.4rem);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
.service-detail__related-item {
|
|
429
|
-
display: grid;
|
|
430
|
-
grid-template-columns: auto 1fr;
|
|
431
|
-
gap: var(--service-detail-related-item-gap, 1.2rem);
|
|
432
|
-
align-items: center;
|
|
433
|
-
|
|
434
|
-
.service-detail__related-image {
|
|
435
|
-
width: var(--service-detail-related-image-size, 5.6rem);
|
|
436
|
-
height: var(--service-detail-related-image-size, 5.6rem);
|
|
437
|
-
border-radius: var(--service-detail-related-image-border-radius, 0.4rem);
|
|
438
|
-
object-fit: cover;
|
|
439
|
-
display: block;
|
|
440
|
-
}
|
|
441
479
|
|
|
442
|
-
.service-detail__related-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
480
|
+
.service-detail__related-item {
|
|
481
|
+
display: grid;
|
|
482
|
+
grid-template-columns: auto 1fr;
|
|
483
|
+
gap: var(--service-detail-related-item-gap, 1.2rem);
|
|
484
|
+
align-items: center;
|
|
485
|
+
|
|
486
|
+
.service-detail__related-image {
|
|
487
|
+
width: var(--service-detail-related-image-size, 5.6rem);
|
|
488
|
+
height: var(--service-detail-related-image-size, 5.6rem);
|
|
489
|
+
border-radius: var(--service-detail-related-image-border-radius, 0.4rem);
|
|
490
|
+
object-fit: cover;
|
|
491
|
+
display: block;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
.service-detail__related-title,
|
|
495
|
+
.service-detail__related-price {
|
|
496
|
+
margin: 0;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.service-detail__related-price {
|
|
500
|
+
margin-block-start: var(--service-detail-related-price-margin-block-start, 0.6rem);
|
|
501
|
+
font-size: var(--service-detail-related-price-font-size, 1.2rem);
|
|
502
|
+
opacity: var(--service-detail-muted-opacity, 0.7);
|
|
503
|
+
}
|
|
450
504
|
}
|
|
451
505
|
}
|
|
452
506
|
}
|
|
453
507
|
}
|
|
454
508
|
}
|
|
455
509
|
|
|
456
|
-
.service-detail__final-cta {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
@container (width >= 700px) {
|
|
467
|
-
flex-direction: row;
|
|
468
|
-
align-items: center;
|
|
469
|
-
}
|
|
510
|
+
.service-detail__final-cta-row {
|
|
511
|
+
.service-detail__final-cta {
|
|
512
|
+
display: flex;
|
|
513
|
+
flex-direction: column;
|
|
514
|
+
gap: var(--service-detail-final-cta-gap, 1.6rem);
|
|
515
|
+
align-items: flex-start;
|
|
516
|
+
justify-content: space-between;
|
|
517
|
+
margin-block-start: var(--service-detail-final-cta-margin-block-start, 3.2rem);
|
|
518
|
+
padding-block: var(--service-detail-final-cta-padding-block, 3.2rem 3.2rem);
|
|
519
|
+
border-block-start: 1px solid var(--service-detail-final-cta-divider-colour, currentColor);
|
|
470
520
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
521
|
+
@container (width >= 700px) {
|
|
522
|
+
flex-direction: row;
|
|
523
|
+
align-items: center;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.service-detail__final-cta-copy {
|
|
527
|
+
p {
|
|
528
|
+
margin: 0;
|
|
529
|
+
opacity: var(--service-detail-muted-opacity, 0.7);
|
|
530
|
+
}
|
|
475
531
|
}
|
|
476
532
|
}
|
|
477
533
|
}
|
package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts
CHANGED
|
@@ -27,6 +27,26 @@ const meta: Meta<typeof ServiceDetail> = {
|
|
|
27
27
|
description:
|
|
28
28
|
"Items for the Breadcrumb atom. Defaults to a plain (non-linked) [category, title] trail built from serviceData when omitted",
|
|
29
29
|
},
|
|
30
|
+
heroImageVariant: {
|
|
31
|
+
control: { type: "select" },
|
|
32
|
+
options: ["full", "popout", "content", "inset-content"],
|
|
33
|
+
description: "PageRow variant applied to the hero image layer",
|
|
34
|
+
},
|
|
35
|
+
heroContentVariant: {
|
|
36
|
+
control: { type: "select" },
|
|
37
|
+
options: ["full", "popout", "content", "inset-content"],
|
|
38
|
+
description: "PageRow variant applied to the hero content layer (breadcrumb/title/pills over the image)",
|
|
39
|
+
},
|
|
40
|
+
bodyVariant: {
|
|
41
|
+
control: { type: "select" },
|
|
42
|
+
options: ["full", "popout", "content", "inset-content"],
|
|
43
|
+
description: "PageRow variant applied to the two-column body (main content + sidebar)",
|
|
44
|
+
},
|
|
45
|
+
finalCtaVariant: {
|
|
46
|
+
control: { type: "select" },
|
|
47
|
+
options: ["full", "popout", "content", "inset-content"],
|
|
48
|
+
description: "PageRow variant applied to the closing full-width CTA banner",
|
|
49
|
+
},
|
|
30
50
|
processHeading: { control: "text", description: "Heading text for the process section" },
|
|
31
51
|
idealForHeading: { control: "text", description: "Heading text for the ideal-for section" },
|
|
32
52
|
maintenanceHeading: { control: "text", description: "Heading text for the maintenance/aftercare section" },
|
|
@@ -46,6 +66,10 @@ const meta: Meta<typeof ServiceDetail> = {
|
|
|
46
66
|
tag: "article",
|
|
47
67
|
headerTag: "h1",
|
|
48
68
|
subheadingTag: "h2",
|
|
69
|
+
heroImageVariant: "full",
|
|
70
|
+
heroContentVariant: "content",
|
|
71
|
+
bodyVariant: "content",
|
|
72
|
+
finalCtaVariant: "content",
|
|
49
73
|
styleClassPassthrough: [],
|
|
50
74
|
},
|
|
51
75
|
parameters: {
|
|
@@ -119,6 +119,50 @@ describe("ServiceDetail", () => {
|
|
|
119
119
|
expect(wrapper.text()).not.toContain(mockService.category);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
// ─── Layout ─────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
it("wraps the hero in a GridStack and the body in a PageRow", async () => {
|
|
125
|
+
const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
|
|
126
|
+
expect(wrapper.find(".service-detail__hero.grid-stack").exists()).toBe(true);
|
|
127
|
+
expect(wrapper.find(".service-detail__body-row.page-row").exists()).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("defaults heroImageVariant to full, heroContentVariant and bodyVariant to content", async () => {
|
|
131
|
+
const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
|
|
132
|
+
expect(wrapper.find(".service-detail__hero-image-row").classes()).toContain("full");
|
|
133
|
+
expect(wrapper.find(".service-detail__hero-overlay").classes()).toContain("content");
|
|
134
|
+
expect(wrapper.find(".service-detail__body-row").classes()).toContain("content");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("applies the heroImageVariant prop to the hero image PageRow", async () => {
|
|
138
|
+
const wrapper = await mountSuspended(ServiceDetail, {
|
|
139
|
+
props: { serviceData: mockService, heroImageVariant: "popout" },
|
|
140
|
+
});
|
|
141
|
+
expect(wrapper.find(".service-detail__hero-image-row").classes()).toContain("popout");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("applies the heroContentVariant prop to the hero content PageRow", async () => {
|
|
145
|
+
const wrapper = await mountSuspended(ServiceDetail, {
|
|
146
|
+
props: { serviceData: mockService, heroContentVariant: "inset-content" },
|
|
147
|
+
});
|
|
148
|
+
expect(wrapper.find(".service-detail__hero-overlay").classes()).toContain("inset-content");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("applies the bodyVariant prop to the body PageRow", async () => {
|
|
152
|
+
const wrapper = await mountSuspended(ServiceDetail, {
|
|
153
|
+
props: { serviceData: mockService, bodyVariant: "popout" },
|
|
154
|
+
});
|
|
155
|
+
expect(wrapper.find(".service-detail__body-row").classes()).toContain("popout");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("defaults finalCtaVariant to content and applies the prop to the final CTA PageRow", async () => {
|
|
159
|
+
const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
|
|
160
|
+
expect(wrapper.find(".service-detail__final-cta-row").classes()).toContain("content");
|
|
161
|
+
|
|
162
|
+
await wrapper.setProps({ finalCtaVariant: "full" });
|
|
163
|
+
expect(wrapper.find(".service-detail__final-cta-row").classes()).toContain("full");
|
|
164
|
+
});
|
|
165
|
+
|
|
122
166
|
// ─── Service data ───────────────────────────────────────────────────────
|
|
123
167
|
|
|
124
168
|
it("renders service price and duration in the hero pills", async () => {
|
|
@@ -2,97 +2,107 @@
|
|
|
2
2
|
|
|
3
3
|
exports[`ServiceDetail > renders correct HTML structure 1`] = `
|
|
4
4
|
"<div class="service-detail">
|
|
5
|
-
<div class="
|
|
6
|
-
<div class="
|
|
7
|
-
<div class="service-detail__hero-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
5
|
+
<div class="grid-stack service-detail__hero">
|
|
6
|
+
<div class="grid-stack__layer">
|
|
7
|
+
<div class="page-row full service-detail__hero-image-row"><img data-nuxt-img="" srcset="/_ipx/_/images/balayage.jpg 1x, /_ipx/_/images/balayage.jpg 2x" alt="Balayage" loading="eager" fetchpriority="high" class="service-detail__hero-image" src="/_ipx/_/images/balayage.jpg"></div>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="grid-stack__layer">
|
|
10
|
+
<div class="page-row content service-detail__hero-overlay">
|
|
11
|
+
<div class="service-detail__hero-content">
|
|
12
|
+
<nav class="breadcrumb service-detail__breadcrumb" aria-label="Breadcrumb">
|
|
13
|
+
<ol class="breadcrumb__list">
|
|
14
|
+
<li class="breadcrumb__item"><span class="breadcrumb__label">Colour</span><span class="breadcrumb__separator" aria-hidden="true">/</span></li>
|
|
15
|
+
<li class="breadcrumb__item"><span class="breadcrumb__label" aria-current="page">Balayage</span>
|
|
16
|
+
<!--v-if-->
|
|
17
|
+
</li>
|
|
18
|
+
</ol>
|
|
19
|
+
</nav>
|
|
20
|
+
<div class="eyebrow-text large">Freehand colour artistry</div>
|
|
21
|
+
<h1 id="v-0-0" class="hero-text mb-20 display axis-horizontal">
|
|
22
|
+
<!--v-if--><span class="text-block-0 normal">Balayage</span>
|
|
23
|
+
</h1>
|
|
24
|
+
<div class="service-detail__hero-pills"><span class="display-pill md neutral"><span class="pill-label">2.5 - 3.5 hours</span></span><span class="display-pill md neutral"><span class="pill-label">From £95</span></span></div>
|
|
25
|
+
</div>
|
|
21
26
|
</div>
|
|
22
27
|
</div>
|
|
23
28
|
</div>
|
|
24
|
-
<div class="service-detail__body">
|
|
25
|
-
<div class="service-
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
<
|
|
29
|
+
<div class="page-row content service-detail__body-row">
|
|
30
|
+
<div class="service-detail__body">
|
|
31
|
+
<div class="service-detail__main">
|
|
32
|
+
<p class="page-body-normal">Balayage is a freehand hair colouring technique that creates a soft gradient.</p>
|
|
33
|
+
<h2 class="hero-text mb-20 subheading axis-horizontal">
|
|
34
|
+
<!--v-if--><span class="text-block-0 normal">What is Balayage?</span>
|
|
35
|
+
</h2>
|
|
36
|
+
<p class="page-body-normal">The word balayage comes from the French for 'to sweep'.</p>
|
|
37
|
+
<h2 class="hero-text mb-20 subheading axis-horizontal">
|
|
38
|
+
<!--v-if--><span class="text-block-0 normal">The Process</span>
|
|
39
|
+
</h2>
|
|
40
|
+
<ol class="stepper-list has-connectors service-detail__process">
|
|
41
|
+
<li class="indicator-top indicator-circle" style="--_connector-top: 0px; --_connector-height: 0px;">
|
|
42
|
+
<div class="stepper-list__indicator-counter"></div>
|
|
43
|
+
<div>
|
|
44
|
+
<p class="page-body-normal">Consultation</p>
|
|
45
|
+
</div>
|
|
46
|
+
</li>
|
|
47
|
+
<li class="indicator-top indicator-circle" style="--_connector-top: 0px; --_connector-height: 0px;">
|
|
48
|
+
<div class="stepper-list__indicator-counter"></div>
|
|
49
|
+
<div>
|
|
50
|
+
<p class="page-body-normal">Sectioning</p>
|
|
51
|
+
</div>
|
|
52
|
+
</li>
|
|
53
|
+
<li class="indicator-top indicator-circle">
|
|
54
|
+
<div class="stepper-list__indicator-counter"></div>
|
|
55
|
+
<div>
|
|
56
|
+
<p class="page-body-normal">Painting</p>
|
|
57
|
+
</div>
|
|
58
|
+
</li>
|
|
59
|
+
</ol>
|
|
60
|
+
<h2 class="hero-text mb-20 subheading axis-horizontal">
|
|
61
|
+
<!--v-if--><span class="text-block-0 normal">Ideal For</span>
|
|
62
|
+
</h2>
|
|
63
|
+
<ul class="service-detail__ideal-for">
|
|
64
|
+
<li class="service-detail__ideal-for-item"><span class="iconify i-mdi:diamond-stone service-detail__ideal-for-icon" aria-hidden="true"></span>
|
|
65
|
+
<p class="page-body-normal">First-time colour clients</p>
|
|
66
|
+
</li>
|
|
67
|
+
<li class="service-detail__ideal-for-item"><span class="iconify i-mdi:diamond-stone service-detail__ideal-for-icon" aria-hidden="true"></span>
|
|
68
|
+
<p class="page-body-normal">Low-maintenance colour</p>
|
|
69
|
+
</li>
|
|
70
|
+
</ul>
|
|
71
|
+
<h2 class="hero-text mb-20 subheading axis-horizontal">
|
|
72
|
+
<!--v-if--><span class="text-block-0 normal">Aftercare & Maintenance</span>
|
|
73
|
+
</h2>
|
|
74
|
+
<p class="page-body-normal">Balayage is low maintenance since regrowth is soft and natural.</p>
|
|
75
|
+
<h2 class="hero-text mb-20 subheading axis-horizontal">
|
|
76
|
+
<!--v-if--><span class="text-block-0 normal">Frequently Asked Questions</span>
|
|
77
|
+
</h2>
|
|
78
|
+
<div class="service-detail__faqs">
|
|
79
|
+
<div class="service-detail__faq">
|
|
80
|
+
<h3 class="service-detail__faq-question">Will it damage my hair?</h3>
|
|
81
|
+
<p class="page-body-normal">Balayage is gentler than full-head bleaching.</p>
|
|
45
82
|
</div>
|
|
46
|
-
</li>
|
|
47
|
-
<li class="indicator-top indicator-circle">
|
|
48
|
-
<div class="stepper-list__indicator-counter"></div>
|
|
49
|
-
<div>
|
|
50
|
-
<p class="page-body-normal">Painting</p>
|
|
51
|
-
</div>
|
|
52
|
-
</li>
|
|
53
|
-
</ol>
|
|
54
|
-
<h2 class="hero-text mb-20 subheading axis-horizontal">
|
|
55
|
-
<!--v-if--><span class="text-block-0 normal">Ideal For</span>
|
|
56
|
-
</h2>
|
|
57
|
-
<ul class="service-detail__ideal-for">
|
|
58
|
-
<li class="service-detail__ideal-for-item"><span class="iconify i-mdi:diamond-stone service-detail__ideal-for-icon" aria-hidden="true"></span>
|
|
59
|
-
<p class="page-body-normal">First-time colour clients</p>
|
|
60
|
-
</li>
|
|
61
|
-
<li class="service-detail__ideal-for-item"><span class="iconify i-mdi:diamond-stone service-detail__ideal-for-icon" aria-hidden="true"></span>
|
|
62
|
-
<p class="page-body-normal">Low-maintenance colour</p>
|
|
63
|
-
</li>
|
|
64
|
-
</ul>
|
|
65
|
-
<h2 class="hero-text mb-20 subheading axis-horizontal">
|
|
66
|
-
<!--v-if--><span class="text-block-0 normal">Aftercare & Maintenance</span>
|
|
67
|
-
</h2>
|
|
68
|
-
<p class="page-body-normal">Balayage is low maintenance since regrowth is soft and natural.</p>
|
|
69
|
-
<h2 class="hero-text mb-20 subheading axis-horizontal">
|
|
70
|
-
<!--v-if--><span class="text-block-0 normal">Frequently Asked Questions</span>
|
|
71
|
-
</h2>
|
|
72
|
-
<div class="service-detail__faqs">
|
|
73
|
-
<div class="service-detail__faq">
|
|
74
|
-
<h3 class="service-detail__faq-question">Will it damage my hair?</h3>
|
|
75
|
-
<p class="page-body-normal">Balayage is gentler than full-head bleaching.</p>
|
|
76
83
|
</div>
|
|
77
84
|
</div>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
<aside class="service-detail__sidebar">
|
|
86
|
+
<div class="glass-panel service-detail__booking-card">
|
|
87
|
+
<p class="service-detail__sidebar-label">Book This Service</p>
|
|
88
|
+
<div class="service-detail__sidebar-row"><span>Price</span><span>£95</span></div>
|
|
89
|
+
<div class="service-detail__sidebar-row"><span>Duration</span><span>2.5 - 3.5 hours</span></div>
|
|
90
|
+
<!--v-if-->
|
|
91
|
+
<!--v-if-->
|
|
92
|
+
<div class="service-detail__sidebar-note"></div>
|
|
93
|
+
</div>
|
|
84
94
|
<!--v-if-->
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<!--v-if-->
|
|
88
|
-
</aside>
|
|
95
|
+
</aside>
|
|
96
|
+
</div>
|
|
89
97
|
</div>
|
|
90
|
-
<div class="service-detail__final-cta">
|
|
91
|
-
<div class="service-detail__final-cta
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
<div class="page-row content service-detail__final-cta-row">
|
|
99
|
+
<div class="service-detail__final-cta">
|
|
100
|
+
<div class="service-detail__final-cta-copy">
|
|
101
|
+
<h2 class="hero-text mbs-0 mbe-8 subheading axis-horizontal">
|
|
102
|
+
<!--v-if--><span class="text-block-0 normal">Ready to book your appointment?</span>
|
|
103
|
+
</h2>
|
|
104
|
+
<p class="page-body-normal">Get in touch to book your appointment.</p>
|
|
105
|
+
</div>
|
|
96
106
|
</div>
|
|
97
107
|
</div>
|
|
98
108
|
</div>"
|