@duffcloudservices/cms 0.9.0 → 0.11.0
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/README.md +59 -2
- package/dist/chunk-DAYLLSEE.js +3 -0
- package/dist/{chunk-KCWMS7P4.js.map → chunk-DAYLLSEE.js.map} +1 -1
- package/dist/{chunk-UPAMLKOQ.js → chunk-F3EIWEZD.js} +158 -11
- package/dist/chunk-F3EIWEZD.js.map +1 -0
- package/dist/index.d.ts +74 -6
- package/dist/index.js +88 -6
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.d.ts +114 -4
- package/dist/plugins/index.js +418 -107
- package/dist/plugins/index.js.map +1 -1
- package/dist/seo/index.d.ts +132 -226
- package/dist/seo/index.js +2 -2
- package/dist/spliceHeadHtml-CsBEucGy.d.ts +254 -0
- package/dist/{vitepressTransform-DeEzgGWU.d.ts → vitepressTransform-DfmABXmK.d.ts} +53 -2
- package/package.json +14 -2
- package/src/components/DcsCallButton.test.ts +126 -0
- package/src/components/DcsCallButton.vue +185 -0
- package/src/components/DcsReviewShowcase.vue +13 -2
- package/src/components/LiteMediaEmbed.test.ts +229 -0
- package/src/components/LiteMediaEmbed.vue +399 -0
- package/src/composables/useMediaCarousel.ts +6 -1
- package/src/composables/useReviewContent.test.ts +86 -0
- package/src/composables/useReviewContent.ts +34 -2
- package/src/composables/useSiteVisitorSession.test.ts +120 -0
- package/src/composables/useSiteVisitorSession.ts +160 -0
- package/dist/chunk-KCWMS7P4.js +0 -3
- package/dist/chunk-UPAMLKOQ.js.map +0 -1
|
@@ -18,6 +18,14 @@ interface SeoConfiguration {
|
|
|
18
18
|
global?: GlobalSeoConfig;
|
|
19
19
|
/** Page-specific SEO configurations keyed by page slug */
|
|
20
20
|
pages?: Record<string, PageSeoConfig>;
|
|
21
|
+
/**
|
|
22
|
+
* Per-site escape hatch for build-time BODY prerender (default ON when the
|
|
23
|
+
* static-HTML emitter is enabled). Set `false` to disable body prerender for
|
|
24
|
+
* this one site with NO code change and NO cms republish — the portal owns
|
|
25
|
+
* `.dcs/seo.yaml`, so flipping this and rebuilding is enough. Head meta +
|
|
26
|
+
* JSON-LD emission is unaffected either way.
|
|
27
|
+
*/
|
|
28
|
+
prerenderBody?: boolean;
|
|
21
29
|
}
|
|
22
30
|
/**
|
|
23
31
|
* Global/site-wide SEO configuration
|
|
@@ -403,6 +411,26 @@ interface ReviewSchemaParts {
|
|
|
403
411
|
* real items, BOTH are omitted — never an invented rating or count.
|
|
404
412
|
*/
|
|
405
413
|
declare function buildReviewSchemaParts(items: ReviewSource[] | undefined): ReviewSchemaParts;
|
|
414
|
+
/**
|
|
415
|
+
* Build a single `EducationalOccupationalCredential` node for a trade license
|
|
416
|
+
* from the free-text `business.license` content key, or `undefined` when the key
|
|
417
|
+
* is empty / whitespace / absent.
|
|
418
|
+
*
|
|
419
|
+
* Shape (schema.org-correct; mirrors the portal's `Credential` editor type in
|
|
420
|
+
* `portal/src/lib/jsonld-schema-types.ts`):
|
|
421
|
+
*
|
|
422
|
+
* { '@type': 'EducationalOccupationalCredential',
|
|
423
|
+
* credentialCategory: 'license',
|
|
424
|
+
* name: <verbatim business.license value> }
|
|
425
|
+
*
|
|
426
|
+
* HONESTY (non-negotiable, mirrors the Review/FAQ gates in this module): an
|
|
427
|
+
* empty / whitespace / absent value returns `undefined` so the caller omits
|
|
428
|
+
* `hasCredential` entirely — never an empty string, never a fabricated license.
|
|
429
|
+
* The value is carried verbatim in `name` (the field is free text — a license
|
|
430
|
+
* number, a "Licensed & Insured" phrase, or a credential name — so the
|
|
431
|
+
* always-valid `name` slot is the safest home; nothing is parsed or invented).
|
|
432
|
+
*/
|
|
433
|
+
declare function buildHasCredential(license: string | undefined): SchemaObject | undefined;
|
|
406
434
|
/**
|
|
407
435
|
* Build the cross-linked global knowledge graph as ONE JSON-LD object carrying
|
|
408
436
|
* a `@graph` array: `Organization`, `WebSite` (publisher → org), and the site's
|
|
@@ -412,16 +440,22 @@ declare function buildReviewSchemaParts(items: ReviewSource[] | undefined): Revi
|
|
|
412
440
|
*
|
|
413
441
|
* The LocalBusiness node ABSORBS the existing `global.schemas[*]` LocalBusiness
|
|
414
442
|
* subtype (its NAP/geo/hours/offers are preserved verbatim) and is promoted with
|
|
415
|
-
* an `@id`; honest `review` + `aggregateRating`
|
|
443
|
+
* an `@id`; honest `review` + `aggregateRating` and a `hasCredential` license are
|
|
444
|
+
* merged in when supplied.
|
|
416
445
|
*
|
|
417
446
|
* Returns `[]` when there is no `siteUrl` (no stable `@id` anchor possible), so
|
|
418
447
|
* the existing per-schema emission is left untouched for un-configured sites.
|
|
419
448
|
*
|
|
420
449
|
* @param opts.reviews REAL review items (from content.yaml) for the business
|
|
421
450
|
* node. Optional; when omitted/empty no Review/aggregateRating is added.
|
|
451
|
+
* @param opts.license Free-text trade/occupational license (from the
|
|
452
|
+
* `business.license` content key). Optional; when empty/absent no
|
|
453
|
+
* `hasCredential` is added (honesty-gated — see `buildHasCredential`). Only
|
|
454
|
+
* ever attached to the LocalBusiness node, never to Organization/WebSite.
|
|
422
455
|
*/
|
|
423
456
|
declare function buildGlobalGraph(global: GlobalSeoConfig, opts?: {
|
|
424
457
|
reviews?: ReviewSource[];
|
|
458
|
+
license?: string;
|
|
425
459
|
}): SchemaObject[];
|
|
426
460
|
/**
|
|
427
461
|
* Build a `BreadcrumbList` from an ordered trail of crumbs (Home → … → current).
|
|
@@ -514,6 +548,15 @@ interface ContentConfig {
|
|
|
514
548
|
* rating/text/authorName are dropped there.
|
|
515
549
|
*/
|
|
516
550
|
declare function findReviewItemsForPage(content: ContentConfig | undefined, pageSlug: string): ReviewSource[];
|
|
551
|
+
/**
|
|
552
|
+
* Read the free-text `business.license` content key from `.dcs/content.yaml`
|
|
553
|
+
* (page-scoped block first, then global — mirroring `findReviewItemsForPage`;
|
|
554
|
+
* NAP identity normally lives under `global`). Returns a trimmed non-empty
|
|
555
|
+
* string, or `undefined` when the key is absent or empty. A bare numeric YAML
|
|
556
|
+
* value (e.g. `business.license: 123456`) is coerced to its string form. No
|
|
557
|
+
* synthesis — an unset key yields `undefined`, so the emit paths stay dark.
|
|
558
|
+
*/
|
|
559
|
+
declare function findBusinessLicense(content: ContentConfig | undefined, pageSlug: string): string | undefined;
|
|
517
560
|
/**
|
|
518
561
|
* Guarantee an absolute URL. When `value` is already absolute (`http(s)://`) it
|
|
519
562
|
* is returned unchanged; when it is a site-relative path it is joined onto the
|
|
@@ -935,6 +978,14 @@ interface CreateSeoTransformPageDataOptions {
|
|
|
935
978
|
* aggregateRating. Default: none.
|
|
936
979
|
*/
|
|
937
980
|
resolveReviews?: (ctx: SeoPageContext) => ReviewSource[] | undefined;
|
|
981
|
+
/**
|
|
982
|
+
* Provide the free-text trade/occupational license (the `business.license`
|
|
983
|
+
* content.yaml key) for the LocalBusiness node in the `@graph` (only used when
|
|
984
|
+
* `emitGraph` is true). When non-empty a `hasCredential`
|
|
985
|
+
* (`EducationalOccupationalCredential`, `credentialCategory: "license"`) is
|
|
986
|
+
* added to the business node; empty/absent ⇒ nothing. Default: none.
|
|
987
|
+
*/
|
|
988
|
+
resolveLicense?: (ctx: SeoPageContext) => string | undefined;
|
|
938
989
|
/** Enable debug logging of the emitted head per page. */
|
|
939
990
|
debug?: boolean;
|
|
940
991
|
}
|
|
@@ -976,4 +1027,4 @@ declare function buildVitePressSeoHead(pageData: VitePressPageData, options: Cre
|
|
|
976
1027
|
*/
|
|
977
1028
|
declare function createSeoTransformPageData(options: CreateSeoTransformPageDataOptions): (pageData: VitePressPageData) => void;
|
|
978
1029
|
|
|
979
|
-
export {
|
|
1030
|
+
export { buildHasCredential as $, AI_BOTS as A, type BuildSitemapParams as B, type CreateSeoTransformPageDataOptions as C, type DcsRobotsOptions as D, type ContentConfig as E, type FaqSource as F, type GlobalSeoConfig as G, type SeoConfiguration as H, type SeoAuthorConfig as I, type SeoSocialConfig as J, type SeoImagesConfig as K, type SeoOpenGraphConfig as L, type SeoTwitterConfig as M, type SeoSchemaConfig as N, type SeoAlternateConfig as O, type PageSeoConfig as P, type SeoVerificationConfig as Q, type ResolvedPageOverrides as R, type SeoPageContext as S, type ResolvedPageSeo as T, type UseSeoReturn as U, type VitePressPageData as V, type UseSeoConfig as W, type HeadOverrides as X, type PageRouteEntry as Y, loadPagesManifest as Z, parsePagesManifest as _, buildSitemapXml as a, findLocalBusinessSchema as a0, graphAbsorbs as a1, findBusinessLicense as a2, deriveSameAs as a3, graphIds as a4, isLocalBusinessType as a5, type NormalisedReview as a6, type NormalisedFaq as a7, type ReviewSchemaParts as a8, buildVitePressSeoHead as b, createSeoTransformPageData as c, defaultRelativePathToRoute as d, buildRobotsTxt as e, buildLlmsTxt as f, buildGlobalGraph as g, buildBreadcrumbList as h, isRouteIndexable as i, breadcrumbTrailFromRoute as j, buildBlogPosting as k, buildFaqPage as l, buildReviewSchemaParts as m, filterRealReviews as n, filterRealFaq as o, findReviewItemsForPage as p, absolutizeUrl as q, type SeoPageTypeRule as r, slugToTitle as s, type VitePressHeadConfig as t, type BuildRobotsParams as u, type BuildLlmsParams as v, type SchemaObject as w, type BreadcrumbCrumb as x, type BlogMeta as y, type ReviewSource as z };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duffcloudservices/cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Vue 3 composables and Vite plugins for DCS CMS integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -31,6 +31,12 @@
|
|
|
31
31
|
},
|
|
32
32
|
"./review-showcase": {
|
|
33
33
|
"import": "./src/components/DcsReviewShowcase.vue"
|
|
34
|
+
},
|
|
35
|
+
"./call-button": {
|
|
36
|
+
"import": "./src/components/DcsCallButton.vue"
|
|
37
|
+
},
|
|
38
|
+
"./lite-media-embed": {
|
|
39
|
+
"import": "./src/components/LiteMediaEmbed.vue"
|
|
34
40
|
}
|
|
35
41
|
},
|
|
36
42
|
"main": "./dist/index.js",
|
|
@@ -51,7 +57,13 @@
|
|
|
51
57
|
},
|
|
52
58
|
"peerDependencies": {
|
|
53
59
|
"vue": "^3.4.0",
|
|
54
|
-
"@unhead/vue": "^1.9.0"
|
|
60
|
+
"@unhead/vue": "^1.9.0",
|
|
61
|
+
"playwright": "^1.40.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"playwright": {
|
|
65
|
+
"optional": true
|
|
66
|
+
}
|
|
55
67
|
},
|
|
56
68
|
"dependencies": {
|
|
57
69
|
"@duffcloudservices/cms-core": "0.4.3",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import DcsCallButton from './DcsCallButton.vue'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `useTextContent` reads the build-time `__DCS_CONTENT__` global synchronously
|
|
7
|
+
* at setup. Stub it to simulate `.dcs/content.yaml` `global` overrides; pass
|
|
8
|
+
* `null` to simulate a site with no injected content at all (the SSR default).
|
|
9
|
+
*/
|
|
10
|
+
function stubContent(global: Record<string, string> | null): void {
|
|
11
|
+
if (global === null) {
|
|
12
|
+
vi.stubGlobal('__DCS_CONTENT__', undefined)
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
vi.stubGlobal('__DCS_CONTENT__', {
|
|
16
|
+
version: 1,
|
|
17
|
+
lastUpdated: '',
|
|
18
|
+
updatedBy: 'test',
|
|
19
|
+
global,
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.unstubAllGlobals()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('DcsCallButton', () => {
|
|
28
|
+
it('renders a tel: link with the managed number when business.phone is set', () => {
|
|
29
|
+
stubContent({ 'business.phone': '(248) 385-2926', 'business.name': 'Iron Oak' })
|
|
30
|
+
|
|
31
|
+
const wrapper = mount(DcsCallButton, { props: { variant: 'inline' } })
|
|
32
|
+
const link = wrapper.find('a')
|
|
33
|
+
|
|
34
|
+
expect(link.exists()).toBe(true)
|
|
35
|
+
expect(link.attributes('href')).toBe('tel:2483852926')
|
|
36
|
+
expect(link.text()).toContain('(248) 385-2926')
|
|
37
|
+
expect(link.attributes('aria-label')).toBe('Call Iron Oak at (248) 385-2926')
|
|
38
|
+
// The visible number is editor-discoverable via the managed content key.
|
|
39
|
+
expect(link.find('[data-dcs-text="business.phone"]').exists()).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('renders nothing when the business.phone key is absent', () => {
|
|
43
|
+
stubContent({ 'business.name': 'Iron Oak' })
|
|
44
|
+
|
|
45
|
+
const wrapper = mount(DcsCallButton)
|
|
46
|
+
|
|
47
|
+
expect(wrapper.find('a').exists()).toBe(false)
|
|
48
|
+
expect(wrapper.text()).toBe('')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('renders nothing when business.phone is present but blank', () => {
|
|
52
|
+
stubContent({ 'business.phone': ' ' })
|
|
53
|
+
|
|
54
|
+
const wrapper = mount(DcsCallButton)
|
|
55
|
+
|
|
56
|
+
expect(wrapper.find('a').exists()).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('renders nothing when no managed content is injected at all (SSR default)', () => {
|
|
60
|
+
stubContent(null)
|
|
61
|
+
|
|
62
|
+
const wrapper = mount(DcsCallButton)
|
|
63
|
+
|
|
64
|
+
// No fabricated fallback number — the A-34 SSR-fabrication class must be
|
|
65
|
+
// impossible by construction.
|
|
66
|
+
expect(wrapper.find('a').exists()).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('normalizes a US-formatted number to a digits-only tel: href', () => {
|
|
70
|
+
stubContent({ 'business.phone': '(248) 385-2926' })
|
|
71
|
+
|
|
72
|
+
const wrapper = mount(DcsCallButton)
|
|
73
|
+
|
|
74
|
+
expect(wrapper.find('a').attributes('href')).toBe('tel:2483852926')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('preserves a single leading + for E.164 international numbers', () => {
|
|
78
|
+
stubContent({ 'business.phone': '+1 (248) 385-2926' })
|
|
79
|
+
|
|
80
|
+
const wrapper = mount(DcsCallButton)
|
|
81
|
+
|
|
82
|
+
expect(wrapper.find('a').attributes('href')).toBe('tel:+12483852926')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('strips dots and spaces from the tel: href', () => {
|
|
86
|
+
stubContent({ 'business.phone': '248.385.2926' })
|
|
87
|
+
|
|
88
|
+
const wrapper = mount(DcsCallButton)
|
|
89
|
+
|
|
90
|
+
expect(wrapper.find('a').attributes('href')).toBe('tel:2483852926')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('icon variant hides the visible number but keeps an accessible label', () => {
|
|
94
|
+
stubContent({ 'business.phone': '(248) 385-2926', 'business.name': 'Iron Oak' })
|
|
95
|
+
|
|
96
|
+
const wrapper = mount(DcsCallButton, { props: { variant: 'icon' } })
|
|
97
|
+
const link = wrapper.find('a')
|
|
98
|
+
|
|
99
|
+
expect(link.exists()).toBe(true)
|
|
100
|
+
// No visible number text — it's icon-only.
|
|
101
|
+
expect(link.text()).toBe('')
|
|
102
|
+
expect(link.find('svg').exists()).toBe(true)
|
|
103
|
+
expect(link.attributes('aria-label')).toBe('Call Iron Oak at (248) 385-2926')
|
|
104
|
+
expect(link.attributes('title')).toBe('Call Iron Oak at (248) 385-2926')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('falls back to "Call {number}" when no business name is available', () => {
|
|
108
|
+
stubContent({ 'business.phone': '(248) 385-2926' })
|
|
109
|
+
|
|
110
|
+
const wrapper = mount(DcsCallButton, { props: { variant: 'icon' } })
|
|
111
|
+
|
|
112
|
+
expect(wrapper.find('a').attributes('aria-label')).toBe('Call (248) 385-2926')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('prefers an explicit businessName prop over managed business.name', () => {
|
|
116
|
+
stubContent({ 'business.phone': '(248) 385-2926', 'business.name': 'Managed Name' })
|
|
117
|
+
|
|
118
|
+
const wrapper = mount(DcsCallButton, {
|
|
119
|
+
props: { variant: 'icon', businessName: 'Explicit Name' },
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(wrapper.find('a').attributes('aria-label')).toBe(
|
|
123
|
+
'Call Explicit Name at (248) 385-2926'
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Click-to-call affordance driven by the portal-managed `business.phone`
|
|
4
|
+
* content key — the same NAP field that powers the site's LocalBusiness
|
|
5
|
+
* identity (see `.dcs/content.yaml` `global.business.phone`). Renders a real
|
|
6
|
+
* `tel:` link so mobile visitors get an above-the-fold call path.
|
|
7
|
+
*
|
|
8
|
+
* HONESTY / SSR SAFETY (non-negotiable): the number is read through
|
|
9
|
+
* `useTextContent`, which resolves build-time `.dcs/content.yaml` synchronously
|
|
10
|
+
* (no window/document at setup). When `business.phone` is unset or blank the
|
|
11
|
+
* component renders NOTHING — it never fabricates a placeholder number. A
|
|
12
|
+
* defaulted number leaking into prod SSR is exactly the A-34 fabrication class
|
|
13
|
+
* this repo already had an incident over, so the render gate is a hard `v-if`.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* import DcsCallButton from '@duffcloudservices/cms/call-button'
|
|
17
|
+
*
|
|
18
|
+
* <!-- compact, above-the-fold mobile header affordance -->
|
|
19
|
+
* <DcsCallButton variant="icon" />
|
|
20
|
+
*
|
|
21
|
+
* <!-- icon + label + number for a nav/menu row or hero -->
|
|
22
|
+
* <DcsCallButton variant="inline" />
|
|
23
|
+
*/
|
|
24
|
+
import { computed } from 'vue'
|
|
25
|
+
import { useTextContent } from '../composables/useTextContent'
|
|
26
|
+
|
|
27
|
+
const props = withDefaults(
|
|
28
|
+
defineProps<{
|
|
29
|
+
/**
|
|
30
|
+
* Page slug to resolve NAP content from. `business.phone`/`business.name`
|
|
31
|
+
* live in the `global` block, which `useTextContent` always merges in, so
|
|
32
|
+
* the default `'global'` resolves correctly from any page.
|
|
33
|
+
*/
|
|
34
|
+
pageSlug?: string
|
|
35
|
+
/**
|
|
36
|
+
* Visual variant: `icon` is a compact icon-only tap target (mobile header);
|
|
37
|
+
* `inline` renders icon + label + number (nav/menu row or hero).
|
|
38
|
+
*/
|
|
39
|
+
variant?: 'icon' | 'inline'
|
|
40
|
+
/** Leading call-to-action label for the `inline` variant. */
|
|
41
|
+
label?: string
|
|
42
|
+
/**
|
|
43
|
+
* Business name for the accessible label ("Call {name} at {number}"). When
|
|
44
|
+
* empty, falls back to the managed `business.name`, then to just the number.
|
|
45
|
+
*/
|
|
46
|
+
businessName?: string
|
|
47
|
+
}>(),
|
|
48
|
+
{
|
|
49
|
+
pageSlug: 'global',
|
|
50
|
+
variant: 'inline',
|
|
51
|
+
label: 'Call',
|
|
52
|
+
businessName: '',
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// Empty defaults: the number MUST come from managed content, never a hardcoded
|
|
57
|
+
// fallback baked into the component.
|
|
58
|
+
const { texts } = useTextContent({ pageSlug: props.pageSlug, defaults: {} })
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The portal-managed phone exactly as entered (the human-readable display
|
|
62
|
+
* form, e.g. "(248) 385-2926"). Empty when the key is unset/blank.
|
|
63
|
+
*/
|
|
64
|
+
const phoneDisplay = computed(() => {
|
|
65
|
+
const value = texts.value['business.phone']
|
|
66
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/** Render only when a real, dialable number is present (has ≥ 1 digit). */
|
|
70
|
+
const hasPhone = computed(() => /\d/.test(phoneDisplay.value))
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `tel:` target: strip all formatting to digits, preserving a single leading
|
|
74
|
+
* `+` for E.164 international numbers.
|
|
75
|
+
* "(248) 385-2926" → "tel:2483852926"
|
|
76
|
+
* "+1 (248) 385-2926" → "tel:+12483852926"
|
|
77
|
+
*/
|
|
78
|
+
const telHref = computed(() => {
|
|
79
|
+
const raw = phoneDisplay.value
|
|
80
|
+
const plus = raw.startsWith('+') ? '+' : ''
|
|
81
|
+
return `tel:${plus}${raw.replace(/\D/g, '')}`
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
/** Business name for the aria-label: prop → managed `business.name` → none. */
|
|
85
|
+
const resolvedName = computed(() => {
|
|
86
|
+
const fromProp = props.businessName.trim()
|
|
87
|
+
if (fromProp) return fromProp
|
|
88
|
+
const value = texts.value['business.name']
|
|
89
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const ariaLabel = computed(() =>
|
|
93
|
+
resolvedName.value
|
|
94
|
+
? `Call ${resolvedName.value} at ${phoneDisplay.value}`
|
|
95
|
+
: `Call ${phoneDisplay.value}`
|
|
96
|
+
)
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<template>
|
|
100
|
+
<a
|
|
101
|
+
v-if="hasPhone"
|
|
102
|
+
:href="telHref"
|
|
103
|
+
class="dcs-call-button"
|
|
104
|
+
:class="`dcs-call-button--${variant}`"
|
|
105
|
+
:aria-label="ariaLabel"
|
|
106
|
+
:title="variant === 'icon' ? ariaLabel : undefined"
|
|
107
|
+
data-dcs-call
|
|
108
|
+
>
|
|
109
|
+
<svg
|
|
110
|
+
class="dcs-call-button__icon"
|
|
111
|
+
viewBox="0 0 24 24"
|
|
112
|
+
fill="none"
|
|
113
|
+
stroke="currentColor"
|
|
114
|
+
stroke-width="2"
|
|
115
|
+
stroke-linecap="round"
|
|
116
|
+
stroke-linejoin="round"
|
|
117
|
+
aria-hidden="true"
|
|
118
|
+
>
|
|
119
|
+
<path
|
|
120
|
+
d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"
|
|
121
|
+
/>
|
|
122
|
+
</svg>
|
|
123
|
+
<span v-if="variant === 'inline'" class="dcs-call-button__label">{{ label }}</span>
|
|
124
|
+
<span
|
|
125
|
+
v-if="variant === 'inline'"
|
|
126
|
+
class="dcs-call-button__number"
|
|
127
|
+
data-dcs-text="business.phone"
|
|
128
|
+
>{{ phoneDisplay }}</span
|
|
129
|
+
>
|
|
130
|
+
</a>
|
|
131
|
+
</template>
|
|
132
|
+
|
|
133
|
+
<style scoped>
|
|
134
|
+
.dcs-call-button {
|
|
135
|
+
display: inline-flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
gap: var(--dcs-call-gap, 0.5rem);
|
|
138
|
+
color: var(--dcs-call-color, currentColor);
|
|
139
|
+
text-decoration: none;
|
|
140
|
+
font-weight: var(--dcs-call-font-weight, 600);
|
|
141
|
+
line-height: 1;
|
|
142
|
+
border-radius: var(--dcs-call-radius, 0.5rem);
|
|
143
|
+
transition:
|
|
144
|
+
color 0.2s ease,
|
|
145
|
+
background-color 0.2s ease;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.dcs-call-button:hover {
|
|
149
|
+
color: var(--dcs-call-color-hover, var(--brand-accent, currentColor));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.dcs-call-button:focus-visible {
|
|
153
|
+
outline: 2px solid var(--dcs-call-focus, currentColor);
|
|
154
|
+
outline-offset: 2px;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* Icon-only: a WCAG 2.5.5 minimum 44×44 touch target for reliable tapping. */
|
|
158
|
+
.dcs-call-button--icon {
|
|
159
|
+
justify-content: center;
|
|
160
|
+
min-width: 2.75rem;
|
|
161
|
+
min-height: 2.75rem;
|
|
162
|
+
padding: var(--dcs-call-icon-padding, 0.5rem);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.dcs-call-button--inline {
|
|
166
|
+
padding: var(--dcs-call-inline-padding, 0);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.dcs-call-button__icon {
|
|
170
|
+
width: var(--dcs-call-icon-size, 1.25rem);
|
|
171
|
+
height: var(--dcs-call-icon-size, 1.25rem);
|
|
172
|
+
flex-shrink: 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.dcs-call-button__number {
|
|
176
|
+
font-variant-numeric: tabular-nums;
|
|
177
|
+
white-space: nowrap;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@media (prefers-reduced-motion: reduce) {
|
|
181
|
+
.dcs-call-button {
|
|
182
|
+
transition: none;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
</style>
|
|
@@ -60,16 +60,27 @@ function markAvatarFailed(reviewId: string) {
|
|
|
60
60
|
failedAvatarIds.value = next
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Format date string to readable format.
|
|
65
|
+
*
|
|
66
|
+
* Both the locale AND the time zone are pinned so the string is byte-identical
|
|
67
|
+
* between the SSR/SSG pass (which runs in the build host's locale/zone — and
|
|
68
|
+
* Windows Node ignores the `TZ` env var) and the client hydration (visitor's
|
|
69
|
+
* locale/zone). Without pinning, a date near a day boundary — or an en-GB vs
|
|
70
|
+
* en-US visitor — renders differently on server and client, tripping a Vue
|
|
71
|
+
* hydration mismatch. `timeZone: 'UTC'` is the key fix; the fixed `en-US` locale
|
|
72
|
+
* removes the remaining month-abbreviation drift.
|
|
73
|
+
*/
|
|
64
74
|
function formatDate(dateStr: string): string {
|
|
65
75
|
const parsed = new Date(dateStr)
|
|
66
76
|
if (Number.isNaN(parsed.getTime())) {
|
|
67
77
|
return dateStr
|
|
68
78
|
}
|
|
69
79
|
|
|
70
|
-
return parsed.toLocaleDateString(
|
|
80
|
+
return parsed.toLocaleDateString('en-US', {
|
|
71
81
|
year: 'numeric',
|
|
72
82
|
month: 'short',
|
|
83
|
+
timeZone: 'UTC',
|
|
73
84
|
})
|
|
74
85
|
}
|
|
75
86
|
|