@duffcloudservices/cms 0.10.0 → 0.12.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.
Files changed (34) hide show
  1. package/README.md +59 -2
  2. package/dist/chunk-DAYLLSEE.js +3 -0
  3. package/dist/{chunk-KCWMS7P4.js.map → chunk-DAYLLSEE.js.map} +1 -1
  4. package/dist/{chunk-UPAMLKOQ.js → chunk-F3EIWEZD.js} +158 -11
  5. package/dist/chunk-F3EIWEZD.js.map +1 -0
  6. package/dist/editor/editorBridge.d.ts +41 -1
  7. package/dist/editor/editorBridge.js +68 -2
  8. package/dist/editor/editorBridge.js.map +1 -1
  9. package/dist/index.d.ts +78 -6
  10. package/dist/index.js +91 -7
  11. package/dist/index.js.map +1 -1
  12. package/dist/plugins/index.d.ts +174 -3
  13. package/dist/plugins/index.js +448 -87
  14. package/dist/plugins/index.js.map +1 -1
  15. package/dist/seo/index.d.ts +132 -226
  16. package/dist/seo/index.js +2 -2
  17. package/dist/spliceHeadHtml-CsBEucGy.d.ts +254 -0
  18. package/dist/{vitepressTransform-DeEzgGWU.d.ts → vitepressTransform-DfmABXmK.d.ts} +53 -2
  19. package/package.json +25 -14
  20. package/src/components/DcsCallButton.test.ts +126 -0
  21. package/src/components/DcsCallButton.vue +185 -0
  22. package/src/components/DcsReviewShowcase.vue +18 -3
  23. package/src/components/LiteMediaEmbed.test.ts +229 -0
  24. package/src/components/LiteMediaEmbed.vue +399 -0
  25. package/src/components/ManagedImage.test.ts +60 -0
  26. package/src/components/ManagedImage.vue +53 -6
  27. package/src/composables/useMediaCarousel.ts +6 -1
  28. package/src/composables/useResponsiveImage.ts +6 -0
  29. package/src/composables/useReviewContent.test.ts +86 -0
  30. package/src/composables/useReviewContent.ts +34 -2
  31. package/src/composables/useSiteVisitorSession.test.ts +120 -0
  32. package/src/composables/useSiteVisitorSession.ts +160 -0
  33. package/dist/chunk-KCWMS7P4.js +0 -3
  34. package/dist/chunk-UPAMLKOQ.js.map +0 -1
@@ -8,38 +8,82 @@ defineOptions({ inheritAttrs: false })
8
8
 
9
9
  const props = withDefaults(defineProps<{
10
10
  pageSlug: string
11
- imageKey: string
11
+ imageKey?: string
12
12
  fallbackSrc: string
13
13
  altKey?: string
14
14
  fallbackAlt?: string
15
15
  context?: ImageContext
16
16
  sizes?: string
17
17
  original?: boolean
18
+ /**
19
+ * D1-A structured image base (Q-168=A): a single dot-suffix key group, e.g.
20
+ * `hero.image`, from which `.src` / `.alt` / `.width` / `.height` are read.
21
+ * When set it supersedes `imageKey`/`altKey`. The legacy two-key form keeps
22
+ * working forever for backward compatibility. The `.width`/`.height` values
23
+ * feed the CLS layout-shift hint; when they are absent no hint is emitted
24
+ * (never fabricated).
25
+ */
26
+ imageBase?: string
27
+ /**
28
+ * Explicit intrinsic dimensions — a fallback when no `.width`/`.height`
29
+ * content key exists (e.g. a static hero whose size is known at build time).
30
+ * Both must be positive to emit a hint.
31
+ */
32
+ width?: number
33
+ height?: number
18
34
  }>(), {
35
+ imageKey: '',
19
36
  altKey: '',
20
37
  fallbackAlt: '',
21
38
  context: 'content',
22
39
  sizes: undefined,
23
40
  original: false,
41
+ imageBase: '',
42
+ width: undefined,
43
+ height: undefined,
24
44
  })
25
45
 
46
+ // Resolve the effective content keys. `imageBase` (D1-A) derives the four
47
+ // dot-suffix keys; otherwise fall back to the legacy imageKey/altKey pair.
48
+ const srcKey = computed(() => (props.imageBase ? `${props.imageBase}.src` : props.imageKey))
49
+ const effectiveAltKey = computed(() => (props.imageBase ? `${props.imageBase}.alt` : props.altKey))
50
+ const widthKey = computed(() => (props.imageBase ? `${props.imageBase}.width` : ''))
51
+ const heightKey = computed(() => (props.imageBase ? `${props.imageBase}.height` : ''))
52
+
26
53
  const attrs = useAttrs()
27
54
  const { t } = useTextContent({
28
55
  pageSlug: props.pageSlug,
29
56
  defaults: {
30
- [props.imageKey]: props.fallbackSrc,
31
- ...(props.altKey ? { [props.altKey]: props.fallbackAlt } : {}),
57
+ ...(srcKey.value ? { [srcKey.value]: props.fallbackSrc } : {}),
58
+ ...(effectiveAltKey.value ? { [effectiveAltKey.value]: props.fallbackAlt } : {}),
59
+ ...(widthKey.value && props.width ? { [widthKey.value]: String(props.width) } : {}),
60
+ ...(heightKey.value && props.height ? { [heightKey.value]: String(props.height) } : {}),
32
61
  },
33
62
  })
34
63
 
35
- const imageSrc = computed(() => t(props.imageKey, props.fallbackSrc))
36
- const imageAlt = computed(() => (props.altKey ? t(props.altKey, props.fallbackAlt) : props.fallbackAlt))
64
+ /** Parse a content value (always a string) into a positive integer, or undefined. */
65
+ function toPositiveInt(value: string | undefined): number | undefined {
66
+ if (!value) return undefined
67
+ const n = Number.parseInt(value, 10)
68
+ return Number.isFinite(n) && n > 0 ? n : undefined
69
+ }
70
+
71
+ const imageSrc = computed(() => t(srcKey.value, props.fallbackSrc))
72
+ const imageAlt = computed(() => (effectiveAltKey.value ? t(effectiveAltKey.value, props.fallbackAlt) : props.fallbackAlt))
73
+ const imageWidth = computed(() =>
74
+ toPositiveInt(widthKey.value ? t(widthKey.value, '') : undefined) ?? (props.width && props.width > 0 ? props.width : undefined),
75
+ )
76
+ const imageHeight = computed(() =>
77
+ toPositiveInt(heightKey.value ? t(heightKey.value, '') : undefined) ?? (props.height && props.height > 0 ? props.height : undefined),
78
+ )
37
79
  const image = useResponsiveImage({
38
80
  src: imageSrc,
39
81
  alt: imageAlt,
40
82
  context: () => props.context,
41
83
  sizes: () => props.sizes,
42
84
  original: () => props.original,
85
+ width: imageWidth,
86
+ height: imageHeight,
43
87
  })
44
88
 
45
89
  // Self-heal: a fit-variant (`-md`/`-lg`) may legitimately be absent when the
@@ -61,7 +105,10 @@ const imgAttrs = computed(() => ({
61
105
  // When a variant has failed, render the original (also exposed as
62
106
  // data-dcs-image-url for the visual editor) instead of the 404'd variant.
63
107
  ...(failed.value ? { src: imageSrc.value } : {}),
64
- 'data-dcs-image-key': props.imageKey,
108
+ // The VE stages a replacement against the SRC key (the key that holds the
109
+ // image URL), which is the dot-suffix `<base>.src` when imageBase is used,
110
+ // or the legacy imageKey otherwise.
111
+ 'data-dcs-image-key': srcKey.value,
65
112
  'data-dcs-image-url': imageSrc.value,
66
113
  'data-dcs-image-alt': imageAlt.value,
67
114
  }))
@@ -45,7 +45,12 @@ import { isCdnAssetUrl } from '@duffcloudservices/cms-core'
45
45
  export interface MediaCarouselItem {
46
46
  /** URL to the image, video file, or embed URL */
47
47
  url: string
48
- /** Type of media: 'image', 'video' (direct file), 'youtube', or 'instagram' */
48
+ /**
49
+ * Type of media: 'image', 'video' (direct file), 'youtube', or 'instagram'.
50
+ * Render `youtube`/`instagram` items through the click-to-load facade
51
+ * `LiteMediaEmbed` (`@duffcloudservices/cms/lite-media-embed`) so the heavy
52
+ * player iframe loads only on user interaction — never eagerly.
53
+ */
49
54
  type: 'image' | 'video' | 'youtube' | 'instagram'
50
55
  /** Accessibility alt text */
51
56
  alt?: string
@@ -50,6 +50,10 @@ export interface UseResponsiveImageOptions {
50
50
  sizes?: MaybeRefOrGetter<string | undefined>
51
51
  /** Skip variant resolution and use the original URL only. */
52
52
  original?: MaybeRefOrGetter<boolean | undefined>
53
+ /** Intrinsic width — emitted as a layout-shift hint when paired with `height`. */
54
+ width?: MaybeRefOrGetter<number | undefined>
55
+ /** Intrinsic height — emitted as a layout-shift hint when paired with `width`. */
56
+ height?: MaybeRefOrGetter<number | undefined>
53
57
  }
54
58
 
55
59
  /**
@@ -67,6 +71,8 @@ export function useResponsiveImage(options: UseResponsiveImageOptions): Responsi
67
71
  context: toValue(options.context),
68
72
  sizes: toValue(options.sizes),
69
73
  original: toValue(options.original) ?? undefined,
74
+ width: toValue(options.width) ?? undefined,
75
+ height: toValue(options.height) ?? undefined,
70
76
  }),
71
77
  )
72
78
 
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { defineComponent, h } from 'vue'
3
+ import { mount } from '@vue/test-utils'
4
+ import { useReviewContent, type ReviewItem } from './useReviewContent'
5
+
6
+ /**
7
+ * Honesty guard: consumer-supplied `defaults` (placeholder / sample reviews)
8
+ * are a DEV-ONLY affordance. In a production build the composable must render an
9
+ * honest empty state instead of fabricated social proof — this is the fix for
10
+ * the flagship SSR bug where a fabricated fallback was baked into the crawler
11
+ * HTML while the client showed the real reviews.
12
+ */
13
+
14
+ const SAMPLE_DEFAULTS: ReviewItem[] = [
15
+ { id: 'seed-1', platform: 'google', rating: 5, authorName: 'Sample Person', text: 'Placeholder review' },
16
+ ]
17
+
18
+ function mountReviews(config: Parameters<typeof useReviewContent>[0]) {
19
+ let api!: ReturnType<typeof useReviewContent>
20
+ const Comp = defineComponent({
21
+ setup() {
22
+ api = useReviewContent(config)
23
+ return () => h('div')
24
+ },
25
+ })
26
+ const wrapper = mount(Comp)
27
+ return { api, wrapper }
28
+ }
29
+
30
+ afterEach(() => {
31
+ vi.unstubAllEnvs()
32
+ vi.unstubAllGlobals()
33
+ vi.restoreAllMocks()
34
+ delete (globalThis as Record<string, unknown>).__DCS_CONTENT__
35
+ })
36
+
37
+ describe('useReviewContent honesty guard', () => {
38
+ it('production build with no content define renders an empty state, NOT the defaults', () => {
39
+ vi.stubEnv('DEV', false)
40
+ // __DCS_CONTENT__ intentionally undefined (simulates a production SSR pass
41
+ // where the define never reached the composable / no curated reviews).
42
+ const { api, wrapper } = mountReviews({ sectionKey: 'testimonials', defaults: SAMPLE_DEFAULTS })
43
+
44
+ expect(api.reviews.value).toEqual([])
45
+ expect(api.hasReviews.value).toBe(false)
46
+ expect(api.count.value).toBe(0)
47
+ wrapper.unmount()
48
+ })
49
+
50
+ it('production build with no reviews for the section renders empty, NOT the defaults', () => {
51
+ vi.stubEnv('DEV', false)
52
+ vi.stubGlobal('__DCS_CONTENT__', { global: {}, pages: {} })
53
+ const { api, wrapper } = mountReviews({ sectionKey: 'testimonials', defaults: SAMPLE_DEFAULTS })
54
+
55
+ expect(api.reviews.value).toEqual([])
56
+ expect(api.hasReviews.value).toBe(false)
57
+ wrapper.unmount()
58
+ })
59
+
60
+ it('production build DOES render the real curated reviews from the content define', () => {
61
+ vi.stubEnv('DEV', false)
62
+ vi.stubGlobal('__DCS_CONTENT__', {
63
+ global: {
64
+ 'reviews.testimonials.items': [
65
+ { id: 'real-1', platform: 'google', rating: 5, authorName: 'Real Reviewer', text: 'Genuine review' },
66
+ ],
67
+ },
68
+ pages: {},
69
+ })
70
+ const { api, wrapper } = mountReviews({ sectionKey: 'testimonials', defaults: SAMPLE_DEFAULTS })
71
+
72
+ expect(api.reviews.value).toHaveLength(1)
73
+ expect(api.reviews.value[0].authorName).toBe('Real Reviewer')
74
+ expect(api.hasReviews.value).toBe(true)
75
+ wrapper.unmount()
76
+ })
77
+
78
+ it('dev build still surfaces the sample defaults (DX affordance)', () => {
79
+ vi.stubEnv('DEV', true)
80
+ const { api, wrapper } = mountReviews({ sectionKey: 'testimonials', defaults: SAMPLE_DEFAULTS })
81
+
82
+ expect(api.reviews.value).toHaveLength(1)
83
+ expect(api.reviews.value[0].id).toBe('seed-1')
84
+ wrapper.unmount()
85
+ })
86
+ })
@@ -25,6 +25,38 @@ function withoutAuthorPhotos(items: ReviewItem[]): ReviewItem[] {
25
25
  }))
26
26
  }
27
27
 
28
+ /**
29
+ * True only in a Vite dev build. Vite statically replaces `import.meta.env.DEV`
30
+ * with a boolean literal (`true` in dev, `false` in production). The
31
+ * `typeof import.meta` guard + try/catch keep this safe if the package is ever
32
+ * SSR-externalized (where `import.meta.env` is undefined at the Node runtime and
33
+ * accessing it would throw) — in that case we deliberately return `false` so the
34
+ * production-safe branch is taken.
35
+ */
36
+ function isDevBuild(): boolean {
37
+ try {
38
+ if (typeof import.meta !== 'undefined' && import.meta.env) {
39
+ return import.meta.env.DEV === true
40
+ }
41
+ } catch {
42
+ // import.meta.env unavailable (externalized SSR / non-Vite host) → prod-safe.
43
+ }
44
+ return false
45
+ }
46
+
47
+ /**
48
+ * Honesty guard: consumer-supplied `defaults` are a DEV-ONLY affordance so a
49
+ * freshly-scaffolded site shows sample reviews while it is being wired up. In a
50
+ * production build they must NEVER render — an SSR production pass emitting
51
+ * placeholder reviews is exactly the fabricated-social-proof / hydration bug
52
+ * this guards against (the content define can be missing server-side if the cms
53
+ * package is SSR-externalized). Production therefore falls back to an honest
54
+ * empty state; real reviews always come from `.dcs/content.yaml`.
55
+ */
56
+ function fallbackReviews(defaults: ReviewItem[]): ReviewItem[] {
57
+ return isDevBuild() ? withoutAuthorPhotos(defaults) : []
58
+ }
59
+
28
60
  export interface UseReviewContentConfig {
29
61
  /** The section key matching the data-dcs-reviews attribute value */
30
62
  sectionKey: string
@@ -130,7 +162,7 @@ export function useReviewContent(config: UseReviewContentConfig): UseReviewConte
130
162
  }
131
163
 
132
164
  if (typeof __DCS_CONTENT__ === 'undefined' || __DCS_CONTENT__ == null) {
133
- return withoutAuthorPhotos(defaults)
165
+ return fallbackReviews(defaults)
134
166
  }
135
167
 
136
168
  let reviewData: unknown = null
@@ -146,7 +178,7 @@ export function useReviewContent(config: UseReviewContentConfig): UseReviewConte
146
178
  }
147
179
 
148
180
  if (!reviewData || !Array.isArray(reviewData)) {
149
- return withoutAuthorPhotos(defaults)
181
+ return fallbackReviews(defaults)
150
182
  }
151
183
 
152
184
  return normalizeReviewList(reviewData)
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { fetchSiteVisitorSession } from './useSiteVisitorSession'
3
+
4
+ /**
5
+ * The anonymous session probe is the common case and MUST be silent — no
6
+ * console.error / console.warn — whether the server answers with the new
7
+ * `200 { visitor: null }` contract or an old server's `401`. A red console
8
+ * error on every signed-out page load is the console-noise this fixes.
9
+ */
10
+
11
+ function installConsoleSpies() {
12
+ return {
13
+ error: vi.spyOn(console, 'error').mockImplementation(() => {}),
14
+ warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
15
+ }
16
+ }
17
+
18
+ function expectSilent(spies: ReturnType<typeof installConsoleSpies>) {
19
+ expect(spies.error).not.toHaveBeenCalled()
20
+ expect(spies.warn).not.toHaveBeenCalled()
21
+ }
22
+
23
+ afterEach(() => {
24
+ vi.unstubAllGlobals()
25
+ vi.restoreAllMocks()
26
+ })
27
+
28
+ describe('fetchSiteVisitorSession — anonymous probe is silent', () => {
29
+ it('new server 200 {visitor:null} → signed out, no console noise', async () => {
30
+ const spies = installConsoleSpies()
31
+ vi.stubGlobal(
32
+ 'fetch',
33
+ vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ visitor: null }) }),
34
+ )
35
+
36
+ const result = await fetchSiteVisitorSession()
37
+
38
+ expect(result.visitor).toBeNull()
39
+ expect(result.authenticated).toBe(false)
40
+ expectSilent(spies)
41
+ })
42
+
43
+ it('old server 401 → signed out, no console noise', async () => {
44
+ const spies = installConsoleSpies()
45
+ vi.stubGlobal(
46
+ 'fetch',
47
+ vi.fn().mockResolvedValue({
48
+ ok: false,
49
+ status: 401,
50
+ json: async () => ({ error: 'Not authenticated' }),
51
+ }),
52
+ )
53
+
54
+ const result = await fetchSiteVisitorSession()
55
+
56
+ expect(result.visitor).toBeNull()
57
+ expect(result.authenticated).toBe(false)
58
+ expectSilent(spies)
59
+ })
60
+
61
+ it('network error → signed out, no console noise', async () => {
62
+ const spies = installConsoleSpies()
63
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
64
+
65
+ const result = await fetchSiteVisitorSession()
66
+
67
+ expect(result.visitor).toBeNull()
68
+ expect(result.authenticated).toBe(false)
69
+ expectSilent(spies)
70
+ })
71
+ })
72
+
73
+ describe('fetchSiteVisitorSession — authenticated shapes', () => {
74
+ it('200 {visitor:{...}} → returns the visitor', async () => {
75
+ vi.stubGlobal(
76
+ 'fetch',
77
+ vi.fn().mockResolvedValue({
78
+ ok: true,
79
+ status: 200,
80
+ json: async () => ({
81
+ visitor: { id: 'g-1', email: 'v@example.com', name: 'Visitor', createdAt: '2026-01-01T00:00:00Z' },
82
+ }),
83
+ }),
84
+ )
85
+
86
+ const result = await fetchSiteVisitorSession()
87
+
88
+ expect(result.authenticated).toBe(true)
89
+ expect(result.visitor?.email).toBe('v@example.com')
90
+ expect(result.visitor?.name).toBe('Visitor')
91
+ })
92
+
93
+ it('contracts-spec shape {authenticated,user:{...}} → returns the visitor', async () => {
94
+ vi.stubGlobal(
95
+ 'fetch',
96
+ vi.fn().mockResolvedValue({
97
+ ok: true,
98
+ status: 200,
99
+ json: async () => ({ authenticated: true, user: { email: 'u@example.com', name: 'User' } }),
100
+ }),
101
+ )
102
+
103
+ const result = await fetchSiteVisitorSession()
104
+
105
+ expect(result.authenticated).toBe(true)
106
+ expect(result.visitor?.email).toBe('u@example.com')
107
+ })
108
+
109
+ it('probes the slug-free /site-auth/session path with credentials', async () => {
110
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ visitor: null }) })
111
+ vi.stubGlobal('fetch', fetchMock)
112
+
113
+ await fetchSiteVisitorSession()
114
+
115
+ expect(fetchMock).toHaveBeenCalledTimes(1)
116
+ const [url, init] = fetchMock.mock.calls[0]
117
+ expect(url).toBe('/api/v1/site-auth/session')
118
+ expect(init.credentials).toBe('include')
119
+ })
120
+ })
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Site-visitor session probe client.
3
+ *
4
+ * Customer sites poll "who is signed in?" on load to render the right auth UI.
5
+ * Signed-out is the EXPECTED state for the vast majority of visits, so this
6
+ * client treats it as normal and never logs:
7
+ *
8
+ * - New servers answer an anonymous probe with `200 { "visitor": null }`.
9
+ * - Old servers (pre-fix) answer an anonymous probe with `401`.
10
+ *
11
+ * Sites bump `@duffcloudservices/cms` independently of server deploys, so this
12
+ * client must accept BOTH shapes and stay silent on the anonymous path — a
13
+ * `console.error`/`console.warn` on every signed-out page load is exactly the
14
+ * console-noise this fixes. A genuine signed-in visitor is returned as a
15
+ * normalized `SiteVisitor`; anything else resolves to `null`.
16
+ */
17
+ import { ref, computed, onMounted, type Ref, type ComputedRef } from 'vue'
18
+
19
+ /** A signed-in site visitor. */
20
+ export interface SiteVisitor {
21
+ id?: string
22
+ email: string
23
+ name: string
24
+ picture?: string
25
+ createdAt?: string
26
+ }
27
+
28
+ export interface SiteVisitorSessionResult {
29
+ /** The signed-in visitor, or `null` when signed out. */
30
+ visitor: SiteVisitor | null
31
+ /** Convenience flag — `true` iff a visitor is present. */
32
+ authenticated: boolean
33
+ }
34
+
35
+ export interface FetchSiteVisitorSessionOptions {
36
+ /** API base (default `/api/v1`, same-origin via Front Door). */
37
+ apiBaseUrl?: string
38
+ /** Optional AbortSignal to cancel an in-flight probe. */
39
+ signal?: AbortSignal
40
+ }
41
+
42
+ const SIGNED_OUT: SiteVisitorSessionResult = { visitor: null, authenticated: false }
43
+
44
+ const apiBase = (value?: string): string => (value ?? '/api/v1').replace(/\/$/u, '')
45
+
46
+ /**
47
+ * Normalize the various session-response shapes into a `SiteVisitor | null`.
48
+ * Accepts the primary/legacy `{ visitor: {...} | null }` envelope and the
49
+ * contracts-spec `{ authenticated, user: {...} }` shape defensively.
50
+ */
51
+ function extractVisitor(data: unknown): SiteVisitor | null {
52
+ if (!data || typeof data !== 'object') {
53
+ return null
54
+ }
55
+ const obj = data as Record<string, unknown>
56
+ const raw = obj.visitor ?? obj.user
57
+ if (!raw || typeof raw !== 'object') {
58
+ return null
59
+ }
60
+ const v = raw as Record<string, unknown>
61
+ const email = typeof v.email === 'string' ? v.email : ''
62
+ if (!email) {
63
+ return null
64
+ }
65
+ return {
66
+ id: typeof v.id === 'string' ? v.id : undefined,
67
+ email,
68
+ name: typeof v.name === 'string' ? v.name : email,
69
+ picture: typeof v.picture === 'string' ? v.picture : undefined,
70
+ createdAt: typeof v.createdAt === 'string' ? v.createdAt : undefined,
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Fetch the current site-visitor session. Never throws and never logs for the
76
+ * anonymous case: any network error, non-OK status (incl. a legacy `401`), or
77
+ * `{ visitor: null }` body resolves to a signed-out result.
78
+ */
79
+ export async function fetchSiteVisitorSession(
80
+ options: FetchSiteVisitorSessionOptions = {},
81
+ ): Promise<SiteVisitorSessionResult> {
82
+ const base = apiBase(options.apiBaseUrl)
83
+
84
+ let response: Response
85
+ try {
86
+ response = await fetch(`${base}/site-auth/session`, {
87
+ method: 'GET',
88
+ credentials: 'include',
89
+ headers: { Accept: 'application/json' },
90
+ signal: options.signal,
91
+ })
92
+ } catch {
93
+ // Network error / aborted / CORS — signed-out is the safe probe assumption.
94
+ return SIGNED_OUT
95
+ }
96
+
97
+ // Old servers reply 401 to an anonymous probe; new servers reply 200-null.
98
+ // Treat any non-OK status as signed-out WITHOUT logging.
99
+ if (!response.ok) {
100
+ return SIGNED_OUT
101
+ }
102
+
103
+ let data: unknown
104
+ try {
105
+ data = await response.json()
106
+ } catch {
107
+ return SIGNED_OUT
108
+ }
109
+
110
+ const visitor = extractVisitor(data)
111
+ return { visitor, authenticated: visitor !== null }
112
+ }
113
+
114
+ export interface UseSiteVisitorSessionOptions extends FetchSiteVisitorSessionOptions {
115
+ /** Probe automatically on mount (browser only). Default `true`. */
116
+ fetchOnMount?: boolean
117
+ }
118
+
119
+ export interface UseSiteVisitorSessionReturn {
120
+ /** The signed-in visitor, or `null` when signed out / not yet loaded. */
121
+ visitor: Ref<SiteVisitor | null>
122
+ /** `true` iff a visitor is present. */
123
+ isAuthenticated: ComputedRef<boolean>
124
+ /** `true` while a probe is in flight. */
125
+ isLoading: Ref<boolean>
126
+ /** Re-run the probe. */
127
+ refresh: () => Promise<void>
128
+ }
129
+
130
+ /**
131
+ * Vue composable wrapper around {@link fetchSiteVisitorSession}. Reactive
132
+ * `visitor` / `isAuthenticated` / `isLoading`, and probes on mount by default.
133
+ */
134
+ export function useSiteVisitorSession(
135
+ options: UseSiteVisitorSessionOptions = {},
136
+ ): UseSiteVisitorSessionReturn {
137
+ const { fetchOnMount = true, ...fetchOptions } = options
138
+
139
+ const visitor = ref<SiteVisitor | null>(null)
140
+ const isLoading = ref(false)
141
+ const isAuthenticated = computed(() => visitor.value !== null)
142
+
143
+ async function refresh(): Promise<void> {
144
+ isLoading.value = true
145
+ try {
146
+ const result = await fetchSiteVisitorSession(fetchOptions)
147
+ visitor.value = result.visitor
148
+ } finally {
149
+ isLoading.value = false
150
+ }
151
+ }
152
+
153
+ if (fetchOnMount && typeof window !== 'undefined') {
154
+ onMounted(() => {
155
+ void refresh()
156
+ })
157
+ }
158
+
159
+ return { visitor, isAuthenticated, isLoading, refresh }
160
+ }
@@ -1,3 +0,0 @@
1
-
2
- //# sourceMappingURL=chunk-KCWMS7P4.js.map
3
- //# sourceMappingURL=chunk-KCWMS7P4.js.map