srcdev-nuxt-components 9.1.22 → 9.1.24
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 +10 -1
- package/.claude/skills/components/services-section-grid.md +130 -0
- package/.claude/skills/components/services-section.md +16 -0
- package/.claude/skills/index.md +3 -2
- package/app/assets/styles/setup/06.utility-classes/animations/_entry-slide-in.css +5 -3
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +193 -0
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +202 -0
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +12 -0
- package/app/components/03.organisms/services/services-grids/ServicesSectionGrid.vue +1 -17
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +181 -0
- package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts +196 -0
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +180 -0
- package/app/components/03.organisms/services/services-grids/tests/ServicesSectionGrid.spec.ts +167 -0
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +42 -0
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +87 -0
- package/app/components/03.organisms/services/services-section/ServicesSection.vue +6 -2
- package/app/components/03.organisms/services/services-section/stories/ServicesSection.stories.ts +214 -0
- package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +213 -0
- package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +84 -0
- package/package.json +1 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import ServicesSectionGrid from "../ServicesSectionGrid.vue";
|
|
4
|
+
import type { Service } from "~/types/types.services";
|
|
5
|
+
|
|
6
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const makeService = (slug: string, title: string): Service => ({
|
|
9
|
+
slug,
|
|
10
|
+
title,
|
|
11
|
+
subtitle: `Subtitle for ${title}`,
|
|
12
|
+
price: "£50",
|
|
13
|
+
duration: "60 mins",
|
|
14
|
+
image: `/images/${slug}.jpg`,
|
|
15
|
+
shortDescription: `Short description for ${title}.`,
|
|
16
|
+
longDescription: "Long description.",
|
|
17
|
+
heroHeading: [{ text: title, styleClass: "normal" }],
|
|
18
|
+
whatIsIt: "What this service is.",
|
|
19
|
+
process: ["Step one"],
|
|
20
|
+
idealFor: ["Everyone"],
|
|
21
|
+
maintenance: "Maintenance advice.",
|
|
22
|
+
faqs: [{ question: "A question?", answer: "An answer." }],
|
|
23
|
+
seoTitle: title,
|
|
24
|
+
seoDescription: `SEO description for ${title}.`,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const mockServices: Service[] = [
|
|
28
|
+
makeService("locs-installation", "Locs Installation"),
|
|
29
|
+
makeService("locs-retwist", "Locs Retwist"),
|
|
30
|
+
makeService("colour-treatment", "Colour Treatment"),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
describe("ServicesSectionGrid", () => {
|
|
36
|
+
// ─── Mount ─────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
it("mounts without error", async () => {
|
|
39
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
40
|
+
props: { servicesData: mockServices },
|
|
41
|
+
});
|
|
42
|
+
expect(wrapper.vm).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("renders correct HTML structure", async () => {
|
|
46
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
47
|
+
props: { servicesData: mockServices },
|
|
48
|
+
});
|
|
49
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ─── Root element ───────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
it("has services-grid class on root", async () => {
|
|
55
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
56
|
+
props: { servicesData: mockServices },
|
|
57
|
+
});
|
|
58
|
+
expect(wrapper.classes()).toContain("services-grid");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("renders as a div by default", async () => {
|
|
62
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
63
|
+
props: { servicesData: mockServices },
|
|
64
|
+
});
|
|
65
|
+
expect(wrapper.element.tagName).toBe("DIV");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("renders the tag prop as the root element", async () => {
|
|
69
|
+
for (const tag of ["section", "main"] as const) {
|
|
70
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
71
|
+
props: { servicesData: mockServices, tag },
|
|
72
|
+
});
|
|
73
|
+
expect(wrapper.element.tagName).toBe(tag.toUpperCase());
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ─── Children ───────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
it("renders the correct number of service sections", async () => {
|
|
80
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
81
|
+
props: { servicesData: mockServices },
|
|
82
|
+
});
|
|
83
|
+
expect(wrapper.findAll(".services-section")).toHaveLength(mockServices.length);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("renders no sections when servicesData is empty", async () => {
|
|
87
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
88
|
+
props: { servicesData: [] },
|
|
89
|
+
});
|
|
90
|
+
expect(wrapper.findAll(".services-section")).toHaveLength(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("renders a single section correctly", async () => {
|
|
94
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
95
|
+
props: { servicesData: [mockServices[0]!] },
|
|
96
|
+
});
|
|
97
|
+
expect(wrapper.findAll(".services-section")).toHaveLength(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ─── useAlternateReverse ────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
it("does not apply reverse to any section by default", async () => {
|
|
103
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
104
|
+
props: { servicesData: mockServices },
|
|
105
|
+
});
|
|
106
|
+
wrapper.findAll(".services-section__grid").forEach((grid) => {
|
|
107
|
+
expect(grid.classes()).not.toContain("services-section__grid--reverse");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("applies reverse to odd-indexed sections when useAlternateReverse is true", async () => {
|
|
112
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
113
|
+
props: { servicesData: mockServices, useAlternateReverse: true },
|
|
114
|
+
});
|
|
115
|
+
const grids = wrapper.findAll(".services-section__grid");
|
|
116
|
+
expect(grids[0]?.classes()).not.toContain("services-section__grid--reverse"); // index 0 — even
|
|
117
|
+
expect(grids[1]?.classes()).toContain("services-section__grid--reverse"); // index 1 — odd
|
|
118
|
+
expect(grids[2]?.classes()).not.toContain("services-section__grid--reverse"); // index 2 — even
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ─── Slots ─────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
it("renders summary-link slot content in each section", async () => {
|
|
124
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
125
|
+
props: { servicesData: mockServices },
|
|
126
|
+
slots: {
|
|
127
|
+
"summary-link": '<a class="test-link" href="/services/test">View service</a>',
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
expect(wrapper.findAll(".test-link")).toHaveLength(mockServices.length);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("renders nothing in summary-link when slot is not provided", async () => {
|
|
134
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
135
|
+
props: { servicesData: [mockServices[0]!] },
|
|
136
|
+
});
|
|
137
|
+
// isSummary=true, so summary-link slot is rendered — but empty since no content was provided
|
|
138
|
+
expect(wrapper.find(".test-link").exists()).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ─── styleClassPassthrough ──────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
it("applies a single styleClassPassthrough string", async () => {
|
|
144
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
145
|
+
props: { servicesData: mockServices, styleClassPassthrough: "custom-grid" },
|
|
146
|
+
});
|
|
147
|
+
expect(wrapper.classes()).toContain("custom-grid");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("applies multiple styleClassPassthrough classes from an array", async () => {
|
|
151
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
152
|
+
props: { servicesData: mockServices, styleClassPassthrough: ["class-a", "class-b"] },
|
|
153
|
+
});
|
|
154
|
+
expect(wrapper.classes()).toContain("class-a");
|
|
155
|
+
expect(wrapper.classes()).toContain("class-b");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("updates classes when styleClassPassthrough prop changes", async () => {
|
|
159
|
+
const wrapper = await mountSuspended(ServicesSectionGrid, {
|
|
160
|
+
props: { servicesData: mockServices, styleClassPassthrough: ["original"] },
|
|
161
|
+
});
|
|
162
|
+
expect(wrapper.classes()).toContain("original");
|
|
163
|
+
await wrapper.setProps({ styleClassPassthrough: ["updated"] });
|
|
164
|
+
expect(wrapper.classes()).not.toContain("original");
|
|
165
|
+
expect(wrapper.classes()).toContain("updated");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
|
|
4
|
+
"<div class="services-card-grid">
|
|
5
|
+
<div class="services-card">
|
|
6
|
+
<div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-installation.jpg 1x, /_ipx/_/images/locs-installation.jpg 2x" alt="Locs Installation" loading="lazy" class="image" src="/_ipx/_/images/locs-installation.jpg"></div>
|
|
7
|
+
<div class="eyebrow-text large">Subtitle for Locs Installation</div>
|
|
8
|
+
<h2 class="hero-text heading axis-horizontal">
|
|
9
|
+
<!--v-if--><span class="text-block-0 normal">Locs Installation</span>
|
|
10
|
+
</h2>
|
|
11
|
+
<div class="description">Short description for Locs Installation.</div><a href="/ui/services/services-section/locs-installation" data-testid="input-button-core" data-theme="default" class="input-button-core secondary mbs-24 is-link">
|
|
12
|
+
<!--v-if-->
|
|
13
|
+
<!--v-if--><span class="button-text">Enquire about Locs Installation</span><span class="btn-icon right"><span class="iconify i-mdi:arrow-right icon" aria-hidden="true"></span></span>
|
|
14
|
+
<!--v-if-->
|
|
15
|
+
</a>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="services-card">
|
|
18
|
+
<div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-retwist.jpg 1x, /_ipx/_/images/locs-retwist.jpg 2x" alt="Locs Retwist" loading="lazy" class="image" src="/_ipx/_/images/locs-retwist.jpg"></div>
|
|
19
|
+
<div class="eyebrow-text large">Subtitle for Locs Retwist</div>
|
|
20
|
+
<h2 class="hero-text heading axis-horizontal">
|
|
21
|
+
<!--v-if--><span class="text-block-0 normal">Locs Retwist</span>
|
|
22
|
+
</h2>
|
|
23
|
+
<div class="description">Short description for Locs Retwist.</div><a href="/ui/services/services-section/locs-retwist" data-testid="input-button-core" data-theme="default" class="input-button-core secondary mbs-24 is-link">
|
|
24
|
+
<!--v-if-->
|
|
25
|
+
<!--v-if--><span class="button-text">Enquire about Locs Retwist</span><span class="btn-icon right"><span class="iconify i-mdi:arrow-right icon" aria-hidden="true"></span></span>
|
|
26
|
+
<!--v-if-->
|
|
27
|
+
</a>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="services-card">
|
|
30
|
+
<div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/colour-treatment.jpg 1x, /_ipx/_/images/colour-treatment.jpg 2x" alt="Colour Treatment" loading="lazy" class="image" src="/_ipx/_/images/colour-treatment.jpg"></div>
|
|
31
|
+
<div class="eyebrow-text large">Subtitle for Colour Treatment</div>
|
|
32
|
+
<h2 class="hero-text heading axis-horizontal">
|
|
33
|
+
<!--v-if--><span class="text-block-0 normal">Colour Treatment</span>
|
|
34
|
+
</h2>
|
|
35
|
+
<div class="description">Short description for Colour Treatment.</div><a href="/ui/services/services-section/colour-treatment" data-testid="input-button-core" data-theme="default" class="input-button-core secondary mbs-24 is-link">
|
|
36
|
+
<!--v-if-->
|
|
37
|
+
<!--v-if--><span class="button-text">Enquire about Colour Treatment</span><span class="btn-icon right"><span class="iconify i-mdi:arrow-right icon" aria-hidden="true"></span></span>
|
|
38
|
+
<!--v-if-->
|
|
39
|
+
</a>
|
|
40
|
+
</div>
|
|
41
|
+
</div>"
|
|
42
|
+
`;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`ServicesSectionGrid > renders correct HTML structure 1`] = `
|
|
4
|
+
"<div class="services-grid">
|
|
5
|
+
<div class="services-section">
|
|
6
|
+
<div class="services-section__grid">
|
|
7
|
+
<div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-installation.jpg 1x, /_ipx/_/images/locs-installation.jpg 2x" alt="Locs Installation" loading="eager" class="image" src="/_ipx/_/images/locs-installation.jpg"></div>
|
|
8
|
+
<div class="info-wrapper is-summary align-center">
|
|
9
|
+
<div class="eyebrow-text large">Subtitle for Locs Installation</div>
|
|
10
|
+
<h2 class="hero-text mb-20 title axis-horizontal">
|
|
11
|
+
<!--v-if--><span class="text-block-0 normal">Locs Installation</span>
|
|
12
|
+
</h2>
|
|
13
|
+
<div class="price-duration">
|
|
14
|
+
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline decorator" aria-hidden="true"></span><span>60 mins</span></div>
|
|
15
|
+
<div class="flex-row"><span class="iconify i-mdi:currency-gbp decorator" aria-hidden="true"></span><span>£50</span></div>
|
|
16
|
+
</div>
|
|
17
|
+
<!--v-if-->
|
|
18
|
+
<!--v-if-->
|
|
19
|
+
<p class="page-body-normal">What this service is.</p>
|
|
20
|
+
<!--v-if-->
|
|
21
|
+
<!--v-if-->
|
|
22
|
+
<!--v-if-->
|
|
23
|
+
<!--v-if-->
|
|
24
|
+
<!--v-if-->
|
|
25
|
+
<!--v-if-->
|
|
26
|
+
<!--v-if-->
|
|
27
|
+
<!--v-if-->
|
|
28
|
+
<!--v-if-->
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="services-section">
|
|
33
|
+
<div class="services-section__grid">
|
|
34
|
+
<div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-retwist.jpg 1x, /_ipx/_/images/locs-retwist.jpg 2x" alt="Locs Retwist" loading="eager" class="image" src="/_ipx/_/images/locs-retwist.jpg"></div>
|
|
35
|
+
<div class="info-wrapper is-summary align-center">
|
|
36
|
+
<div class="eyebrow-text large">Subtitle for Locs Retwist</div>
|
|
37
|
+
<h2 class="hero-text mb-20 title axis-horizontal">
|
|
38
|
+
<!--v-if--><span class="text-block-0 normal">Locs Retwist</span>
|
|
39
|
+
</h2>
|
|
40
|
+
<div class="price-duration">
|
|
41
|
+
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline decorator" aria-hidden="true"></span><span>60 mins</span></div>
|
|
42
|
+
<div class="flex-row"><span class="iconify i-mdi:currency-gbp decorator" aria-hidden="true"></span><span>£50</span></div>
|
|
43
|
+
</div>
|
|
44
|
+
<!--v-if-->
|
|
45
|
+
<!--v-if-->
|
|
46
|
+
<p class="page-body-normal">What this service is.</p>
|
|
47
|
+
<!--v-if-->
|
|
48
|
+
<!--v-if-->
|
|
49
|
+
<!--v-if-->
|
|
50
|
+
<!--v-if-->
|
|
51
|
+
<!--v-if-->
|
|
52
|
+
<!--v-if-->
|
|
53
|
+
<!--v-if-->
|
|
54
|
+
<!--v-if-->
|
|
55
|
+
<!--v-if-->
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="services-section">
|
|
60
|
+
<div class="services-section__grid">
|
|
61
|
+
<div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/colour-treatment.jpg 1x, /_ipx/_/images/colour-treatment.jpg 2x" alt="Colour Treatment" loading="lazy" class="image" src="/_ipx/_/images/colour-treatment.jpg"></div>
|
|
62
|
+
<div class="info-wrapper is-summary align-center">
|
|
63
|
+
<div class="eyebrow-text large">Subtitle for Colour Treatment</div>
|
|
64
|
+
<h2 class="hero-text mb-20 title axis-horizontal">
|
|
65
|
+
<!--v-if--><span class="text-block-0 normal">Colour Treatment</span>
|
|
66
|
+
</h2>
|
|
67
|
+
<div class="price-duration">
|
|
68
|
+
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline decorator" aria-hidden="true"></span><span>60 mins</span></div>
|
|
69
|
+
<div class="flex-row"><span class="iconify i-mdi:currency-gbp decorator" aria-hidden="true"></span><span>£50</span></div>
|
|
70
|
+
</div>
|
|
71
|
+
<!--v-if-->
|
|
72
|
+
<!--v-if-->
|
|
73
|
+
<p class="page-body-normal">What this service is.</p>
|
|
74
|
+
<!--v-if-->
|
|
75
|
+
<!--v-if-->
|
|
76
|
+
<!--v-if-->
|
|
77
|
+
<!--v-if-->
|
|
78
|
+
<!--v-if-->
|
|
79
|
+
<!--v-if-->
|
|
80
|
+
<!--v-if-->
|
|
81
|
+
<!--v-if-->
|
|
82
|
+
<!--v-if-->
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>"
|
|
87
|
+
`;
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
|
|
21
21
|
<div class="price-duration">
|
|
22
22
|
<div class="flex-row">
|
|
23
|
-
<Icon name="
|
|
23
|
+
<Icon :name="durationIcon" class="decorator" />
|
|
24
24
|
<span>{{ serviceData.duration }}</span>
|
|
25
25
|
</div>
|
|
26
26
|
<div class="flex-row">
|
|
27
|
-
<Icon name="
|
|
27
|
+
<Icon :name="priceIcon" class="decorator" />
|
|
28
28
|
<span>{{ serviceData.price }}</span>
|
|
29
29
|
</div>
|
|
30
30
|
</div>
|
|
@@ -150,6 +150,8 @@ interface Props {
|
|
|
150
150
|
isSummary?: boolean;
|
|
151
151
|
summaryAlignment?: "start" | "center" | "end";
|
|
152
152
|
reverse?: boolean;
|
|
153
|
+
durationIcon?: string;
|
|
154
|
+
priceIcon?: string;
|
|
153
155
|
styleClassPassthrough?: string | string[];
|
|
154
156
|
}
|
|
155
157
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -158,6 +160,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
158
160
|
index: 0,
|
|
159
161
|
isSummary: false,
|
|
160
162
|
summaryAlignment: "center",
|
|
163
|
+
durationIcon: "mdi:clock-time-four-outline",
|
|
164
|
+
priceIcon: "mdi:currency-gbp",
|
|
161
165
|
styleClassPassthrough: () => [],
|
|
162
166
|
});
|
|
163
167
|
|
package/app/components/03.organisms/services/services-section/stories/ServicesSection.stories.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import ServicesSection from "../ServicesSection.vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
import type { Service } from "~/types/types.services";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof ServicesSection> = {
|
|
6
|
+
title: "Organisms/Services/Services Section",
|
|
7
|
+
component: ServicesSection,
|
|
8
|
+
argTypes: {
|
|
9
|
+
tag: {
|
|
10
|
+
control: { type: "select" },
|
|
11
|
+
options: ["div", "section", "article", "main"],
|
|
12
|
+
description: "HTML element rendered as the root",
|
|
13
|
+
},
|
|
14
|
+
headerTag: {
|
|
15
|
+
control: { type: "select" },
|
|
16
|
+
options: ["h1", "h2", "h3"],
|
|
17
|
+
description: "Heading element used for the service title",
|
|
18
|
+
},
|
|
19
|
+
isSummary: {
|
|
20
|
+
control: { type: "boolean" },
|
|
21
|
+
description: "Summary mode — compact view with summary-link slot",
|
|
22
|
+
},
|
|
23
|
+
summaryAlignment: {
|
|
24
|
+
control: { type: "select" },
|
|
25
|
+
options: ["start", "center", "end"],
|
|
26
|
+
description: "Vertical alignment of the info column in summary mode",
|
|
27
|
+
},
|
|
28
|
+
reverse: {
|
|
29
|
+
control: { type: "boolean" },
|
|
30
|
+
description: "Swap image and content columns",
|
|
31
|
+
},
|
|
32
|
+
durationIcon: {
|
|
33
|
+
control: { type: "text" },
|
|
34
|
+
description: "Icon name for the duration row (any Iconify icon)",
|
|
35
|
+
},
|
|
36
|
+
priceIcon: {
|
|
37
|
+
control: { type: "text" },
|
|
38
|
+
description: "Icon name for the price row (any Iconify icon)",
|
|
39
|
+
},
|
|
40
|
+
styleClassPassthrough: {
|
|
41
|
+
control: "object",
|
|
42
|
+
description: "Additional CSS classes applied to the root element",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
args: {
|
|
46
|
+
tag: "section",
|
|
47
|
+
headerTag: "h2",
|
|
48
|
+
isSummary: false,
|
|
49
|
+
summaryAlignment: "center",
|
|
50
|
+
reverse: false,
|
|
51
|
+
durationIcon: "mdi:clock-time-four-outline",
|
|
52
|
+
priceIcon: "mdi:currency-gbp",
|
|
53
|
+
styleClassPassthrough: [],
|
|
54
|
+
},
|
|
55
|
+
parameters: {
|
|
56
|
+
docs: {
|
|
57
|
+
description: {
|
|
58
|
+
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.",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export default meta;
|
|
66
|
+
type Story = StoryObj<typeof ServicesSection>;
|
|
67
|
+
|
|
68
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
const sampleService: Service = {
|
|
71
|
+
slug: "locs-installation",
|
|
72
|
+
title: "Locs Installation",
|
|
73
|
+
subtitle: "Start your loc journey",
|
|
74
|
+
price: "£120",
|
|
75
|
+
duration: "3–4 hours",
|
|
76
|
+
image: "https://picsum.photos/seed/locs/800/600",
|
|
77
|
+
shortDescription: "Professional loc installation tailored to your hair type.",
|
|
78
|
+
longDescription:
|
|
79
|
+
"Our loc installation service covers everything from initial consultation through to the finished style. We work with all hair types and lengths to create beautiful, long-lasting locs.",
|
|
80
|
+
heroHeading: [
|
|
81
|
+
{ text: "Why choose ", styleClass: "normal" },
|
|
82
|
+
{ text: "locs?", styleClass: "accent" },
|
|
83
|
+
],
|
|
84
|
+
whatIsIt:
|
|
85
|
+
"Locs are a protective hairstyle formed by allowing hair to naturally matt and coil over time. They require minimal daily maintenance and are suitable for all hair types.",
|
|
86
|
+
process: [
|
|
87
|
+
"Consultation to assess hair type and length",
|
|
88
|
+
"Scalp preparation and cleanse",
|
|
89
|
+
"Sectioning and palm-rolling",
|
|
90
|
+
"Drying and finishing",
|
|
91
|
+
],
|
|
92
|
+
idealFor: [
|
|
93
|
+
"Anyone looking for a low-maintenance protective style",
|
|
94
|
+
"Those with natural hair wanting a permanent style",
|
|
95
|
+
"People seeking a versatile long-term hairstyle",
|
|
96
|
+
],
|
|
97
|
+
maintenance:
|
|
98
|
+
"Re-twist every 4–6 weeks to maintain neat roots. Keep scalp moisturised and protect hair at night with a satin bonnet.",
|
|
99
|
+
faqs: [
|
|
100
|
+
{
|
|
101
|
+
question: "How long do locs take to mature?",
|
|
102
|
+
answer: "Locs typically take 12–18 months to fully mature depending on hair type and texture.",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
question: "Can I wash my locs?",
|
|
106
|
+
answer: "Yes — regular washing is encouraged. We recommend a residue-free shampoo every 1–2 weeks.",
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
seoTitle: "Locs Installation | Professional Hair Locs Service",
|
|
110
|
+
seoDescription:
|
|
111
|
+
"Professional locs installation service. Book a consultation today and start your loc journey with an experienced stylist.",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// ─── Stories ──────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
export const FullMode: Story = {
|
|
117
|
+
name: "Full Mode",
|
|
118
|
+
render: (args) => ({
|
|
119
|
+
components: { ServicesSection },
|
|
120
|
+
setup() {
|
|
121
|
+
return { args, sampleService };
|
|
122
|
+
},
|
|
123
|
+
template: `
|
|
124
|
+
<ServicesSection v-bind="args" :service-data="sampleService">
|
|
125
|
+
<template #cta>
|
|
126
|
+
<button style="margin-top:1.6rem;padding:1.2rem 2.4rem;background:#333;color:#fff;border:none;border-radius:0.4rem;cursor:pointer;">
|
|
127
|
+
Book Now
|
|
128
|
+
</button>
|
|
129
|
+
</template>
|
|
130
|
+
</ServicesSection>
|
|
131
|
+
`,
|
|
132
|
+
}),
|
|
133
|
+
parameters: {
|
|
134
|
+
docs: {
|
|
135
|
+
description: {
|
|
136
|
+
story:
|
|
137
|
+
"Full mode (isSummary: false) — renders all content including process, ideal-for, FAQs, and the CTA GlassPanel.",
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const SummaryMode: Story = {
|
|
144
|
+
name: "Summary Mode",
|
|
145
|
+
args: {
|
|
146
|
+
isSummary: true,
|
|
147
|
+
},
|
|
148
|
+
render: (args) => ({
|
|
149
|
+
components: { ServicesSection },
|
|
150
|
+
setup() {
|
|
151
|
+
return { args, sampleService };
|
|
152
|
+
},
|
|
153
|
+
template: `
|
|
154
|
+
<ServicesSection v-bind="args" :service-data="sampleService">
|
|
155
|
+
<template #summary-link="{ serviceData }">
|
|
156
|
+
<a :href="'/services/' + serviceData.slug" style="color:inherit;">
|
|
157
|
+
More about {{ serviceData.title }} →
|
|
158
|
+
</a>
|
|
159
|
+
</template>
|
|
160
|
+
</ServicesSection>
|
|
161
|
+
`,
|
|
162
|
+
}),
|
|
163
|
+
parameters: {
|
|
164
|
+
docs: {
|
|
165
|
+
description: {
|
|
166
|
+
story: "Summary mode — compact view with eyebrow, title, price/duration, whatIsIt, and summary-link slot.",
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const Reversed: Story = {
|
|
173
|
+
name: "Reversed Layout",
|
|
174
|
+
args: {
|
|
175
|
+
reverse: true,
|
|
176
|
+
},
|
|
177
|
+
render: (args) => ({
|
|
178
|
+
components: { ServicesSection },
|
|
179
|
+
setup() {
|
|
180
|
+
return { args, sampleService };
|
|
181
|
+
},
|
|
182
|
+
template: `<ServicesSection v-bind="args" :service-data="sampleService" />`,
|
|
183
|
+
}),
|
|
184
|
+
parameters: {
|
|
185
|
+
docs: {
|
|
186
|
+
description: {
|
|
187
|
+
story: "Image on the right, content on the left — use on alternating items in a list.",
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const CustomIcons: Story = {
|
|
194
|
+
name: "Custom Icons",
|
|
195
|
+
args: {
|
|
196
|
+
durationIcon: "mdi:timer-outline",
|
|
197
|
+
priceIcon: "mdi:currency-eur",
|
|
198
|
+
},
|
|
199
|
+
render: (args) => ({
|
|
200
|
+
components: { ServicesSection },
|
|
201
|
+
setup() {
|
|
202
|
+
return { args, sampleService };
|
|
203
|
+
},
|
|
204
|
+
template: `<ServicesSection v-bind="args" :service-data="sampleService" />`,
|
|
205
|
+
}),
|
|
206
|
+
parameters: {
|
|
207
|
+
docs: {
|
|
208
|
+
description: {
|
|
209
|
+
story:
|
|
210
|
+
"Custom icon names passed via durationIcon and priceIcon props — useful when the consuming app uses a different currency or wants a different clock style.",
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
};
|