@duffcloudservices/cms 0.11.0 → 0.13.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 (41) hide show
  1. package/README.md +244 -8
  2. package/dist/chunk-A5F4C72F.js +500 -0
  3. package/dist/chunk-A5F4C72F.js.map +1 -0
  4. package/dist/{chunk-F3EIWEZD.js → chunk-HVSF23P7.js} +971 -73
  5. package/dist/chunk-HVSF23P7.js.map +1 -0
  6. package/dist/editor/editorBridge.d.ts +53 -1
  7. package/dist/editor/editorBridge.js +141 -5
  8. package/dist/editor/editorBridge.js.map +1 -1
  9. package/dist/headHonesty-OzxvLuwd.d.ts +222 -0
  10. package/dist/index.d.ts +369 -22
  11. package/dist/index.js +424 -22
  12. package/dist/index.js.map +1 -1
  13. package/dist/installSeoHead-kWQwObez.d.ts +627 -0
  14. package/dist/plugins/index.d.ts +173 -6
  15. package/dist/plugins/index.js +628 -54
  16. package/dist/plugins/index.js.map +1 -1
  17. package/dist/seo/index.d.ts +763 -4
  18. package/dist/seo/index.js +2 -2
  19. package/dist/{vitepressTransform-DfmABXmK.d.ts → vitepressTransform-JG_zlaux.d.ts} +99 -6
  20. package/package.json +26 -16
  21. package/src/components/DcsCallButton.test.ts +58 -0
  22. package/src/components/DcsCallButton.vue +19 -4
  23. package/src/components/DcsReviewShowcase.vue +5 -1
  24. package/src/components/LiteMediaEmbed.vue +3 -3
  25. package/src/components/ManagedImage.test.ts +94 -0
  26. package/src/components/ManagedImage.vue +58 -6
  27. package/src/components/PreviewRibbon.vue +4 -1
  28. package/src/composables/useConversionTracking.test.ts +492 -0
  29. package/src/composables/useConversionTracking.ts +770 -0
  30. package/src/composables/useReleaseNotes.ts +7 -1
  31. package/src/composables/useResponsiveImage.ts +6 -0
  32. package/src/composables/useSEO.applyHead.test.ts +150 -0
  33. package/src/composables/useSEO.ts +63 -17
  34. package/src/composables/useSiteVersion.ts +4 -1
  35. package/src/composables/useSiteVisitorSession.test.ts +56 -0
  36. package/src/composables/useSiteVisitorSession.ts +39 -3
  37. package/src/composables/useTextContent.ts +9 -1
  38. package/dist/chunk-DAYLLSEE.js +0 -3
  39. package/dist/chunk-DAYLLSEE.js.map +0 -1
  40. package/dist/chunk-F3EIWEZD.js.map +0 -1
  41. package/dist/spliceHeadHtml-CsBEucGy.d.ts +0 -254
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import * as vue from 'vue';
2
2
  import { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
3
- import { U as UseSeoReturn, G as GlobalSeoConfig } from './vitepressTransform-DfmABXmK.js';
4
- export { A as AI_BOTS, y as BlogMeta, x as BreadcrumbCrumb, v as BuildLlmsParams, u as BuildRobotsParams, B as BuildSitemapParams, E as ContentConfig, C as CreateSeoTransformPageDataOptions, D as DcsRobotsOptions, F as FaqSource, X as HeadOverrides, P as PageSeoConfig, R as ResolvedPageOverrides, T as ResolvedPageSeo, z as ReviewSource, w as SchemaObject, O as SeoAlternateConfig, I as SeoAuthorConfig, H as SeoConfiguration, K as SeoImagesConfig, L as SeoOpenGraphConfig, S as SeoPageContext, r as SeoPageTypeRule, N as SeoSchemaConfig, J as SeoSocialConfig, M as SeoTwitterConfig, Q as SeoVerificationConfig, W as UseSeoConfig, t as VitePressHeadConfig, V as VitePressPageData, q as absolutizeUrl, j as breadcrumbTrailFromRoute, k as buildBlogPosting, h as buildBreadcrumbList, l as buildFaqPage, g as buildGlobalGraph, f as buildLlmsTxt, m as buildReviewSchemaParts, e as buildRobotsTxt, a as buildSitemapXml, b as buildVitePressSeoHead, c as createSeoTransformPageData, d as defaultRelativePathToRoute, o as filterRealFaq, n as filterRealReviews, p as findReviewItemsForPage, i as isRouteIndexable, s as slugToTitle } from './vitepressTransform-DfmABXmK.js';
5
- export { h as HeadLinkTag, H as HeadMetaTag, i as HeadScriptTag, j as HeadTagOverrides, R as ResolvedHeadTags, b as buildHeadTags, f as escapeJsonLd, g as generateJsonLd, a as generateOpenGraphMeta, c as generateTwitterMeta, d as renderHeadTags, r as resolvePageSeo, s as spliceHeadHtml, e as stripManagedHeadTags } from './spliceHeadHtml-CsBEucGy.js';
3
+ import { U as UseSeoReturn, G as GlobalSeoConfig } from './vitepressTransform-JG_zlaux.js';
4
+ export { A as AI_BOTS, y as BlogMeta, x as BreadcrumbCrumb, v as BuildLlmsParams, u as BuildRobotsParams, B as BuildSitemapParams, E as ContentConfig, C as CreateSeoTransformPageDataOptions, D as DcsRobotsOptions, F as FaqSource, X as HeadOverrides, P as PageSeoConfig, R as ResolvedPageOverrides, T as ResolvedPageSeo, z as ReviewSource, w as SchemaObject, O as SeoAlternateConfig, I as SeoAuthorConfig, H as SeoConfiguration, K as SeoImagesConfig, L as SeoOpenGraphConfig, S as SeoPageContext, r as SeoPageTypeRule, N as SeoSchemaConfig, J as SeoSocialConfig, M as SeoTwitterConfig, Q as SeoVerificationConfig, W as UseSeoConfig, t as VitePressHeadConfig, V as VitePressPageData, q as absolutizeUrl, j as breadcrumbTrailFromRoute, k as buildBlogPosting, h as buildBreadcrumbList, l as buildFaqPage, g as buildGlobalGraph, f as buildLlmsTxt, m as buildReviewSchemaParts, e as buildRobotsTxt, a as buildSitemapXml, b as buildVitePressSeoHead, c as createSeoTransformPageData, d as defaultRelativePathToRoute, o as filterRealFaq, n as filterRealReviews, p as findReviewItemsForPage, i as isRouteIndexable, s as slugToTitle } from './vitepressTransform-JG_zlaux.js';
5
+ export { j as HeadLinkTag, H as HeadMetaTag, k as HeadScriptTag, l as HeadTagOverrides, I as InstallSeoHeadOptions, R as ResolvedHeadTags, o as SeoHeadClientLike, p as SeoHeadEntryLike, q as SeoHeadInput, t as SeoHeadPageRoute, u as SeoHeadPagesManifest, v as SeoHeadResolution, w as SeoHeadResolutionReason, m as SeoHeadRouteLike, S as SeoHeadRouterLike, b as buildHeadTags, h as buildSeoHeadRouteMap, f as escapeJsonLd, g as generateJsonLd, a as generateOpenGraphMeta, c as generateTwitterMeta, i as installSeoHead, n as normalizeSeoHeadPath, d as renderHeadTags, r as resolvePageSeo, s as spliceHeadHtml, e as stripManagedHeadTags } from './installSeoHead-kWQwObez.js';
6
6
  import { ImageContext, ResponsiveImageResult } from '@duffcloudservices/cms-core';
7
7
  export { ImageContext, ResponsiveImageOptions, ResponsiveImageResult, ResponsiveSource, isCdnAssetUrl, resolveResponsiveImage } from '@duffcloudservices/cms-core';
8
8
 
@@ -119,6 +119,12 @@ declare function useTextContent(config: TextContentConfig): TextContentReturn;
119
119
  * module so that the build-time static-HTML emitter (`dcsSeoPlugin`) produces
120
120
  * byte-identical output. This composable is a thin Vue/unhead wrapper over it.
121
121
  *
122
+ * THE HEAD-AUTHORITY CONTRACT (C-356). `.dcs/seo.yaml` is the ONLY writer of
123
+ * the managed head fields. `applyHead()` RE-ASSERTS the baked head at runtime;
124
+ * it never AUTHORS one, so it takes no arguments. Full text + reasoning:
125
+ * `.docs/plans/dynamic-site-resolution/README.md` § "The head-authority
126
+ * contract (C-356)".
127
+ *
122
128
  * @example
123
129
  * ```vue
124
130
  * <script setup lang="ts">
@@ -126,16 +132,15 @@ declare function useTextContent(config: TextContentConfig): TextContentReturn;
126
132
  *
127
133
  * const { applyHead, getSchema, config } = useSEO('home')
128
134
  *
129
- * // Apply all meta tags
135
+ * // Re-assert the baked head for this route. No arguments — ever.
130
136
  * applyHead()
131
- *
132
- * // Or customize before applying
133
- * applyHead({
134
- * title: 'Custom Override Title',
135
- * schemas: [...getSchema(), customSchema]
136
- * })
137
137
  * </script>
138
138
  * ```
139
+ *
140
+ * Need a different title? Change it in `.dcs/seo.yaml` (or in the portal SEO
141
+ * editor, which writes it). A value hardcoded here is a SECOND writer, and a
142
+ * second writer is a divergence by construction — whether or not today's two
143
+ * values happen to agree.
139
144
  */
140
145
 
141
146
  /**
@@ -427,6 +432,10 @@ interface UseResponsiveImageOptions {
427
432
  sizes?: MaybeRefOrGetter<string | undefined>;
428
433
  /** Skip variant resolution and use the original URL only. */
429
434
  original?: MaybeRefOrGetter<boolean | undefined>;
435
+ /** Intrinsic width — emitted as a layout-shift hint when paired with `height`. */
436
+ width?: MaybeRefOrGetter<number | undefined>;
437
+ /** Intrinsic height — emitted as a layout-shift hint when paired with `width`. */
438
+ height?: MaybeRefOrGetter<number | undefined>;
430
439
  }
431
440
  /**
432
441
  * Reactively resolves responsive image metadata for a DCS CDN URL.
@@ -473,22 +482,347 @@ interface UseReviewContentReturn {
473
482
  }
474
483
  declare function useReviewContent(config: UseReviewContentConfig): UseReviewContentReturn;
475
484
 
485
+ /** How a clicked element was classified. */
486
+ type ConversionInteractionType = 'booking' | 'phone' | 'email' | 'form_submit' | 'social' | 'external' | 'internal' | 'button';
487
+ /**
488
+ * The interaction types that ARE the money moment — the ones an owner report counts.
489
+ *
490
+ * Everything else (`social`, `external`, `internal`, `button`) is navigation telemetry and
491
+ * is still captured, but it must never be summed into "conversions". Keeping the set here,
492
+ * rather than in each report's query, means one edit changes every consumer.
493
+ */
494
+ declare const CONVERSION_INTERACTION_TYPES: readonly ConversionInteractionType[];
495
+ /** Whether an interaction type counts as a conversion. */
496
+ declare function isConversionType(type: ConversionInteractionType): boolean;
497
+ /** A captured conversion event, in App Insights `trackEvent` shape. */
498
+ interface ConversionEvent {
499
+ /** Event name — `site_interaction` by default (see {@link ConversionTrackingOptions.eventName}). */
500
+ name: string;
501
+ /** Flat string properties; App Insights `customDimensions`. */
502
+ properties: {
503
+ interaction_type: ConversionInteractionType;
504
+ /**
505
+ * `'true'` when {@link isConversionType} holds. A string, not a boolean, because App
506
+ * Insights `customDimensions` and GA4 event params are both string maps — so the
507
+ * owner-report query is one predicate (`is_conversion == "true"`) instead of an
508
+ * interaction-type IN-list that every new report has to remember to keep in sync.
509
+ */
510
+ is_conversion: string;
511
+ /** Visible label / aria-label of the clicked element, truncated. */
512
+ label: string;
513
+ /**
514
+ * Destination, query string and fragment stripped, and REDACTED for contact schemes:
515
+ * a `tel:` / `sms:` / `mailto:` href becomes `tel:#<digest>` — never the raw number or
516
+ * address. See {@link redactHref}. Empty for buttons and form submits.
517
+ */
518
+ href: string;
519
+ /** URL scheme of the destination including the colon (`tel:`, `https:`), or `''`. */
520
+ href_scheme: string;
521
+ /** Host of the destination, or `''` for buttons, form submits and contact schemes. */
522
+ href_host: string;
523
+ /**
524
+ * Short digest of the contact target (the phone number / email address), or `''` for
525
+ * everything else. Lets a report say "CTA A got 12 taps, CTA B got 3" without ever
526
+ * storing the contact string itself.
527
+ */
528
+ href_hash: string;
529
+ /** Path of the page the click happened on. */
530
+ page_path: string;
531
+ /** Host of the page the click happened on. */
532
+ host: string;
533
+ /** Schema version, so a report can tell old rows from new ones. */
534
+ capture_version: string;
535
+ };
536
+ }
537
+ /** A telemetry transport. Typically `(e) => telemetry.trackEvent(e)`. */
538
+ type ConversionSink = (event: ConversionEvent) => void;
539
+ interface ConversionTrackingOptions {
540
+ /**
541
+ * Where to send events. Optional on purpose — omit it when the telemetry SDK is
542
+ * deferred, and call {@link attachConversionSink} once it has loaded. Events captured
543
+ * in the meantime are buffered, not dropped.
544
+ */
545
+ sink?: ConversionSink;
546
+ /**
547
+ * Fire-and-forget transports that receive every event AS WELL AS `sink`, and that do NOT
548
+ * count as "a sink exists" for buffering purposes.
549
+ *
550
+ * This distinction is load-bearing. GA4's `gtag` is a mirror: the deploy injects it as a
551
+ * synchronous inline snippet, so it is either there when the click happens or the hit is
552
+ * genuinely unavailable — there is nothing to wait for. App Insights is a `sink`: it boots
553
+ * on an idle callback minutes later, which is exactly what the buffer exists to survive.
554
+ * Treating GA4 as a `sink` would have satisfied the "do we have somewhere to send this?"
555
+ * test on every site and quietly disabled the deferred-SDK interlock.
556
+ */
557
+ mirrors?: ConversionSink[];
558
+ /**
559
+ * Custom event name. Defaults to `site_interaction` — the name Just Posh has been
560
+ * emitting since 2026-04, so its history stays one continuous series. Pass
561
+ * `'dcs_conversion'` on a site with no existing history if you prefer the canonical name.
562
+ */
563
+ eventName?: string;
564
+ /**
565
+ * Extra hostnames to treat as booking destinations, on top of {@link DEFAULT_BOOKING_HOSTS}.
566
+ * Matched on host suffix, so `vagaro.com` also matches `www.vagaro.com`.
567
+ */
568
+ bookingHosts?: string[];
569
+ /**
570
+ * Same-origin paths that mean "booking" (e.g. a self-hosted `/book`). Matched as a
571
+ * prefix on the pathname.
572
+ */
573
+ bookingPaths?: string[];
574
+ /** Extra social hostnames on top of {@link DEFAULT_SOCIAL_HOSTS}. */
575
+ socialHosts?: string[];
576
+ /** Max events held while no sink is attached. Default 50 — bounded so a bot cannot grow it. */
577
+ bufferLimit?: number;
578
+ /** Drop untrusted (script-dispatched) clicks. Default `false`. */
579
+ requireTrusted?: boolean;
580
+ /** Document to bind to. Defaults to the ambient `document`. Injected in tests. */
581
+ target?: Document;
582
+ /**
583
+ * Also capture managed-form submissions as `form_submit`. Default `true`.
584
+ *
585
+ * A form submit is a conversion on every DCS site that has a form, and it is the one
586
+ * affordance a click listener alone cannot see honestly: clicking "Send" on a form that
587
+ * then fails validation is not a lead. So the `submit` event — not the click — is
588
+ * authoritative, and clicks on submit controls are deliberately dropped to keep the two
589
+ * from counting the same action twice.
590
+ */
591
+ captureFormSubmits?: boolean;
592
+ /**
593
+ * Honour the visitor's Do Not Track signal. Default `true`.
594
+ *
595
+ * With DNT on, `start()` binds nothing at all — no listener, no buffer, no event. This
596
+ * is a measurement rail, not a consent platform: if a site ever grows a real consent
597
+ * banner, gate {@link installConversionCapture} on it rather than weakening this.
598
+ */
599
+ respectDoNotTrack?: boolean;
600
+ /**
601
+ * Bind a second delegated listener even though one is already live on this document.
602
+ *
603
+ * Off by default and it should stay off. Two delegated listeners on one document count
604
+ * every click twice — the same defect class C-288 measured as 52% duplicate page views
605
+ * on a live customer site, which the portal then reported to the owner as traffic. If
606
+ * you are reaching for this, you almost certainly want `stop()` on the existing tracker.
607
+ */
608
+ force?: boolean;
609
+ }
610
+ /**
611
+ * Schema version stamped on every captured event. Bump on a breaking property change.
612
+ *
613
+ * `2` (C-326): `href` is redacted for `tel:`/`sms:`/`mailto:`, and `is_conversion`,
614
+ * `href_scheme` + `href_hash` were added. Version `1` rows carry raw contact hrefs and no
615
+ * conversion flag, so a report spanning the boundary must branch on this.
616
+ */
617
+ declare const CONVERSION_CAPTURE_VERSION = "2";
618
+ /**
619
+ * Booking/scheduling vendors seen across the DCS fleet plus the common SMB schedulers.
620
+ * Host-suffix matched. Add per-site extras via `bookingHosts` rather than editing this.
621
+ */
622
+ declare const DEFAULT_BOOKING_HOSTS: readonly string[];
623
+ /** Social destinations. Host-suffix matched. */
624
+ declare const DEFAULT_SOCIAL_HOSTS: readonly string[];
625
+ /**
626
+ * FNV-1a (32-bit), base36. Synchronous on purpose: this runs inside a click handler, where
627
+ * `crypto.subtle` — the only real hash a browser offers — is async and would force the
628
+ * event to be built after the navigation has already started.
629
+ *
630
+ * BE HONEST ABOUT WHAT THIS IS. It is not anonymisation. A site publishes two or three
631
+ * phone numbers, so anybody holding the site could brute-force the digest back in
632
+ * milliseconds. What it buys is real but narrow: the contact string never lands in an
633
+ * analytics store (GA4 forbids PII in event params outright), a support screenshot of the
634
+ * events table cannot leak a customer's mailbox, and the value is still stable enough to
635
+ * answer "which CTA did they tap". Do not describe it as anything more than that.
636
+ */
637
+ declare function hashTarget(value: string): string;
638
+ /** The scheme of an href, including the colon (`tel:`, `https:`), or `''`. */
639
+ declare function hrefScheme(href: string, pageHost: string): string;
640
+ /**
641
+ * Split an href into the parts that are safe to emit.
642
+ *
643
+ * For `tel:` / `sms:` / `mailto:` the target is a person's or business's contact string, so
644
+ * it is replaced by `<scheme>#<digest>` and the digest is also surfaced on its own. For
645
+ * everything else the href passes through {@link sanitizeUrl} unchanged — a booking URL's
646
+ * path is the useful part and its query (which CAN carry a name or email) is already gone.
647
+ */
648
+ declare function redactHref(href: string, pageHost: string): {
649
+ href: string;
650
+ scheme: string;
651
+ hash: string;
652
+ };
653
+ /**
654
+ * Whether this element is the control that submits a form.
655
+ *
656
+ * Clicks on these are dropped so the `submit` event can be the single source of truth —
657
+ * see {@link ConversionTrackingOptions.captureFormSubmits}. Note the HTML default: a
658
+ * `<button>` inside a form with no `type` IS a submit button, which is exactly how every
659
+ * `<DcsForm>` renders its action.
660
+ */
661
+ declare function isSubmitControl(el: Element): boolean;
662
+ /** Read the visitor's Do Not Track signal across the three places browsers have put it. */
663
+ declare function doNotTrackEnabled(): boolean;
664
+ /**
665
+ * Classify a link.
666
+ *
667
+ * ORDER IS LOAD-BEARING and is asserted by tests. Protocol wins first (a `tel:` href has
668
+ * no host to match on), then booking — *before* the same-origin and social checks — so a
669
+ * self-hosted `/book` path and a booking vendor that also runs a social profile are both
670
+ * attributed to revenue rather than to `internal` / `social`.
671
+ */
672
+ declare function classifyHref(href: string, opts: {
673
+ pageHost: string;
674
+ bookingHosts: readonly string[];
675
+ bookingPaths: readonly string[];
676
+ socialHosts: readonly string[];
677
+ }): ConversionInteractionType;
678
+ /** The object returned by {@link createConversionTracker}. */
679
+ interface ConversionTracker {
680
+ /** Bind the capture-phase click listener. Idempotent. */
681
+ start(): void;
682
+ /** Unbind and clear the buffer. Idempotent. */
683
+ stop(): void;
684
+ /** Attach (or replace) the sink and immediately drain anything buffered. */
685
+ attachSink(sink: ConversionSink): void;
686
+ /** Events currently held because no sink is attached. Read-only copy. */
687
+ buffered(): ConversionEvent[];
688
+ /** Capture a click target directly. Exposed for tests and manual instrumentation. */
689
+ capture(element: Element): ConversionEvent | null;
690
+ }
691
+ /**
692
+ * Attach a sink to every live tracker and drain their buffers.
693
+ *
694
+ * Call this from a deferred/lazy App Insights loader once the SDK is ready:
695
+ *
696
+ * ```ts
697
+ * const { ApplicationInsights } = await import('@microsoft/applicationinsights-web')
698
+ * const ai = new ApplicationInsights({ config })
699
+ * ai.loadAppInsights()
700
+ * attachConversionSink((e) => ai.trackEvent({ name: e.name, properties: e.properties }))
701
+ * ```
702
+ *
703
+ * @returns how many trackers received the sink.
704
+ */
705
+ declare function attachConversionSink(sink: ConversionSink): number;
706
+ /**
707
+ * Framework-free conversion tracker. Vue callers usually want
708
+ * {@link useConversionTracking} instead.
709
+ */
710
+ declare function createConversionTracker(options?: ConversionTrackingOptions): ConversionTracker;
711
+ /**
712
+ * Vue composable: start conversion capture for the lifetime of the calling component.
713
+ *
714
+ * YOU PROBABLY DO NOT NEED THIS ANY MORE. Since C-326, importing `@duffcloudservices/cms`
715
+ * auto-installs a tracker (`installConversionCapture`), so every fleet site captures
716
+ * conversions with no site-repo code at all. Calling this on top of that is a no-op with a
717
+ * loud console warning — `start()` refuses to bind a second listener to a document that
718
+ * already has one, because two listeners double every conversion count.
719
+ *
720
+ * It remains exported for the cases the auto-install deliberately does not cover: a site
721
+ * that needs per-site `bookingHosts`/`bookingPaths`, or a non-cms consumer wiring capture
722
+ * by hand. In the first case, prefer configuring the auto-installer:
723
+ *
724
+ * ```ts
725
+ * import { installConversionCapture } from '@duffcloudservices/cms'
726
+ * installConversionCapture({ bookingHosts: ['stridethera.com'], bookingPaths: ['/book'] })
727
+ * ```
728
+ *
729
+ * Returns the tracker so a caller can attach a sink or inspect the buffer. Safe on the
730
+ * server: with no `document`, `start()` is a no-op.
731
+ */
732
+ declare function useConversionTracking(options?: ConversionTrackingOptions): ConversionTracker;
733
+
734
+ /**
735
+ * Fleet-wide conversion capture, installed without asking the site.
736
+ *
737
+ * WHY THIS FILE EXISTS
738
+ * --------------------
739
+ * `useConversionTracking` shipped in @duffcloudservices/cms, was exported from the package
740
+ * index, was documented with an example, and was adopted by ZERO of the eleven fleet sites.
741
+ * Two independent reviews on 2026-07-26 measured what that cost (C-326):
742
+ *
743
+ * - Bryan's Handyman Solutions exists to generate phone calls. It ships five `tel:` CTAs
744
+ * — hero, header, mobile icon, sticky call bar, footer — and every one of them fires
745
+ * nothing. `trackEvent` appears in the bundle exactly once: as the composable's own
746
+ * unused export. Its GA4 property was never created either, so the site has no
747
+ * measurement of any kind for the only action it is built to produce.
748
+ * - Kim Duff Homes, a flagship account, has 6 pageViews in App Insights across 365 days.
749
+ *
750
+ * The lesson is not "write better docs". A capability the site must remember to switch on
751
+ * is a capability that is off. So capture is a SIDE EFFECT of importing the package every
752
+ * fleet site already depends on, and it finds its own transport.
753
+ *
754
+ * HOW A SITE'S EVENTS GET OUT — three sinks, in the order they are usually available
755
+ * ---------------------------------------------------------------------------------
756
+ * 1. GA4 (`window.gtag`). The deploy workflow injects the gtag snippet into every built
757
+ * HTML file when `.dcs/site.yaml` carries a `google_analytics_id`. That means a site
758
+ * with GA4 configured captures conversions with LITERALLY no code change — which is
759
+ * precisely why the empty-`google_analytics_id` launch gate is the other half of C-326.
760
+ * 2. `window.__dcsConversionAttach(sink)` — a one-line, import-free contract published by
761
+ * this module for any telemetry transport that boots later. `@duffcloudservices/telemetry`
762
+ * calls it automatically inside `initialize()`; a site with a bespoke App Insights boot
763
+ * adds the single line. Events captured before that arrive are buffered, not lost.
764
+ * 3. `attachConversionSink(sink)` — the direct API, for callers holding the import.
765
+ *
766
+ * WHERE IT DELIBERATELY DOES NOTHING
767
+ * ----------------------------------
768
+ * - No `document` (SSR, VitePress build, the Playwright prerender pass).
769
+ * - Inside an iframe — the DCS visual editor and preview surfaces frame the site, and
770
+ * an editor clicking around a customer's page is not a lead.
771
+ * - Do Not Track is on.
772
+ * - The page opted out via `window.__dcsConversionOptOut = true` or
773
+ * `<html data-dcs-analytics="off">`.
774
+ *
775
+ * In every one of those cases it binds no listener and buffers nothing.
776
+ */
777
+
778
+ /** Options for {@link installConversionCapture}. */
779
+ interface InstallConversionCaptureOptions extends Omit<ConversionTrackingOptions, 'sink' | 'mirrors' | 'force'> {
780
+ /**
781
+ * Emit through GA4's `window.gtag` when it is present. Default `true`.
782
+ *
783
+ * Looked up at emit time, not install time: the gtag snippet is `async`, so on a fast
784
+ * connection the site's JS can install this before Google's script has defined `gtag`.
785
+ */
786
+ gtag?: boolean;
787
+ }
788
+ /** Whether the page has explicitly asked not to be measured. */
789
+ declare function conversionOptedOut(): boolean;
476
790
  /**
477
- * Site-visitor session probe client.
791
+ * Whether this document is being rendered inside a frame.
478
792
  *
479
- * Customer sites poll "who is signed in?" on load to render the right auth UI.
480
- * Signed-out is the EXPECTED state for the vast majority of visits, so this
481
- * client treats it as normal and never logs:
793
+ * The DCS portal embeds customer sites in an iframe for the visual editor and preview
794
+ * (`dcsEditorPlugin` keys off the same `window.parent !== window` test). Counting an
795
+ * editor's clicks as customer conversions would inflate exactly the number the owner
796
+ * report is supposed to make trustworthy.
797
+ */
798
+ declare function inFrame(): boolean;
799
+ /** Reason the installer declined, or `null` when it installed. */
800
+ type ConversionInstallSkipReason = 'no-document' | 'already-installed' | 'in-frame' | 'do-not-track' | 'opted-out';
801
+ /** Result of an {@link installConversionCapture} call. */
802
+ interface ConversionInstallResult {
803
+ installed: boolean;
804
+ reason: ConversionInstallSkipReason | null;
805
+ tracker: ConversionTracker | null;
806
+ }
807
+ /**
808
+ * Install fleet conversion capture. Idempotent; safe to call from anywhere, any number of
809
+ * times, on the server or in a frame.
482
810
  *
483
- * - New servers answer an anonymous probe with `200 { "visitor": null }`.
484
- * - Old servers (pre-fix) answer an anonymous probe with `401`.
811
+ * Called for its side effect from the package index, so a site gets this by importing
812
+ * `@duffcloudservices/cms` at all. Call it directly only to pass per-site configuration
813
+ * (extra `bookingHosts`, a self-hosted `bookingPaths`) — do that BEFORE the first click,
814
+ * i.e. at app entry, since the first call wins.
815
+ */
816
+ declare function installConversionCapture(options?: InstallConversionCaptureOptions): ConversionInstallResult;
817
+ /** The installed tracker, or `null`. Exposed for tests and diagnostics. */
818
+ declare function installedConversionTracker(): ConversionTracker | null;
819
+ /**
820
+ * Tear the auto-installed tracker down and clear the global flags.
485
821
  *
486
- * Sites bump `@duffcloudservices/cms` independently of server deploys, so this
487
- * client must accept BOTH shapes and stay silent on the anonymous path — a
488
- * `console.error`/`console.warn` on every signed-out page load is exactly the
489
- * console-noise this fixes. A genuine signed-in visitor is returned as a
490
- * normalized `SiteVisitor`; anything else resolves to `null`.
822
+ * Exists for tests and for a site that genuinely needs to re-install with different
823
+ * options. Not part of the normal runtime path.
491
824
  */
825
+ declare function resetConversionCapture(): void;
492
826
 
493
827
  /** A signed-in site visitor. */
494
828
  interface SiteVisitor {
@@ -503,6 +837,14 @@ interface SiteVisitorSessionResult {
503
837
  visitor: SiteVisitor | null;
504
838
  /** Convenience flag — `true` iff a visitor is present. */
505
839
  authenticated: boolean;
840
+ /**
841
+ * `true` when the probe did not reach the platform API at all — the response carried a
842
+ * non-JSON body (C-298 layer 2), i.e. this host does not route `/api/v1/*` to the API.
843
+ * Distinct from an ordinary signed-out visit: a site can render "sign-in temporarily
844
+ * unavailable" instead of a login button that cannot possibly work. Absent/`false` on
845
+ * every normal path, so existing consumers are unaffected.
846
+ */
847
+ apiUnreachable?: boolean;
506
848
  }
507
849
  interface FetchSiteVisitorSessionOptions {
508
850
  /** API base (default `/api/v1`, same-origin via Front Door). */
@@ -527,6 +869,11 @@ interface UseSiteVisitorSessionReturn {
527
869
  isAuthenticated: ComputedRef<boolean>;
528
870
  /** `true` while a probe is in flight. */
529
871
  isLoading: Ref<boolean>;
872
+ /**
873
+ * `true` when the last probe did not reach the platform API (non-JSON body — C-298
874
+ * layer 2). Render "sign-in unavailable" rather than a login button that cannot work.
875
+ */
876
+ apiUnreachable: Ref<boolean>;
530
877
  /** Re-run the probe. */
531
878
  refresh: () => Promise<void>;
532
879
  }
@@ -536,4 +883,4 @@ interface UseSiteVisitorSessionReturn {
536
883
  */
537
884
  declare function useSiteVisitorSession(options?: UseSiteVisitorSessionOptions): UseSiteVisitorSessionReturn;
538
885
 
539
- export { type DcsContentFile, type FetchSiteVisitorSessionOptions, GlobalSeoConfig, type MediaCarouselItem, type ReleaseNote, type ReleaseNotesReturn, type ReviewItem, type SiteVersionReturn, type SiteVisitor, type SiteVisitorSessionResult, type TextContentConfig, type TextContentReturn, type UseMediaCarouselConfig, type UseMediaCarouselReturn, type UseReviewContentConfig, type UseReviewContentReturn, UseSeoReturn, type UseSiteVisitorSessionOptions, type UseSiteVisitorSessionReturn, createSiteSEO, fetchSiteVisitorSession, useMediaCarousel, useReleaseNotes, useResponsiveImage, useReviewContent, useSEO, useSiteVersion, useSiteVisitorSession, useTextContent };
886
+ export { CONVERSION_CAPTURE_VERSION, CONVERSION_INTERACTION_TYPES, type ConversionEvent, type ConversionInstallResult, type ConversionInstallSkipReason, type ConversionInteractionType, type ConversionSink, type ConversionTracker, type ConversionTrackingOptions, DEFAULT_BOOKING_HOSTS, DEFAULT_SOCIAL_HOSTS, type DcsContentFile, type FetchSiteVisitorSessionOptions, GlobalSeoConfig, type InstallConversionCaptureOptions, type MediaCarouselItem, type ReleaseNote, type ReleaseNotesReturn, type ReviewItem, type SiteVersionReturn, type SiteVisitor, type SiteVisitorSessionResult, type TextContentConfig, type TextContentReturn, type UseMediaCarouselConfig, type UseMediaCarouselReturn, type UseReviewContentConfig, type UseReviewContentReturn, UseSeoReturn, type UseSiteVisitorSessionOptions, type UseSiteVisitorSessionReturn, attachConversionSink, classifyHref, conversionOptedOut, createConversionTracker, createSiteSEO, doNotTrackEnabled, fetchSiteVisitorSession, hashTarget, hrefScheme, inFrame, installConversionCapture, installedConversionTracker, isConversionType, isSubmitControl, redactHref, resetConversionCapture, useConversionTracking, useMediaCarousel, useReleaseNotes, useResponsiveImage, useReviewContent, useSEO, useSiteVersion, useSiteVisitorSession, useTextContent };