srcdev-nuxt-components 9.3.7 → 9.3.8

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.
Files changed (108) hide show
  1. package/.claude/hooks/check-component-update.sh +30 -1
  2. package/.claude/skills/components/breadcrumb.md +49 -0
  3. package/.claude/skills/components/content-docs.md +1 -1
  4. package/.claude/skills/components/service-detail.md +156 -0
  5. package/.claude/skills/components/services-card-grid.md +9 -9
  6. package/.claude/skills/components/services-card.md +82 -30
  7. package/.claude/skills/index.md +3 -1
  8. package/.claude/skills/qa-panel.md +2 -2
  9. package/.vscode/srcdev-component-breadcrumb.code-snippets +14 -0
  10. package/.vscode/srcdev-component-service-detail.code-snippets +49 -0
  11. package/.vscode/srcdev-component-services-card.code-snippets +108 -0
  12. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +94 -0
  13. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +32 -0
  14. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +87 -0
  15. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +87 -0
  16. package/app/components/01.atoms/navigation/breadcrumb/tests/__snapshots__/Breadcrumb.spec.ts.snap +13 -0
  17. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +181 -0
  18. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +480 -0
  19. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +282 -0
  20. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +270 -0
  21. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +99 -0
  22. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +129 -0
  23. package/app/components/03.organisms/services/services-card/ServicesCard.vue +153 -39
  24. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +65 -0
  25. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +82 -0
  26. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +13 -5
  27. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +3 -7
  28. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +126 -2
  29. package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts +6 -0
  30. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +51 -27
  31. package/app/types/components/breadcrumb.ts +4 -0
  32. package/app/types/components/index.ts +1 -0
  33. package/package.json +2 -2
  34. package/app/pages/auto-grid.vue +0 -328
  35. package/app/pages/banner-video.vue +0 -238
  36. package/app/pages/forms/examples/buttons/index.vue +0 -259
  37. package/app/pages/forms/examples/material/checkbox-radio-panels.vue +0 -244
  38. package/app/pages/forms/examples/material/cssbattle.vue +0 -60
  39. package/app/pages/forms/examples/material/text-fields.vue +0 -910
  40. package/app/pages/grid-stack.vue +0 -252
  41. package/app/pages/index.vue +0 -127
  42. package/app/pages/page-hero-highlights.vue +0 -204
  43. package/app/pages/samaritan.vue +0 -327
  44. package/app/pages/typography/hero-text.vue +0 -97
  45. package/app/pages/typography/index.vue +0 -39
  46. package/app/pages/typography/page-body.vue +0 -171
  47. package/app/pages/typography/page-heading.vue +0 -76
  48. package/app/pages/typography/page-link.vue +0 -103
  49. package/app/pages/ui/accordian.vue +0 -211
  50. package/app/pages/ui/anchor-scroll-tab-navigation.vue +0 -191
  51. package/app/pages/ui/anchor-scroll.vue +0 -367
  52. package/app/pages/ui/animated-svg-text.vue +0 -85
  53. package/app/pages/ui/block-decorators.vue +0 -142
  54. package/app/pages/ui/card-core.vue +0 -82
  55. package/app/pages/ui/carousel-basic.vue +0 -292
  56. package/app/pages/ui/carousel-flip.vue +0 -449
  57. package/app/pages/ui/carousel-infinite.vue +0 -260
  58. package/app/pages/ui/clipped-panels.vue +0 -101
  59. package/app/pages/ui/contact-section.vue +0 -323
  60. package/app/pages/ui/container-glow.vue +0 -106
  61. package/app/pages/ui/content-width.vue +0 -126
  62. package/app/pages/ui/deep-expanding-menu.vue +0 -138
  63. package/app/pages/ui/display-avatar.vue +0 -253
  64. package/app/pages/ui/display-banner.vue +0 -78
  65. package/app/pages/ui/display-chip.vue +0 -367
  66. package/app/pages/ui/display-details.vue +0 -171
  67. package/app/pages/ui/display-dialog.vue +0 -344
  68. package/app/pages/ui/display-pill.vue +0 -396
  69. package/app/pages/ui/display-prompt.vue +0 -284
  70. package/app/pages/ui/display-toast.vue +0 -311
  71. package/app/pages/ui/expanding-panel.vue +0 -326
  72. package/app/pages/ui/glowing-border.vue +0 -111
  73. package/app/pages/ui/layout-content-docs.vue +0 -72
  74. package/app/pages/ui/layout-grid-a.vue +0 -96
  75. package/app/pages/ui/layout-grid-b.vue +0 -132
  76. package/app/pages/ui/magnetic-navigation.vue +0 -50
  77. package/app/pages/ui/marquee-scroller.vue +0 -99
  78. package/app/pages/ui/mask-element.vue +0 -104
  79. package/app/pages/ui/masonry-columns.vue +0 -55
  80. package/app/pages/ui/masonry-grid-ordered-experiment.vue +0 -79
  81. package/app/pages/ui/masonry-grid-ordered.vue +0 -56
  82. package/app/pages/ui/masonry-grid-sorted.vue +0 -55
  83. package/app/pages/ui/masonry-grid.vue +0 -55
  84. package/app/pages/ui/navigation/navigation-horizontal.vue +0 -484
  85. package/app/pages/ui/navigation/site-navigation/about.vue +0 -36
  86. package/app/pages/ui/navigation/site-navigation/contact.vue +0 -39
  87. package/app/pages/ui/navigation/site-navigation/index.vue +0 -38
  88. package/app/pages/ui/navigation/site-navigation/portfolio.vue +0 -43
  89. package/app/pages/ui/navigation/site-navigation/services.vue +0 -42
  90. package/app/pages/ui/navigation/tab-navigation.vue +0 -493
  91. package/app/pages/ui/page-row.vue +0 -127
  92. package/app/pages/ui/price-list.vue +0 -65
  93. package/app/pages/ui/profile-section.vue +0 -142
  94. package/app/pages/ui/qr-code/[componentName].vue +0 -218
  95. package/app/pages/ui/rotating-carousel.vue +0 -154
  96. package/app/pages/ui/scroll-reveal-image.vue +0 -483
  97. package/app/pages/ui/section-parallax.vue +0 -54
  98. package/app/pages/ui/services/colour-finder.vue +0 -43
  99. package/app/pages/ui/services/services-cards.vue +0 -57
  100. package/app/pages/ui/services/services-section/[slug].vue +0 -61
  101. package/app/pages/ui/services/services-sections-compact.vue +0 -64
  102. package/app/pages/ui/settings.vue +0 -54
  103. package/app/pages/ui/simple-grid.vue +0 -55
  104. package/app/pages/ui/slider-gallery.vue +0 -261
  105. package/app/pages/ui/tabs-y.vue +0 -107
  106. package/app/pages/ui/tabs.vue +0 -189
  107. package/app/pages/ui/tooltips.vue +0 -104
  108. package/app/pages/ui/wipe-away-vertical.vue +0 -129
@@ -0,0 +1,282 @@
1
+ import ServiceDetail from "../ServiceDetail.vue";
2
+ import InputButtonCore from "../../../../05.forms/input-button/InputButtonCore.vue";
3
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
4
+ import type { Service } from "~/types/types.services";
5
+
6
+ const meta: Meta<typeof ServiceDetail> = {
7
+ title: "Organisms/Services/Service Detail",
8
+ component: ServiceDetail,
9
+ argTypes: {
10
+ tag: {
11
+ control: { type: "select" },
12
+ options: ["div", "section", "article", "main"],
13
+ description: "HTML element rendered as the root",
14
+ },
15
+ headerTag: {
16
+ control: { type: "select" },
17
+ options: ["h1", "h2", "h3"],
18
+ description: "Heading element used for the service title in the hero banner",
19
+ },
20
+ subheadingTag: {
21
+ control: { type: "select" },
22
+ options: ["h2", "h3"],
23
+ description: "Heading element used for every section subheading below the hero",
24
+ },
25
+ breadcrumbItems: {
26
+ control: "object",
27
+ description:
28
+ "Items for the Breadcrumb atom. Defaults to a plain (non-linked) [category, title] trail built from serviceData when omitted",
29
+ },
30
+ processHeading: { control: "text", description: "Heading text for the process section" },
31
+ idealForHeading: { control: "text", description: "Heading text for the ideal-for section" },
32
+ maintenanceHeading: { control: "text", description: "Heading text for the maintenance/aftercare section" },
33
+ faqsHeading: { control: "text", description: "Heading text for the FAQs section" },
34
+ bookingHeading: { control: "text", description: "Label above the sidebar booking card" },
35
+ priceLabel: { control: "text", description: "Row label for the price in the sidebar booking card" },
36
+ durationLabel: { control: "text", description: "Row label for the duration in the sidebar booking card" },
37
+ locationLabel: { control: "text", description: "Row label for the location in the sidebar booking card" },
38
+ location: { control: "text", description: "Location text — the row is hidden entirely when omitted" },
39
+ relatedServicesHeading: { control: "text", description: "Heading above the 'You may also like' list" },
40
+ relatedServices: { control: "object", description: "Services rendered in the 'You may also like' sidebar list" },
41
+ finalCtaHeading: { control: "text", description: "Heading in the closing full-width CTA banner" },
42
+ finalCtaBody: { control: "text", description: "Body text in the closing full-width CTA banner" },
43
+ styleClassPassthrough: { control: "object", description: "Additional CSS classes applied to the root element" },
44
+ },
45
+ args: {
46
+ tag: "article",
47
+ headerTag: "h1",
48
+ subheadingTag: "h2",
49
+ styleClassPassthrough: [],
50
+ },
51
+ parameters: {
52
+ backgrounds: {
53
+ default: "storybook-canvas",
54
+ options: {
55
+ "storybook-canvas": { name: "Canvas", value: "oklch(0.163 0.005 17)" },
56
+ },
57
+ },
58
+ docs: {
59
+ description: {
60
+ component:
61
+ "Renders a full service detail page: a full-bleed hero banner (breadcrumb, eyebrow, title, price/duration pills over the service image), a two-column body (long-form content plus a sticky sidebar booking card and related-services list), and a closing full-width CTA banner. Routing (breadcrumb links, book-cta, related-service links, final-cta) is delegated to the consumer via slots, same pattern as ServicesSection.",
62
+ },
63
+ },
64
+ },
65
+ };
66
+
67
+ export default meta;
68
+ type Story = StoryObj<typeof ServiceDetail>;
69
+
70
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
71
+
72
+ const balayage: Service = {
73
+ slug: "balayage",
74
+ category: "Colour",
75
+ title: "Balayage",
76
+ subtitle: "Freehand colour artistry",
77
+ price: "From £95",
78
+ duration: "2.5 - 3.5 hours",
79
+ image: "https://picsum.photos/seed/balayage/1600/900",
80
+ shortDescription: "Freehand hand-painted colour for a soft, sun-kissed finish.",
81
+ longDescription:
82
+ "Balayage is a freehand hair colouring technique that creates a soft, natural-looking gradient of lightness through the hair. Unlike traditional highlights, balayage is painted on by hand without foils, allowing for a more blended and sun-kissed finish. It's perfect for those who want a low-maintenance style with depth and movement, as the regrowth line is less noticeable.",
83
+ heroHeading: [{ text: "What is Balayage?", styleClass: "normal" }],
84
+ whatIsIt:
85
+ "The word 'balayage' comes from the French word meaning 'to sweep'. During the technique, colour is literally swept onto sections of hair by hand using a brush or paddle. This freehand approach means no two balayage results are ever identical — each is bespoke to your hair's natural fall, texture, and the placement that best frames your face.",
86
+ process: [
87
+ "We discuss your desired look, assess your natural colour and hair condition, and plan placement.",
88
+ "Your hair is divided into panels based on where the lightness will have the most impact.",
89
+ "Lightener is hand-painted onto the surface of each section, concentrating colour towards the ends for that graduated effect.",
90
+ "The lightener develops (typically 30-45 minutes) with or without wrapping, depending on the desired intensity.",
91
+ "Once lifted, a toner is applied to achieve the perfect shade: warm caramel, cool ash, honey blonde, or anything in between.",
92
+ "A professional finish so you can see the full dimension and movement of your new colour.",
93
+ ],
94
+ idealFor: [
95
+ "First-time colour clients who want a natural-looking result",
96
+ "Anyone wanting low-maintenance colour that grows out gracefully",
97
+ "Adding depth and dimension to flat or single-tone hair",
98
+ "Creating a sun-kissed, lived-in look year-round",
99
+ ],
100
+ maintenance:
101
+ "One of the biggest advantages of balayage is its low maintenance. Because the colour is graduated rather than starting at the root, regrowth is soft and natural. Most clients return every 12-16 weeks for a refresh, though some go even longer. Using colour-safe shampoo and a weekly hair mask will keep your balayage looking vibrant between appointments.",
102
+ faqs: [
103
+ {
104
+ question: "Will balayage damage my hair?",
105
+ answer:
106
+ "All lightening processes involve some chemical change, but balayage is gentler than full-head bleaching because it's applied to selected sections only. I use professional-grade products with bond-strengthening technology to minimise damage and keep your hair healthy.",
107
+ },
108
+ {
109
+ question: "Can I get balayage on dark hair?",
110
+ answer:
111
+ "Absolutely! Balayage looks stunning on dark hair. Warm toffee, rich caramel, and chestnut tones create beautiful contrast against a dark base. Very dark hair may require a two-session approach to lift gradually and avoid brassiness.",
112
+ },
113
+ {
114
+ question: "How long does a balayage appointment take?",
115
+ answer:
116
+ "Allow 2.5 to 3.5 hours depending on hair length and thickness. This includes consultation, application, processing, toning, and styling.",
117
+ },
118
+ ],
119
+ seoTitle: "Balayage | Luxury Locs by Natasha",
120
+ seoDescription: "Balayage hair colouring — freehand, low-maintenance colour. Mobile service across Bath.",
121
+ };
122
+
123
+ const relatedServices: Service[] = [
124
+ { ...balayage, slug: "highlights", title: "Highlights", price: "From £85" },
125
+ { ...balayage, slug: "lowlights", title: "Lowlights", price: "From £75" },
126
+ { ...balayage, slug: "toner-gloss", title: "Toner & Gloss", price: "From £35" },
127
+ ];
128
+
129
+ // Story-only palette matching the design mock — cards, borders, and a gold accent, applied via
130
+ // ServiceDetail's own --service-detail-*/--glass-panel-*/--breadcrumb-* tokens (see
131
+ // CONSUMER-STYLING.md) rather than new component defaults.
132
+ const storyTheme = {
133
+ "--glass-panel-bg": "oklch(0.216 0.006 40)",
134
+ "--glass-panel-border-color": "oklch(0.283 0.008 44)",
135
+ "--service-detail-process-divider-colour": "oklch(0.283 0.008 44)",
136
+ "--service-detail-faq-divider-colour": "oklch(0.283 0.008 44)",
137
+ "--service-detail-sidebar-row-divider-colour": "oklch(0.283 0.008 44)",
138
+ "--service-detail-final-cta-divider-colour": "oklch(0.283 0.008 44)",
139
+ "--service-detail-ideal-for-item-border-colour": "oklch(0.283 0.008 44)",
140
+ "--service-detail-process-index-colour": "oklch(0.727 0.089 87)",
141
+ "--service-detail-ideal-for-icon-colour": "oklch(0.727 0.089 87)",
142
+ "--service-detail-hero-pill-border-colour": "oklch(0.727 0.089 87)",
143
+ "--breadcrumb-colour-current": "oklch(0.727 0.089 87)",
144
+ };
145
+
146
+ // ─── Stories ──────────────────────────────────────────────────────────────────
147
+
148
+ export const Default: Story = {
149
+ render: (args) => ({
150
+ components: { ServiceDetail, InputButtonCore },
151
+ setup() {
152
+ return { args, balayage, relatedServices, storyTheme };
153
+ },
154
+ template: `
155
+ <ServiceDetail
156
+ v-bind="args"
157
+ :service-data="balayage"
158
+ location="Mobile — across Bath"
159
+ :related-services="relatedServices"
160
+ :style="storyTheme"
161
+ >
162
+ <template #book-cta>
163
+ <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" :style-class-passthrough="['mbs-16']" />
164
+ </template>
165
+ <template #sidebar-note>
166
+ A patch test is required at least 48 hours before any colour treatment.
167
+ </template>
168
+ <template #final-cta>
169
+ <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="secondary" />
170
+ </template>
171
+ </ServiceDetail>
172
+ `,
173
+ }),
174
+ parameters: {
175
+ docs: {
176
+ description: {
177
+ story:
178
+ "Matches the real service detail page mock: breadcrumb + hero image, full content, sticky sidebar with location, patch-test note, and 'You may also like' related services.",
179
+ },
180
+ },
181
+ },
182
+ };
183
+
184
+ export const NoLocationOrRelated: Story = {
185
+ name: "No Location / No Related Services",
186
+ render: (args) => ({
187
+ components: { ServiceDetail, InputButtonCore },
188
+ setup() {
189
+ return { args, balayage, storyTheme };
190
+ },
191
+ template: `
192
+ <ServiceDetail v-bind="args" :service-data="balayage" :style="storyTheme">
193
+ <template #book-cta>
194
+ <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" :style-class-passthrough="['mbs-16']" />
195
+ </template>
196
+ <template #final-cta>
197
+ <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="secondary" />
198
+ </template>
199
+ </ServiceDetail>
200
+ `,
201
+ }),
202
+ parameters: {
203
+ docs: {
204
+ description: {
205
+ story:
206
+ "Both the location row and the 'You may also like' block are optional — they simply don't render when `location` / `relatedServices` aren't passed.",
207
+ },
208
+ },
209
+ },
210
+ };
211
+
212
+ export const CustomBreadcrumbAndHeadings: Story = {
213
+ name: "Custom Breadcrumb & Headings",
214
+ render: (args) => ({
215
+ components: { ServiceDetail, InputButtonCore },
216
+ setup() {
217
+ return { args, balayage, relatedServices, storyTheme };
218
+ },
219
+ template: `
220
+ <ServiceDetail
221
+ v-bind="args"
222
+ :service-data="balayage"
223
+ :related-services="relatedServices"
224
+ :breadcrumb-items="[{ label: 'Home', to: '/' }, { label: 'Services', to: '/services' }, { label: 'Balayage' }]"
225
+ process-heading="How It Works"
226
+ ideal-for-heading="Who Is This For"
227
+ maintenance-heading="Aftercare"
228
+ faqs-heading="FAQs"
229
+ booking-heading="Reserve Your Slot"
230
+ :style="storyTheme"
231
+ >
232
+ <template #book-cta>
233
+ <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" :style-class-passthrough="['mbs-16']" />
234
+ </template>
235
+ <template #final-cta>
236
+ <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="secondary" />
237
+ </template>
238
+ </ServiceDetail>
239
+ `,
240
+ }),
241
+ parameters: {
242
+ docs: {
243
+ description: {
244
+ story: "Real breadcrumb routes and overridden section/booking copy, all via props — no hardcoded English text to fight.",
245
+ },
246
+ },
247
+ },
248
+ };
249
+
250
+ export const CustomRelatedServiceLink: Story = {
251
+ name: "Custom Related Service Link (slot override)",
252
+ render: (args) => ({
253
+ components: { ServiceDetail, InputButtonCore },
254
+ setup() {
255
+ return { args, balayage, relatedServices, storyTheme };
256
+ },
257
+ template: `
258
+ <ServiceDetail v-bind="args" :service-data="balayage" :related-services="relatedServices" :style="storyTheme">
259
+ <template #book-cta>
260
+ <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" :style-class-passthrough="['mbs-16']" />
261
+ </template>
262
+ <template #related-service="{ service }">
263
+ <a :href="'/services/' + service.slug" style="display:flex;gap:1.2rem;align-items:center;color:inherit;text-decoration:none;">
264
+ <img :src="service.image" :alt="service.title" style="width:5.6rem;height:5.6rem;object-fit:cover;border-radius:0.4rem;" />
265
+ <span>{{ service.title }} — {{ service.price }}</span>
266
+ </a>
267
+ </template>
268
+ <template #final-cta>
269
+ <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="secondary" />
270
+ </template>
271
+ </ServiceDetail>
272
+ `,
273
+ }),
274
+ parameters: {
275
+ docs: {
276
+ description: {
277
+ story:
278
+ "The related-service scoped slot replaces each related item's default (non-clickable) markup — use it to wrap the item in a real link, matching the pattern of ServicesSection's summary-link slot.",
279
+ },
280
+ },
281
+ },
282
+ };
@@ -0,0 +1,270 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import ServiceDetail from "../ServiceDetail.vue";
4
+ import type { Service } from "~/types/types.services";
5
+
6
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
7
+
8
+ const mockService: Service = {
9
+ slug: "balayage",
10
+ category: "Colour",
11
+ title: "Balayage",
12
+ subtitle: "Freehand colour artistry",
13
+ price: "£95",
14
+ duration: "2.5 - 3.5 hours",
15
+ image: "/images/balayage.jpg",
16
+ shortDescription: "Freehand hand-painted colour",
17
+ longDescription: "Balayage is a freehand hair colouring technique that creates a soft gradient.",
18
+ heroHeading: [{ text: "What is Balayage?", styleClass: "normal" }],
19
+ whatIsIt: "The word balayage comes from the French for 'to sweep'.",
20
+ process: ["Consultation", "Sectioning", "Painting"],
21
+ idealFor: ["First-time colour clients", "Low-maintenance colour"],
22
+ maintenance: "Balayage is low maintenance since regrowth is soft and natural.",
23
+ faqs: [{ question: "Will it damage my hair?", answer: "Balayage is gentler than full-head bleaching." }],
24
+ seoTitle: "Balayage",
25
+ seoDescription: "Balayage service",
26
+ };
27
+
28
+ const relatedService: Service = {
29
+ ...mockService,
30
+ slug: "highlights",
31
+ title: "Highlights",
32
+ price: "£85",
33
+ };
34
+
35
+ // ─── Tests ────────────────────────────────────────────────────────────────────
36
+
37
+ describe("ServiceDetail", () => {
38
+ // ─── Mount ─────────────────────────────────────────────────────────────
39
+
40
+ it("mounts without error", async () => {
41
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
42
+ expect(wrapper.vm).toBeTruthy();
43
+ });
44
+
45
+ it("renders correct HTML structure", async () => {
46
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
47
+ expect(wrapper.html()).toMatchSnapshot();
48
+ });
49
+
50
+ // ─── Root element ───────────────────────────────────────────────────────
51
+
52
+ it("has service-detail class on root", async () => {
53
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
54
+ expect(wrapper.classes()).toContain("service-detail");
55
+ });
56
+
57
+ it("renders as a div by default", async () => {
58
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
59
+ expect(wrapper.element.tagName).toBe("DIV");
60
+ });
61
+
62
+ it("renders the tag prop as the root element", async () => {
63
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService, tag: "section" } });
64
+ expect(wrapper.element.tagName).toBe("SECTION");
65
+ });
66
+
67
+ // ─── Aria ───────────────────────────────────────────────────────────────
68
+
69
+ it("binds aria-labelledby to the title HeroText's own id when tag is section", async () => {
70
+ const wrapper = await mountSuspended(ServiceDetail, {
71
+ props: { serviceData: mockService, tag: "section" },
72
+ attachTo: document.body,
73
+ });
74
+ const ariaLabelledby = wrapper.attributes("aria-labelledby");
75
+ expect(ariaLabelledby).toBeTruthy();
76
+ expect(document.getElementById(ariaLabelledby!)?.textContent).toContain(mockService.title);
77
+ });
78
+
79
+ // ─── Heading tags ───────────────────────────────────────────────────────
80
+
81
+ it("renders the title as an h1 by default", async () => {
82
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
83
+ expect(wrapper.find("h1").text()).toContain(mockService.title);
84
+ });
85
+
86
+ it("renders the title tag from the headerTag prop", async () => {
87
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService, headerTag: "h2" } });
88
+ expect(wrapper.find("h2").exists()).toBe(true);
89
+ });
90
+
91
+ it("renders section subheadings as h2 by default", async () => {
92
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
93
+ const h2s = wrapper.findAll("h2");
94
+ expect(h2s.some((h) => h.text() === "The Process")).toBe(true);
95
+ });
96
+
97
+ it("renders section subheadings from the subheadingTag prop", async () => {
98
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService, subheadingTag: "h3" } });
99
+ const h3s = wrapper.findAll("h3");
100
+ expect(h3s.some((h) => h.text() === "The Process")).toBe(true);
101
+ });
102
+
103
+ // ─── Breadcrumb ─────────────────────────────────────────────────────────
104
+
105
+ it("builds a default breadcrumb from the service's category and title", async () => {
106
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
107
+ expect(wrapper.text()).toContain(mockService.category);
108
+ expect(wrapper.findComponent({ name: "Breadcrumb" }).exists()).toBe(true);
109
+ });
110
+
111
+ it("uses breadcrumbItems prop over the default when provided", async () => {
112
+ const wrapper = await mountSuspended(ServiceDetail, {
113
+ props: {
114
+ serviceData: mockService,
115
+ breadcrumbItems: [{ label: "Home", to: "/" }, { label: "Custom Crumb" }],
116
+ },
117
+ });
118
+ expect(wrapper.text()).toContain("Custom Crumb");
119
+ expect(wrapper.text()).not.toContain(mockService.category);
120
+ });
121
+
122
+ // ─── Service data ───────────────────────────────────────────────────────
123
+
124
+ it("renders service price and duration in the hero pills", async () => {
125
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
126
+ expect(wrapper.text()).toContain(mockService.duration);
127
+ expect(wrapper.text()).toContain(mockService.price);
128
+ });
129
+
130
+ it("renders the process steps via StepperList, one li per step", async () => {
131
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
132
+ expect(wrapper.find(".service-detail__process").findAll("li").length).toBe(mockService.process.length);
133
+ expect(wrapper.text()).toContain("Consultation");
134
+ });
135
+
136
+ it("renders the ideal-for list", async () => {
137
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
138
+ expect(wrapper.text()).toContain("First-time colour clients");
139
+ });
140
+
141
+ it("renders the faqs", async () => {
142
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
143
+ expect(wrapper.text()).toContain("Will it damage my hair?");
144
+ expect(wrapper.text()).toContain("Balayage is gentler than full-head bleaching.");
145
+ });
146
+
147
+ // ─── Sidebar ────────────────────────────────────────────────────────────
148
+
149
+ it("renders default sidebar labels and service price/duration rows", async () => {
150
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
151
+ expect(wrapper.text()).toContain("Book This Service");
152
+ expect(wrapper.text()).toContain("Price");
153
+ expect(wrapper.text()).toContain("Duration");
154
+ });
155
+
156
+ it("does not render a location row when location is not provided", async () => {
157
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
158
+ expect(wrapper.text()).not.toContain("Location");
159
+ });
160
+
161
+ it("renders a location row when location is provided", async () => {
162
+ const wrapper = await mountSuspended(ServiceDetail, {
163
+ props: { serviceData: mockService, location: "Mobile — across Bath" },
164
+ });
165
+ expect(wrapper.text()).toContain("Location");
166
+ expect(wrapper.text()).toContain("Mobile — across Bath");
167
+ });
168
+
169
+ it("renders the book-cta slot scoped with serviceData", async () => {
170
+ const wrapper = await mountSuspended(ServiceDetail, {
171
+ props: { serviceData: mockService },
172
+ slots: {
173
+ "book-cta": `<template #book-cta="{ serviceData }"><button class="test-book">{{ serviceData.title }}</button></template>`,
174
+ },
175
+ });
176
+ expect(wrapper.find(".test-book").text()).toBe(mockService.title);
177
+ });
178
+
179
+ it("renders the sidebar-note slot", async () => {
180
+ const wrapper = await mountSuspended(ServiceDetail, {
181
+ props: { serviceData: mockService },
182
+ slots: { "sidebar-note": '<p class="test-note">A patch test is required.</p>' },
183
+ });
184
+ expect(wrapper.find(".test-note").exists()).toBe(true);
185
+ });
186
+
187
+ // ─── Related services ───────────────────────────────────────────────────
188
+
189
+ it("does not render the related-services block when none are provided", async () => {
190
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
191
+ expect(wrapper.find(".service-detail__related").exists()).toBe(false);
192
+ });
193
+
194
+ it("renders related services with default markup", async () => {
195
+ const wrapper = await mountSuspended(ServiceDetail, {
196
+ props: { serviceData: mockService, relatedServices: [relatedService] },
197
+ });
198
+ expect(wrapper.find(".service-detail__related").exists()).toBe(true);
199
+ expect(wrapper.text()).toContain("Highlights");
200
+ expect(wrapper.text()).toContain("£85");
201
+ });
202
+
203
+ it("replaces a related service item via the related-service scoped slot", async () => {
204
+ const wrapper = await mountSuspended(ServiceDetail, {
205
+ props: { serviceData: mockService, relatedServices: [relatedService] },
206
+ slots: {
207
+ "related-service": `<template #related-service="{ service }"><a class="test-related">{{ service.title }}</a></template>`,
208
+ },
209
+ });
210
+ expect(wrapper.find(".test-related").exists()).toBe(true);
211
+ expect(wrapper.find(".test-related").text()).toBe("Highlights");
212
+ });
213
+
214
+ // ─── Final CTA ──────────────────────────────────────────────────────────
215
+
216
+ it("renders default final CTA heading and body", async () => {
217
+ const wrapper = await mountSuspended(ServiceDetail, { props: { serviceData: mockService } });
218
+ expect(wrapper.text()).toContain("Ready to book your appointment?");
219
+ expect(wrapper.text()).toContain("Get in touch to book your appointment.");
220
+ });
221
+
222
+ it("overrides final CTA heading and body via props", async () => {
223
+ const wrapper = await mountSuspended(ServiceDetail, {
224
+ props: {
225
+ serviceData: mockService,
226
+ finalCtaHeading: "Book your colour appointment",
227
+ finalCtaBody: "Mobile service across Bath.",
228
+ },
229
+ });
230
+ expect(wrapper.text()).toContain("Book your colour appointment");
231
+ expect(wrapper.text()).toContain("Mobile service across Bath.");
232
+ });
233
+
234
+ it("renders the final-cta slot scoped with serviceData", async () => {
235
+ const wrapper = await mountSuspended(ServiceDetail, {
236
+ props: { serviceData: mockService },
237
+ slots: {
238
+ "final-cta": `<template #final-cta="{ serviceData }"><button class="test-final-cta">{{ serviceData.title }}</button></template>`,
239
+ },
240
+ });
241
+ expect(wrapper.find(".test-final-cta").text()).toBe(mockService.title);
242
+ });
243
+
244
+ // ─── styleClassPassthrough ──────────────────────────────────────────────
245
+
246
+ it("applies a single styleClassPassthrough string", async () => {
247
+ const wrapper = await mountSuspended(ServiceDetail, {
248
+ props: { serviceData: mockService, styleClassPassthrough: "custom-class" },
249
+ });
250
+ expect(wrapper.classes()).toContain("custom-class");
251
+ });
252
+
253
+ it("applies multiple styleClassPassthrough classes from an array", async () => {
254
+ const wrapper = await mountSuspended(ServiceDetail, {
255
+ props: { serviceData: mockService, styleClassPassthrough: ["class-a", "class-b"] },
256
+ });
257
+ expect(wrapper.classes()).toContain("class-a");
258
+ expect(wrapper.classes()).toContain("class-b");
259
+ });
260
+
261
+ it("updates classes when styleClassPassthrough prop changes", async () => {
262
+ const wrapper = await mountSuspended(ServiceDetail, {
263
+ props: { serviceData: mockService, styleClassPassthrough: ["original"] },
264
+ });
265
+ expect(wrapper.classes()).toContain("original");
266
+ await wrapper.setProps({ styleClassPassthrough: ["updated"] });
267
+ expect(wrapper.classes()).not.toContain("original");
268
+ expect(wrapper.classes()).toContain("updated");
269
+ });
270
+ });
@@ -0,0 +1,99 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`ServiceDetail > renders correct HTML structure 1`] = `
4
+ "<div class="service-detail">
5
+ <div class="service-detail__hero"><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">
6
+ <div class="service-detail__hero-overlay">
7
+ <div class="service-detail__hero-content">
8
+ <nav class="breadcrumb service-detail__breadcrumb" aria-label="Breadcrumb">
9
+ <ol class="breadcrumb__list">
10
+ <li class="breadcrumb__item"><span class="breadcrumb__label">Colour</span><span class="breadcrumb__separator" aria-hidden="true">/</span></li>
11
+ <li class="breadcrumb__item"><span class="breadcrumb__label" aria-current="page">Balayage</span>
12
+ <!--v-if-->
13
+ </li>
14
+ </ol>
15
+ </nav>
16
+ <div class="eyebrow-text large">Freehand colour artistry</div>
17
+ <h1 id="v-0-0" class="hero-text mb-20 display axis-horizontal">
18
+ <!--v-if--><span class="text-block-0 normal">Balayage</span>
19
+ </h1>
20
+ <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>
21
+ </div>
22
+ </div>
23
+ </div>
24
+ <div class="service-detail__body">
25
+ <div class="service-detail__main">
26
+ <p class="page-body-normal">Balayage is a freehand hair colouring technique that creates a soft gradient.</p>
27
+ <h2 class="hero-text mb-20 subheading axis-horizontal">
28
+ <!--v-if--><span class="text-block-0 normal">What is Balayage?</span>
29
+ </h2>
30
+ <p class="page-body-normal">The word balayage comes from the French for 'to sweep'.</p>
31
+ <h2 class="hero-text mb-20 subheading axis-horizontal">
32
+ <!--v-if--><span class="text-block-0 normal">The Process</span>
33
+ </h2>
34
+ <ol class="stepper-list has-connectors service-detail__process">
35
+ <li class="indicator-top indicator-circle" style="--_connector-top: 0px; --_connector-height: 0px;">
36
+ <div class="stepper-list__indicator-counter"></div>
37
+ <div>
38
+ <p class="page-body-normal">Consultation</p>
39
+ </div>
40
+ </li>
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">Sectioning</p>
45
+ </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 &amp; 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
+ </div>
77
+ </div>
78
+ </div>
79
+ <aside class="service-detail__sidebar">
80
+ <div class="glass-panel service-detail__booking-card">
81
+ <p class="service-detail__sidebar-label">Book This Service</p>
82
+ <div class="service-detail__sidebar-row"><span>Price</span><span>£95</span></div>
83
+ <div class="service-detail__sidebar-row"><span>Duration</span><span>2.5 - 3.5 hours</span></div>
84
+ <!--v-if-->
85
+ <div class="service-detail__sidebar-note"></div>
86
+ </div>
87
+ <!--v-if-->
88
+ </aside>
89
+ </div>
90
+ <div class="service-detail__final-cta">
91
+ <div class="service-detail__final-cta-copy">
92
+ <h2 class="hero-text mbs-0 mbe-8 subheading axis-horizontal">
93
+ <!--v-if--><span class="text-block-0 normal">Ready to book your appointment?</span>
94
+ </h2>
95
+ <p class="page-body-normal">Get in touch to book your appointment.</p>
96
+ </div>
97
+ </div>
98
+ </div>"
99
+ `;