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.
- package/.claude/hooks/check-component-update.sh +30 -1
- package/.claude/skills/components/breadcrumb.md +49 -0
- package/.claude/skills/components/content-docs.md +1 -1
- package/.claude/skills/components/service-detail.md +156 -0
- package/.claude/skills/components/services-card-grid.md +9 -9
- package/.claude/skills/components/services-card.md +82 -30
- package/.claude/skills/index.md +3 -1
- package/.claude/skills/qa-panel.md +2 -2
- package/.vscode/srcdev-component-breadcrumb.code-snippets +14 -0
- package/.vscode/srcdev-component-service-detail.code-snippets +49 -0
- package/.vscode/srcdev-component-services-card.code-snippets +108 -0
- package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +94 -0
- package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +32 -0
- package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +87 -0
- package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +87 -0
- package/app/components/01.atoms/navigation/breadcrumb/tests/__snapshots__/Breadcrumb.spec.ts.snap +13 -0
- package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +181 -0
- package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +480 -0
- package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +282 -0
- package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +270 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +99 -0
- package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +129 -0
- package/app/components/03.organisms/services/services-card/ServicesCard.vue +153 -39
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +65 -0
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +82 -0
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +13 -5
- package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +3 -7
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +126 -2
- package/app/components/03.organisms/services/services-grids/stories/ServicesSectionGrid.stories.ts +6 -0
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +51 -27
- package/app/types/components/breadcrumb.ts +4 -0
- package/app/types/components/index.ts +1 -0
- package/package.json +2 -2
- package/app/pages/auto-grid.vue +0 -328
- package/app/pages/banner-video.vue +0 -238
- package/app/pages/forms/examples/buttons/index.vue +0 -259
- package/app/pages/forms/examples/material/checkbox-radio-panels.vue +0 -244
- package/app/pages/forms/examples/material/cssbattle.vue +0 -60
- package/app/pages/forms/examples/material/text-fields.vue +0 -910
- package/app/pages/grid-stack.vue +0 -252
- package/app/pages/index.vue +0 -127
- package/app/pages/page-hero-highlights.vue +0 -204
- package/app/pages/samaritan.vue +0 -327
- package/app/pages/typography/hero-text.vue +0 -97
- package/app/pages/typography/index.vue +0 -39
- package/app/pages/typography/page-body.vue +0 -171
- package/app/pages/typography/page-heading.vue +0 -76
- package/app/pages/typography/page-link.vue +0 -103
- package/app/pages/ui/accordian.vue +0 -211
- package/app/pages/ui/anchor-scroll-tab-navigation.vue +0 -191
- package/app/pages/ui/anchor-scroll.vue +0 -367
- package/app/pages/ui/animated-svg-text.vue +0 -85
- package/app/pages/ui/block-decorators.vue +0 -142
- package/app/pages/ui/card-core.vue +0 -82
- package/app/pages/ui/carousel-basic.vue +0 -292
- package/app/pages/ui/carousel-flip.vue +0 -449
- package/app/pages/ui/carousel-infinite.vue +0 -260
- package/app/pages/ui/clipped-panels.vue +0 -101
- package/app/pages/ui/contact-section.vue +0 -323
- package/app/pages/ui/container-glow.vue +0 -106
- package/app/pages/ui/content-width.vue +0 -126
- package/app/pages/ui/deep-expanding-menu.vue +0 -138
- package/app/pages/ui/display-avatar.vue +0 -253
- package/app/pages/ui/display-banner.vue +0 -78
- package/app/pages/ui/display-chip.vue +0 -367
- package/app/pages/ui/display-details.vue +0 -171
- package/app/pages/ui/display-dialog.vue +0 -344
- package/app/pages/ui/display-pill.vue +0 -396
- package/app/pages/ui/display-prompt.vue +0 -284
- package/app/pages/ui/display-toast.vue +0 -311
- package/app/pages/ui/expanding-panel.vue +0 -326
- package/app/pages/ui/glowing-border.vue +0 -111
- package/app/pages/ui/layout-content-docs.vue +0 -72
- package/app/pages/ui/layout-grid-a.vue +0 -96
- package/app/pages/ui/layout-grid-b.vue +0 -132
- package/app/pages/ui/magnetic-navigation.vue +0 -50
- package/app/pages/ui/marquee-scroller.vue +0 -99
- package/app/pages/ui/mask-element.vue +0 -104
- package/app/pages/ui/masonry-columns.vue +0 -55
- package/app/pages/ui/masonry-grid-ordered-experiment.vue +0 -79
- package/app/pages/ui/masonry-grid-ordered.vue +0 -56
- package/app/pages/ui/masonry-grid-sorted.vue +0 -55
- package/app/pages/ui/masonry-grid.vue +0 -55
- package/app/pages/ui/navigation/navigation-horizontal.vue +0 -484
- package/app/pages/ui/navigation/site-navigation/about.vue +0 -36
- package/app/pages/ui/navigation/site-navigation/contact.vue +0 -39
- package/app/pages/ui/navigation/site-navigation/index.vue +0 -38
- package/app/pages/ui/navigation/site-navigation/portfolio.vue +0 -43
- package/app/pages/ui/navigation/site-navigation/services.vue +0 -42
- package/app/pages/ui/navigation/tab-navigation.vue +0 -493
- package/app/pages/ui/page-row.vue +0 -127
- package/app/pages/ui/price-list.vue +0 -65
- package/app/pages/ui/profile-section.vue +0 -142
- package/app/pages/ui/qr-code/[componentName].vue +0 -218
- package/app/pages/ui/rotating-carousel.vue +0 -154
- package/app/pages/ui/scroll-reveal-image.vue +0 -483
- package/app/pages/ui/section-parallax.vue +0 -54
- package/app/pages/ui/services/colour-finder.vue +0 -43
- package/app/pages/ui/services/services-cards.vue +0 -57
- package/app/pages/ui/services/services-section/[slug].vue +0 -61
- package/app/pages/ui/services/services-sections-compact.vue +0 -64
- package/app/pages/ui/settings.vue +0 -54
- package/app/pages/ui/simple-grid.vue +0 -55
- package/app/pages/ui/slider-gallery.vue +0 -261
- package/app/pages/ui/tabs-y.vue +0 -107
- package/app/pages/ui/tabs.vue +0 -189
- package/app/pages/ui/tooltips.vue +0 -104
- package/app/pages/ui/wipe-away-vertical.vue +0 -129
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# PostToolUse hook (Write|Edit) for app/components/*.vue files. Advisory nudges only
|
|
3
3
|
# (missing != wrong): skill doc, story, test, CONSUMER-STYLING.md, .vscode snippet,
|
|
4
|
-
# legacy tier-folder location, options-style defineProps
|
|
4
|
+
# legacy tier-folder location, options-style defineProps, single-use private CSS tokens,
|
|
5
|
+
# leftover app/pages/ demo pages (this library is Storybook-only, see Claude.md).
|
|
5
6
|
|
|
6
7
|
root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
7
8
|
|
|
@@ -63,4 +64,32 @@ if grep -qE 'defineProps\(\s*\{' "$f" && ! grep -q 'defineProps<' "$f"; then
|
|
|
63
64
|
msg="$msg This component uses the options-style defineProps({...}) pattern, an outdated-pattern signal; migrate to interface Props + withDefaults(defineProps<Props>(), {...}) per the Props Pattern in Claude.md."
|
|
64
65
|
fi
|
|
65
66
|
|
|
67
|
+
# Private --_x CSS tokens that appear only twice in the file (once declared, once consumed) are
|
|
68
|
+
# likely pure indirection with no reuse/computation/state to justify them — see the "Public
|
|
69
|
+
# token pattern" rule in Claude.md's Styling Methodology and pitfall #20 (ServicesCard).
|
|
70
|
+
private_vars=$(grep -oE -- '--_[a-zA-Z0-9-]+' "$f" | sort -u)
|
|
71
|
+
if [[ -n "$private_vars" ]]; then
|
|
72
|
+
single_use=""
|
|
73
|
+
while IFS= read -r var; do
|
|
74
|
+
[[ -z "$var" ]] && continue
|
|
75
|
+
count=$(grep -oE -- "\\${var}\\b" "$f" | wc -l | tr -d ' ')
|
|
76
|
+
if [[ "$count" -le 2 ]]; then
|
|
77
|
+
single_use="$single_use $var"
|
|
78
|
+
fi
|
|
79
|
+
done <<< "$private_vars"
|
|
80
|
+
if [[ -n "$single_use" ]]; then
|
|
81
|
+
msg="$msg This component declares private CSS token(s) ($(echo "$single_use" | xargs)) that appear to be used only once — check whether each is genuinely reused across multiple declarations/selectors, composed from a v-bind() value, or swapped by a state (:hover, data-theme). If not, inline the public var(--token, default) directly at its point of use instead, per the Public token pattern rule in Claude.md's Styling Methodology (see pitfall #20 for the ServicesCard precedent), and update CONSUMER-STYLING.md/the skill doc if the token's public name or default changes."
|
|
82
|
+
fi
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# This library ships no demo pages — Storybook stories are the only demo surface (see Claude.md's
|
|
86
|
+
# "Storybook is the only demo surface"). Flag any app/pages/ file that mentions this component,
|
|
87
|
+
# whether a pre-existing leftover or a newly (re)added one.
|
|
88
|
+
if [[ -d "$root/app/pages" ]]; then
|
|
89
|
+
demo_pages=$(grep -rlE "\\b${name}\\b" "$root/app/pages" 2>/dev/null | xargs)
|
|
90
|
+
if [[ -n "$demo_pages" ]]; then
|
|
91
|
+
msg="$msg Found file(s) under app/pages/ referencing $name ($demo_pages); this library has no demo pages (Storybook only) — delete them and move any demo-worthy content into the Storybook story instead."
|
|
92
|
+
fi
|
|
93
|
+
fi
|
|
94
|
+
|
|
66
95
|
jq -n --arg msg "$msg" '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Breadcrumb Component
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`Breadcrumb` renders a `<nav aria-label="Breadcrumb"><ol>...</ol></nav>` trail from an `items`
|
|
6
|
+
array. Routing is delegated to the consumer: an item with a `to` renders as a `NuxtLink`; an item
|
|
7
|
+
without one renders as plain text marked `aria-current="page"` (typically the last item, the
|
|
8
|
+
current page).
|
|
9
|
+
|
|
10
|
+
## Props
|
|
11
|
+
|
|
12
|
+
| Prop | Type | Default | Required |
|
|
13
|
+
|------|------|---------|----------|
|
|
14
|
+
| `items` | `BreadcrumbItem[]` | — | **yes** |
|
|
15
|
+
| `separator` | `string` | `"/"` | no |
|
|
16
|
+
| `styleClassPassthrough` | `string \| string[]` | `[]` | no |
|
|
17
|
+
|
|
18
|
+
`BreadcrumbItem` is exported from `~/types/components/breadcrumb` (and re-exported from
|
|
19
|
+
`~/types/components`):
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
interface BreadcrumbItem {
|
|
23
|
+
label: string;
|
|
24
|
+
to?: string;
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```vue
|
|
31
|
+
<Breadcrumb
|
|
32
|
+
:items="[
|
|
33
|
+
{ label: 'Services', to: '/services' },
|
|
34
|
+
{ label: 'Balayage' },
|
|
35
|
+
]"
|
|
36
|
+
/>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Omit `to` on whichever item represents the current page — usually the last one. Every item with
|
|
40
|
+
a `to` is a real link; the library never guesses which item is "current" from its position.
|
|
41
|
+
|
|
42
|
+
## Notes
|
|
43
|
+
|
|
44
|
+
- Component is auto-imported in Nuxt — no import needed.
|
|
45
|
+
- No default content/slot — it's data-driven purely from `items`.
|
|
46
|
+
- `ServiceDetail` uses this component internally for its hero banner breadcrumb, overriding
|
|
47
|
+
`--breadcrumb-colour` to `white` so it reads over the hero image. See
|
|
48
|
+
[service-detail.md](service-detail.md).
|
|
49
|
+
- See `CONSUMER-STYLING.md` in the component's own folder for the full `--breadcrumb-*` token API.
|
|
@@ -178,4 +178,4 @@ All `--content-docs-*` tokens can be overridden at global, page, or instance sco
|
|
|
178
178
|
- File: `app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue`
|
|
179
179
|
- Types: `app/types/components/content-docs.d.ts` (`DocsNavItem`)
|
|
180
180
|
- Tests: `app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts`
|
|
181
|
-
-
|
|
181
|
+
- Storybook: `app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts`
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# ServiceDetail Component
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`ServiceDetail` renders a single service as a full, standalone detail page: a full-bleed hero
|
|
6
|
+
banner (breadcrumb, eyebrow, title, price/duration pills over the service image), a two-column
|
|
7
|
+
body (long-form content in the main column, a sticky sidebar booking card + "You may also like"
|
|
8
|
+
related-services list), and a closing full-width CTA banner. The process steps reuse
|
|
9
|
+
[StepperList](stepper-list.md) (numbered circle indicators with connectors) rather than bespoke
|
|
10
|
+
markup — Ideal For and FAQs are still bespoke (2-column card grid, plain Q&A list) since neither
|
|
11
|
+
matches an existing molecule as directly.
|
|
12
|
+
|
|
13
|
+
It is a different shape from [ServicesSection](services-section.md): `ServicesSection` renders
|
|
14
|
+
image-beside-content (used both as a compact summary card in a list and, in full mode, as a
|
|
15
|
+
same-page detail block); `ServiceDetail` is meant to *be* the whole page for one service —
|
|
16
|
+
`headerTag` defaults to `"h1"` accordingly, and there is no summary/compact mode. There is no
|
|
17
|
+
`isSummary` prop and no `v-if` gating of content — everything the component renders is always
|
|
18
|
+
rendered.
|
|
19
|
+
|
|
20
|
+
## Props
|
|
21
|
+
|
|
22
|
+
| Prop | Type | Default | Required |
|
|
23
|
+
|------|------|---------|----------|
|
|
24
|
+
| `serviceData` | `Service` | — | **yes** |
|
|
25
|
+
| `tag` | `"div" \| "section" \| "article" \| "main"` | `"div"` | no |
|
|
26
|
+
| `headerTag` | `"h1" \| "h2" \| "h3"` | `"h1"` | no |
|
|
27
|
+
| `subheadingTag` | `"h2" \| "h3"` | `"h2"` | no |
|
|
28
|
+
| `breadcrumbItems` | `BreadcrumbItem[]` | auto-built from `serviceData` | no |
|
|
29
|
+
| `processHeading` | `string` | `"The Process"` | no |
|
|
30
|
+
| `idealForHeading` | `string` | `"Ideal For"` | no |
|
|
31
|
+
| `maintenanceHeading` | `string` | `"Aftercare & Maintenance"` | no |
|
|
32
|
+
| `faqsHeading` | `string` | `"Frequently Asked Questions"` | no |
|
|
33
|
+
| `bookingHeading` | `string` | `"Book This Service"` | no |
|
|
34
|
+
| `priceLabel` | `string` | `"Price"` | no |
|
|
35
|
+
| `durationLabel` | `string` | `"Duration"` | no |
|
|
36
|
+
| `locationLabel` | `string` | `"Location"` | no |
|
|
37
|
+
| `location` | `string` | `undefined` | no |
|
|
38
|
+
| `relatedServicesHeading` | `string` | `"You May Also Like"` | no |
|
|
39
|
+
| `relatedServices` | `Service[]` | `[]` | no |
|
|
40
|
+
| `finalCtaHeading` | `string` | `"Ready to book your appointment?"` | no |
|
|
41
|
+
| `finalCtaBody` | `string` | `"Get in touch to book your appointment."` | no |
|
|
42
|
+
| `imageLoading` | `"eager" \| "lazy"` | `"eager"` | no |
|
|
43
|
+
| `imageFetchPriority` | `"high" \| "auto" \| "low"` | `"high"` | no |
|
|
44
|
+
| `styleClassPassthrough` | `string \| string[]` | `[]` | no |
|
|
45
|
+
|
|
46
|
+
`location` and `relatedServices` are **props on ServiceDetail, not fields on the shared `Service`
|
|
47
|
+
type** — neither is universal enough to belong on `~/types/types.services`. The location row and
|
|
48
|
+
the whole "You may also like" block simply don't render when they're omitted.
|
|
49
|
+
|
|
50
|
+
`imageLoading`/`imageFetchPriority` default to eager/high because a `ServiceDetail` page normally
|
|
51
|
+
has exactly one hero image and it's always the LCP candidate — unlike `ServicesSection`, which is
|
|
52
|
+
often looped and needs the `index` prop to lazy-load everything past the first two instances.
|
|
53
|
+
|
|
54
|
+
### `headerTag` vs `subheadingTag`
|
|
55
|
+
|
|
56
|
+
Unlike `ServicesSection` (one `headerTag` prop applied to every heading, title included),
|
|
57
|
+
`ServiceDetail` splits these: `headerTag` controls only the hero `<h1>` title; `subheadingTag`
|
|
58
|
+
controls every other heading (What Is It, Process, Ideal For, Aftercare, FAQs, final CTA heading).
|
|
59
|
+
This keeps the page's heading hierarchy correct when `ServiceDetail` is the page's own `<h1>`.
|
|
60
|
+
|
|
61
|
+
The FAQ question itself is a hardcoded `<h3>` — if you set `subheadingTag="h3"`, FAQ questions
|
|
62
|
+
end up at the same level as the FAQs section heading rather than one below it.
|
|
63
|
+
|
|
64
|
+
### `breadcrumbItems`
|
|
65
|
+
|
|
66
|
+
Defaults to a plain, non-linked two-item trail built from `serviceData.category` and
|
|
67
|
+
`serviceData.title` when omitted. Pass real routes once your app's URL structure is known:
|
|
68
|
+
|
|
69
|
+
```vue
|
|
70
|
+
<ServiceDetail
|
|
71
|
+
:service-data="service"
|
|
72
|
+
:breadcrumb-items="[
|
|
73
|
+
{ label: 'Services', to: '/services' },
|
|
74
|
+
{ label: service.title },
|
|
75
|
+
]"
|
|
76
|
+
/>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Slots
|
|
80
|
+
|
|
81
|
+
| Slot | Slot props | Purpose |
|
|
82
|
+
|------|-----------|---------|
|
|
83
|
+
| `book-cta` | `{ serviceData: Service }` | Booking button inside the sidebar's booking card |
|
|
84
|
+
| `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
|
+
| `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
|
+
| `final-cta` | `{ serviceData: Service }` | Button in the closing full-width CTA banner |
|
|
87
|
+
|
|
88
|
+
All routing decisions (breadcrumb links, book-cta href, related-service links, final-cta href)
|
|
89
|
+
are delegated to the consumer, matching `ServicesSection`'s slot-based routing pattern.
|
|
90
|
+
|
|
91
|
+
## Usage
|
|
92
|
+
|
|
93
|
+
```vue
|
|
94
|
+
<ServiceDetail
|
|
95
|
+
:service-data="service"
|
|
96
|
+
location="Mobile — across Bath"
|
|
97
|
+
:related-services="relatedServices"
|
|
98
|
+
>
|
|
99
|
+
<template #book-cta>
|
|
100
|
+
<InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" />
|
|
101
|
+
</template>
|
|
102
|
+
<template #sidebar-note>
|
|
103
|
+
A patch test is required at least 48 hours before any colour treatment.
|
|
104
|
+
</template>
|
|
105
|
+
<template #related-service="{ service: related }">
|
|
106
|
+
<NuxtLink :to="`/services/${related.slug}`">{{ related.title }} — {{ related.price }}</NuxtLink>
|
|
107
|
+
</template>
|
|
108
|
+
<template #final-cta>
|
|
109
|
+
<InputButtonCore tag="a" href="/contact" button-text="Book now" variant="secondary" />
|
|
110
|
+
</template>
|
|
111
|
+
</ServiceDetail>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Local style override scaffold
|
|
115
|
+
|
|
116
|
+
```vue
|
|
117
|
+
<ServiceDetail :style-class-passthrough="['my-service-page']" :service-data="service">
|
|
118
|
+
...
|
|
119
|
+
</ServiceDetail>
|
|
120
|
+
|
|
121
|
+
<style>
|
|
122
|
+
/* ─── ServiceDetail local overrides ──────────────────────────────
|
|
123
|
+
Colours, borders, geometry only — do not override behaviour.
|
|
124
|
+
Delete this block if no overrides are needed.
|
|
125
|
+
─────────────────────────────────────────────────────────────────── */
|
|
126
|
+
.service-detail {
|
|
127
|
+
&.my-service-page {
|
|
128
|
+
--service-detail-hero-border-radius: 1.2rem;
|
|
129
|
+
--service-detail-process-index-colour: var(--brand-accent);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
</style>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
See [component-local-style-override.md](../component-local-style-override.md) for the general
|
|
136
|
+
pattern, and `CONSUMER-STYLING.md` in the component's own folder for the full token API.
|
|
137
|
+
|
|
138
|
+
## Notes
|
|
139
|
+
|
|
140
|
+
- Component is auto-imported in Nuxt — no import needed.
|
|
141
|
+
- The `Service` type is imported from `~/types/types.services`; `BreadcrumbItem` from
|
|
142
|
+
`~/types/components/breadcrumb`.
|
|
143
|
+
- No `isSummary` mode and no content gating — this component always renders everything it's
|
|
144
|
+
given. If you need a compact summary card for a services listing page, use
|
|
145
|
+
[ServicesSection](services-section.md) in summary mode or [ServicesCard](services-card.md)
|
|
146
|
+
instead, and reserve `ServiceDetail` for the individual `/services/[slug]` page.
|
|
147
|
+
- Uses [Breadcrumb](breadcrumb.md) internally for the hero banner breadcrumb.
|
|
148
|
+
- The two-/three-column layout breakpoints (body columns, ideal-for grid, final CTA row) are
|
|
149
|
+
`@container` queries against the component's own width, not the viewport.
|
|
150
|
+
- Internal element classes are BEM-namespaced under `service-detail__*` (`service-detail__hero`,
|
|
151
|
+
`service-detail__process`, `service-detail__sidebar`, `service-detail__related`, etc.) — see
|
|
152
|
+
`CONSUMER-STYLING.md` for the full list alongside their tokens.
|
|
153
|
+
- The section gets `aria-labelledby` automatically when `tag` is `"section"`, `"article"`, or
|
|
154
|
+
`"aside"`, pointing at the id `ServiceDetail` binds to its own title `HeroText` internally —
|
|
155
|
+
same mechanism as `ServicesSection`. `tag="main"` renders a `<main>` element but is never
|
|
156
|
+
auto-labelled.
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
| `tag` | `"div" \| "section" \| "main"` | `"div"` | no |
|
|
13
13
|
| `eyebrowConfig` | `EyebrowConfig` | `{}` | no |
|
|
14
14
|
| `heroConfig` | `HeroConfig` | `{}` | no |
|
|
15
|
-
| `hrefBase` | `string` | `"/
|
|
15
|
+
| `hrefBase` | `string` | `"/services/"` | no |
|
|
16
16
|
| `buttonTextPrefix` | `string` | `"Enquire about"` | no |
|
|
17
17
|
| `styleClassPassthrough` | `string \| string[]` | `[]` | no |
|
|
18
18
|
|
|
@@ -38,8 +38,8 @@ Set on `.services-card-grid` (or scoped to a page class):
|
|
|
38
38
|
|
|
39
39
|
| Token | Default | Controls |
|
|
40
40
|
| ---------------------- | --------- | ------------------------------------- |
|
|
41
|
-
| `--
|
|
42
|
-
| `--
|
|
41
|
+
| `--services-card-grid-gap` | `4rem` | Gap between grid cells |
|
|
42
|
+
| `--services-card-grid-column-min-width` | `250px` | Minimum column width before wrapping |
|
|
43
43
|
|
|
44
44
|
## Consumer page boilerplate
|
|
45
45
|
|
|
@@ -87,13 +87,13 @@ if (servicesData.value.length === 0) {
|
|
|
87
87
|
.page-services {
|
|
88
88
|
/* Page-level CSS token overrides — delete any you don't need */
|
|
89
89
|
.services-card-grid {
|
|
90
|
-
--
|
|
91
|
-
--
|
|
90
|
+
--services-card-grid-gap: 4rem;
|
|
91
|
+
--services-card-grid-column-min-width: 250px;
|
|
92
92
|
|
|
93
93
|
.services-card {
|
|
94
|
-
--
|
|
95
|
-
--
|
|
96
|
-
--
|
|
94
|
+
--eyebrow-text-padding-block: 0.8rem 0;
|
|
95
|
+
--hero-text-padding-block: 2rem 1rem;
|
|
96
|
+
--description-text-colour: var(--colour-text-secondary);
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
}
|
|
@@ -104,6 +104,6 @@ if (servicesData.value.length === 0) {
|
|
|
104
104
|
|
|
105
105
|
- Component is auto-imported in Nuxt — no import needed.
|
|
106
106
|
- The `Service` type is imported from `~/types/types.services`.
|
|
107
|
-
- Uses `repeat(auto-fit, minmax(var(--
|
|
107
|
+
- Uses `repeat(auto-fit, minmax(var(--services-card-grid-column-min-width, 250px), 1fr))` — columns grow to fill available space and wrap when below the minimum width.
|
|
108
108
|
- The `#actions` slot template is passed down into each `ServicesCard`; `serviceData` is the scoped prop for the current iteration item.
|
|
109
109
|
- Data fetching is the page's responsibility — pass an empty array as fallback while loading (`servicesData ?? []`).
|
|
@@ -2,51 +2,69 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
`ServicesCard` renders a single service as a portrait card: image, subtitle (eyebrow), title, short description, and an `actions` slot for any CTA content. The component owns the layout and data display; all routing and button decisions are delegated to the consumer via the slot.
|
|
5
|
+
`ServicesCard` renders a single service as a portrait card: image, subtitle (eyebrow), title, short description, an optional duration/price meta row, and an `actions` slot for any CTA content. The component owns the layout and data display; all routing and button decisions are delegated to the consumer via the slot.
|
|
6
6
|
|
|
7
7
|
## Props
|
|
8
8
|
|
|
9
|
-
| Prop | Type | Default
|
|
10
|
-
| ----------------------- |
|
|
11
|
-
| `serviceData` | `Service`
|
|
12
|
-
| `tag` | `"div" \| "section" \| "article"` | `"div"`
|
|
13
|
-
| `
|
|
14
|
-
| `
|
|
15
|
-
| `
|
|
9
|
+
| Prop | Type | Default | Required |
|
|
10
|
+
| ----------------------- | ---------------------------------- | ----------- | -------- |
|
|
11
|
+
| `serviceData` | `Service` | — | **yes** |
|
|
12
|
+
| `tag` | `"div" \| "section" \| "article"` | `"div"` | no |
|
|
13
|
+
| `href` | `string` | `undefined` | no |
|
|
14
|
+
| `external` | `boolean` | `false` | no |
|
|
15
|
+
| `eyebrowConfig` | `EyebrowConfig` | `{}` | no |
|
|
16
|
+
| `heroConfig` | `HeroConfig` | `{}` | no |
|
|
17
|
+
| `durationText` | `string` | `undefined` | no |
|
|
18
|
+
| `priceText` | `string` | `undefined` | no |
|
|
19
|
+
| `styleClassPassthrough` | `string \| string[]` | `[]` | no |
|
|
20
|
+
|
|
21
|
+
### Meta row (`durationText` / `priceText`)
|
|
22
|
+
|
|
23
|
+
A row below the description shows duration (left) and price (right), separated by a top divider. It defaults to `serviceData.duration`/`serviceData.price` — `durationText`/`priceText` props override that text, and the `duration`/`price` slots (see Slots below) fully replace the content (e.g. to add an icon). The row is omitted entirely when there's no duration/price text and no slot content on either side.
|
|
24
|
+
|
|
25
|
+
### Whole-card clickable (`href`)
|
|
26
|
+
|
|
27
|
+
When `href` is set **and no `actions` slot content is provided**, the root element switches from `tag` to a link (`NuxtLink` for an internal href starting with `/`, a plain `a` otherwise) and the whole card becomes clickable — matching `InputButtonCore`'s link-resolution pattern. `external` forces a plain `a` tag even for an internal-looking href (e.g. a Nitro server route like `/api/auth/github`).
|
|
28
|
+
|
|
29
|
+
If an `actions` slot is provided, the card stays as a static `tag` element (no href rendered) even when `href` is set — this avoids nesting another interactive element (e.g. a button/link in `actions`) inside the card's own anchor, which would be invalid HTML. Use the `href` mode for a plain "whole card links out" card with no separate CTA, and the `actions`-slot mode (see Basic usage below) when the card needs its own button/link.
|
|
30
|
+
|
|
31
|
+
`is-clickable` is added to the root class list when whole-card-clickable mode is active, giving `cursor: pointer` and removing default link colour/underline. It's also what gates the border/outline hover/focus interaction-state tokens — see "Interaction states (whole-card-clickable only)" in `CONSUMER-STYLING.md`; they're a no-op when the card isn't clickable, since only `.is-clickable` has a `:hover`/`:focus-visible` rule.
|
|
16
32
|
|
|
17
33
|
### EyebrowConfig
|
|
18
34
|
|
|
19
|
-
| Key | Type | Default
|
|
20
|
-
| ---------- |
|
|
21
|
-
| `tag` | `"p" \| "div" \| "span"`
|
|
22
|
-
| `fontSize` | `"large" \| "medium" \| "small"`
|
|
35
|
+
| Key | Type | Default |
|
|
36
|
+
| ---------- | ---------------------------------- | --------- |
|
|
37
|
+
| `tag` | `"p" \| "div" \| "span"` | `"div"` |
|
|
38
|
+
| `fontSize` | `"large" \| "medium" \| "small"` | `"large"` |
|
|
23
39
|
|
|
24
40
|
### HeroConfig
|
|
25
41
|
|
|
26
|
-
| Key | Type
|
|
27
|
-
| ---------- |
|
|
28
|
-
| `tag` | `"h1" \| "h2" \| "h3" \| "h4" \| "h5" \| "h6"`
|
|
42
|
+
| Key | Type | Default |
|
|
43
|
+
| ---------- | ----------------------------------------------------------------- | ----------- |
|
|
44
|
+
| `tag` | `"h1" \| "h2" \| "h3" \| "h4" \| "h5" \| "h6"` | `"h2"` |
|
|
29
45
|
| `fontSize` | `"display" \| "title" \| "heading" \| "subheading" \| "label"` | `"heading"` |
|
|
30
46
|
|
|
31
47
|
Config objects are partial — only specify the keys you want to override. Unset keys fall back to the defaults shown above.
|
|
32
48
|
|
|
33
49
|
## Slots
|
|
34
50
|
|
|
35
|
-
| Slot
|
|
36
|
-
|
|
|
37
|
-
| `
|
|
51
|
+
| Slot | Slot props | Purpose |
|
|
52
|
+
| ---------- | --------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
53
|
+
| `duration` | `{ serviceData: Service }` | Replaces the duration (left) side of the meta row; defaults to `durationText`/`serviceData.duration` |
|
|
54
|
+
| `price` | `{ serviceData: Service }` | Replaces the price (right) side of the meta row; defaults to `priceText`/`serviceData.price` |
|
|
55
|
+
| `actions` | `{ serviceData: Service }` | CTA area below the meta row — buttons, links, or any action content |
|
|
38
56
|
|
|
39
|
-
|
|
57
|
+
All three slots receive `serviceData` as a scoped prop so the consumer can construct routes, labels, or formatted text from the service data without additional props.
|
|
40
58
|
|
|
41
59
|
## CSS custom properties
|
|
42
60
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
61
|
+
Prefer the `--services-card-*`/`--image-wrapper-*`/`--details-wrapper-*`/`--description-*`/`--meta-*`/`--footer-*`
|
|
62
|
+
CSS custom properties documented in `CONSUMER-STYLING.md` (in the component's own folder) over
|
|
63
|
+
raw class overrides — it lists every token, its default, which ones have a global `:root`
|
|
64
|
+
fallback vs. page-scoped-only, and worked examples for global theming, page-scoped overrides,
|
|
65
|
+
and per-instance overrides via `styleClassPassthrough`. For anything the tokens don't cover,
|
|
66
|
+
scaffold a style block using `styleClassPassthrough` instead — see
|
|
67
|
+
[component-local-style-override.md](../component-local-style-override.md) for the general pattern.
|
|
50
68
|
|
|
51
69
|
## Basic usage
|
|
52
70
|
|
|
@@ -80,6 +98,24 @@ Set on `.services-card` (or scoped to a page class):
|
|
|
80
98
|
</ServicesCard>
|
|
81
99
|
```
|
|
82
100
|
|
|
101
|
+
## With custom meta row content
|
|
102
|
+
|
|
103
|
+
```vue
|
|
104
|
+
<ServicesCard :service-data="service">
|
|
105
|
+
<template #duration="{ serviceData }">
|
|
106
|
+
<Icon name="mdi:clock-time-four-outline" class="icon" />
|
|
107
|
+
<span>{{ serviceData.duration }}</span>
|
|
108
|
+
</template>
|
|
109
|
+
<template #price="{ serviceData }">
|
|
110
|
+
<Icon name="mdi:currency-gbp" class="icon" />
|
|
111
|
+
<span>From {{ serviceData.price }}</span>
|
|
112
|
+
</template>
|
|
113
|
+
<template #actions="{ serviceData }">
|
|
114
|
+
<InputButtonCore variant="secondary" :button-text="`Enquire`" :href="`/services/${serviceData.slug}`" />
|
|
115
|
+
</template>
|
|
116
|
+
</ServicesCard>
|
|
117
|
+
```
|
|
118
|
+
|
|
83
119
|
## Consumer page boilerplate
|
|
84
120
|
|
|
85
121
|
```vue
|
|
@@ -107,18 +143,34 @@ Set on `.services-card` (or scoped to a page class):
|
|
|
107
143
|
<style lang="css">
|
|
108
144
|
.page-my-page {
|
|
109
145
|
.services-card {
|
|
110
|
-
--
|
|
111
|
-
--
|
|
112
|
-
--
|
|
146
|
+
--services-card-gap: 1.4rem;
|
|
147
|
+
--description-line-clamp: 3;
|
|
148
|
+
--eyebrow-text-padding-block: 0.8rem 0;
|
|
149
|
+
--hero-text-padding-block: 2rem 1rem;
|
|
150
|
+
--meta-border-colour: var(--brand-border);
|
|
113
151
|
}
|
|
114
152
|
}
|
|
115
153
|
</style>
|
|
116
154
|
```
|
|
117
155
|
|
|
156
|
+
See `CONSUMER-STYLING.md` for the full token list — every token is a plain public custom property
|
|
157
|
+
consumed directly at its point of use (no `--_`-prefixed private indirection layer), so all of
|
|
158
|
+
them work both globally (`:root`) and scoped like the example above.
|
|
159
|
+
|
|
160
|
+
## Whole-card clickable usage
|
|
161
|
+
|
|
162
|
+
```vue
|
|
163
|
+
<ServicesCard :service-data="service" :href="`/services/${service.slug}`" />
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
No `actions` slot — the entire card renders as an anchor (`NuxtLink` for the internal href here) and is clickable anywhere within it.
|
|
167
|
+
|
|
118
168
|
## Notes
|
|
119
169
|
|
|
120
170
|
- Component is auto-imported in Nuxt — no import needed.
|
|
121
171
|
- The `Service` type is imported from `~/types/types.services`.
|
|
122
|
-
-
|
|
172
|
+
- Root markup is two rows (`grid-template-rows: auto 1fr`): `.image-wrapper`, then a `.details-wrapper` (`display: flex; flex-direction: column`) holding the eyebrow, title, description, and a `.footer` wrapper. Description length is controlled by `--description-line-clamp` (default effectively unclamped) rather than a fixed-height grid row.
|
|
173
|
+
- `.footer` groups the meta row and the `actions` slot and gets `margin-block-start: auto`, pinning them to the bottom of the card. Combined with the root's `1fr` details row, this means when `ServicesCardGrid`'s default `align-items: stretch` makes a card taller than its own content (to match a taller sibling in the same row), the extra height goes to `.footer`'s top margin rather than leaving whitespace below the actions slot — so the meta row and actions/button line up across a row of cards regardless of each card's description length. This does the visual job of CSS subgrid without needing a subgrid chain across `ServicesCard`/`ServicesCardGrid` (which would also break whenever cards in a row don't all render the same optional rows — `.meta` and `actions` are both conditional).
|
|
123
174
|
- Image has a `3/4` aspect ratio with a subtle scale-on-hover effect.
|
|
175
|
+
- `.services-card`, `.image-wrapper`, `.details-wrapper`, and `.footer` all set `min-inline-size: 0`. Without it, an unbreakable child — most commonly a long `actions` slot button label, since `InputButtonCore`'s `.button-text` is `white-space: nowrap` with no ellipsis — forces its content's min-content width up through the flex/grid chain and widens that one card's grid column in `ServicesCardGrid` wider than its siblings (the image just rides along on the widened column; it isn't the actual cause). If you see one card/column wider than the rest with cut-off content, check for a long unbreakable string in a slot before assuming it's an image sizing issue.
|
|
124
176
|
- Usually consumed via `ServicesCardGrid` rather than directly.
|
package/.claude/skills/index.md
CHANGED
|
@@ -48,7 +48,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
48
48
|
├── vercel-node-version.md — .nvmrc pinned to Node 24 is required; without it Vercel uses npm 10 which crashes on versionless optional stubs
|
|
49
49
|
├── robots-env-aware.md — @nuxtjs/robots: allow crawling on prod domain only, block on preview/staging via env var
|
|
50
50
|
├── new-app-scaffold.md — scaffold a new Nuxt consumer app extending this layer (package.json, nuxt.config, app structure, CLAUDE.md)
|
|
51
|
-
├── qa-panel.md — collapsible panel for toggling component props live on a page
|
|
51
|
+
├── qa-panel.md — collapsible panel for toggling component props live on a page, for consuming apps' own pages (gate with isDev unless demo-only); this library's own demos live in Storybook
|
|
52
52
|
├── pull-request-description.md — produce a PR description as a fenced markdown block from git diff vs main
|
|
53
53
|
├── using-component-skills.md — discover and use component skills in consumer apps: where skills land, browsing patterns, workflow for deciding build vs. compose
|
|
54
54
|
├── composable-canonical-url.md — useCanonicalUrl: set <link rel="canonical"> from runtimeConfig.public.canonicalHost; layout setup, node types
|
|
@@ -71,6 +71,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
71
71
|
├── services-card-grid.md — ServicesCardGrid props, config pass-through, CSS tokens, full page boilerplate
|
|
72
72
|
├── services-section-grid.md — ServicesSectionGrid props, useAlternateReverse zigzag layout, page boilerplate
|
|
73
73
|
├── services-section.md — ServicesSection props (incl. heading/CTA copy), summary-link/cta/cta-panel slots, summary vs full mode
|
|
74
|
+
├── service-detail.md — ServiceDetail: full service detail page (hero banner, sticky sidebar booking card + related services, closing CTA), headerTag vs subheadingTag split, book-cta/sidebar-note/related-service/final-cta slots
|
|
75
|
+
├── breadcrumb.md — Breadcrumb: items (BreadcrumbItem[]) trail, link vs current-page text, CSS token API
|
|
74
76
|
├── contact-section.md — ContactSection props (stepperIndicatorSize pass-through), 3-item info+form layout, slot API
|
|
75
77
|
├── stepper-list.md — StepperList dynamic slots (item-{n}/indicator-{n}), props, connector behaviour
|
|
76
78
|
├── expanding-panel.md — ExpandingPanel v-model, forceOpened, contentIsOnTop overlay mode, slots (summary/icon/content), ARIA wiring, CSS token API
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
A collapsible panel that lets you toggle component props live on a page — without touching the component or breaking the visual layout. Uses a native `<details>`/`<summary>` so it takes up no space when collapsed.
|
|
5
|
+
A collapsible panel that lets you toggle component props live on a page — without touching the component or breaking the visual layout. Uses a native `<details>`/`<summary>` so it takes up no space when collapsed. This library no longer ships its own demo pages (`app/pages/`) — component demos live entirely in Storybook now, which has its own live prop-toggling via args/controls, so this panel isn't needed there. It's still useful in a **consuming app's own pages**: gate it behind that app's dev/staging check unless the page is genuinely demo-only.
|
|
6
6
|
|
|
7
7
|
## Structure
|
|
8
8
|
|
|
@@ -218,7 +218,7 @@ Scope inside your page body class (e.g. `.my-page`) so styles don't bleed. The p
|
|
|
218
218
|
|
|
219
219
|
## Notes
|
|
220
220
|
|
|
221
|
-
- **Consuming apps on real production traffic**: if the page hosting the panel isn't demo-only
|
|
221
|
+
- **Consuming apps on real production traffic**: if the page hosting the panel isn't demo-only, gate the panel with that app's own dev/staging check, e.g. `v-if="isDev"` with `const isDev = import.meta.dev;`. `import.meta.dev` is `false` in production builds, so the gated block is tree-shaken with no runtime cost.
|
|
222
222
|
- **Consuming apps**: The active chip color (`oklch(55% 0.18 240)`) is a neutral blue. Replace with a brand accent token if preferred: `background: var(--color-brand-accent)`.
|
|
223
223
|
- **Panel placement**: Outside any `overflow: hidden` or clipping container, otherwise the panel may be clipped or push layout unexpectedly. Placing it as a direct sibling of the component row works well.
|
|
224
224
|
- **Computed CSS vars**: When a prop controls a CSS custom property (e.g. max-height tiers), use a `computed` that returns a style object and bind it with `:style` on the component wrapper:
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"SRCDEV Breadcrumb": {
|
|
3
|
+
"description": "Breadcrumb trail — items without a `to` render as plain current-page text",
|
|
4
|
+
"scope": "vue,html",
|
|
5
|
+
"body": [
|
|
6
|
+
"<Breadcrumb",
|
|
7
|
+
" :items=\"[",
|
|
8
|
+
" { label: '$1Services', to: '$2/services' },",
|
|
9
|
+
" { label: '$3${TM_FILENAME_BASE}' },",
|
|
10
|
+
" ]\"",
|
|
11
|
+
"/>"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"SRCDEV ServiceDetail": {
|
|
3
|
+
"description": "ServiceDetail full page — hero banner, content, sticky sidebar, closing CTA",
|
|
4
|
+
"scope": "vue,html",
|
|
5
|
+
"body": [
|
|
6
|
+
"<ServiceDetail",
|
|
7
|
+
" :service-data=\"$1service\"",
|
|
8
|
+
" location=\"$2Mobile — across Bath\"",
|
|
9
|
+
" :related-services=\"$3relatedServices\"",
|
|
10
|
+
">",
|
|
11
|
+
" <template #book-cta>",
|
|
12
|
+
" <button type=\"button\">$4Book Now</button>",
|
|
13
|
+
" </template>",
|
|
14
|
+
" <template #sidebar-note>",
|
|
15
|
+
" $5",
|
|
16
|
+
" </template>",
|
|
17
|
+
" <template #final-cta>",
|
|
18
|
+
" <button type=\"button\">$6Book Now</button>",
|
|
19
|
+
" </template>",
|
|
20
|
+
"</ServiceDetail>"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"SRCDEV ServiceDetail Custom Breadcrumb": {
|
|
24
|
+
"description": "ServiceDetail with explicit breadcrumb routes instead of the auto-built category/title trail",
|
|
25
|
+
"scope": "vue,html",
|
|
26
|
+
"body": [
|
|
27
|
+
"<ServiceDetail",
|
|
28
|
+
" :service-data=\"$1service\"",
|
|
29
|
+
" :breadcrumb-items=\"[",
|
|
30
|
+
" { label: '$2Services', to: '$3/services' },",
|
|
31
|
+
" { label: '$4${TM_FILENAME_BASE}' },",
|
|
32
|
+
" ]\"",
|
|
33
|
+
">",
|
|
34
|
+
" <template #book-cta>",
|
|
35
|
+
" <button type=\"button\">$5Book Now</button>",
|
|
36
|
+
" </template>",
|
|
37
|
+
"</ServiceDetail>"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"SRCDEV ServiceDetail Related Service Link Slot": {
|
|
41
|
+
"description": "ServiceDetail related-service scoped slot — wraps each related item in a real link",
|
|
42
|
+
"scope": "vue,html",
|
|
43
|
+
"body": [
|
|
44
|
+
"<template #related-service=\"{ service }\">",
|
|
45
|
+
" <NuxtLink :to=\"`/services/${service.slug}`\">$1{{ service.title }}</NuxtLink>",
|
|
46
|
+
"</template>"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
}
|