@sansavision/yousaid-react 0.0.0-stage → 0.2.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.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Published proof components. They consume the same eligibility-gated render
3
+ * manifest as the <yousaid-proof> Web Component and the headless API: only
4
+ * eligible items, only allowed identity fields, with required disclosures.
5
+ * Widget text follows the `locale` prop, then the page language, then the
6
+ * browser (PROOF_STRINGS in @sansavision/yousaid). Moving formats always have a
7
+ * pause control and stay still for visitors who prefer reduced motion.
8
+ */
9
+ import { type CSSProperties } from "react";
10
+ import { type ProofRender, type ProofRenderItem, type ProofTheme } from "@sansavision/yousaid";
11
+ /**
12
+ * Reports a site conversion (for example "signup", "trial" or "purchase") to
13
+ * every proof placement rendered on this page, so Publish analytics can show
14
+ * conversions that came after a proof impression in the same page load. Names
15
+ * are short lowercase slugs; each is sent once per page load. No cookie or
16
+ * visitor id is involved, and analytics labels the result as an association.
17
+ *
18
+ * const convert = useProofConversion();
19
+ * onSignupSuccess(() => convert("signup"));
20
+ * @experimental
21
+ */
22
+ export declare function useProofConversion(): (name: string) => number;
23
+ /** @experimental */
24
+ export type ProofComponentProps = {
25
+ placementId: string;
26
+ /** YouSaid origin. Defaults to https://yousaid.dev. */
27
+ endpoint?: string;
28
+ /** Pre-fetched manifest (SSR / static signed content). Skips the network request. */
29
+ manifest?: ProofRender;
30
+ /**
31
+ * Signed snapshot used only if the live request fails, only until it expires,
32
+ * and only after its Ed25519 signature verifies against the YouSaid JWKS
33
+ * (fetched from `endpoint`). Unverifiable snapshots are never shown.
34
+ */
35
+ fallback?: ProofRender;
36
+ /** Color scheme override. */
37
+ theme?: "light" | "dark" | "auto";
38
+ /** Widget text language (for example "de"). Defaults to the page language, then the browser's. */
39
+ locale?: string;
40
+ /** Disables analytics (for previews). */
41
+ preview?: boolean;
42
+ className?: string;
43
+ };
44
+ /** @experimental */
45
+ export declare function proofThemeStyle(theme: ProofTheme | undefined, override?: "light" | "dark" | "auto"): CSSProperties;
46
+ /** @experimental */
47
+ export type ProofRenderState = {
48
+ render: ProofRender | null;
49
+ status: "loading" | "ready" | "fallback" | "error";
50
+ };
51
+ /**
52
+ * Loads (or accepts) a render manifest. Stale or unverified fallbacks are never shown.
53
+ * @experimental
54
+ */
55
+ export declare function useProofRender(placementId: string, options?: Pick<ProofComponentProps, "endpoint" | "manifest" | "fallback">): ProofRenderState;
56
+ /** @experimental */
57
+ export declare function ProofWall(props: ProofComponentProps): import("react").JSX.Element | null;
58
+ /** @experimental */
59
+ export declare function ProofCard(props: ProofComponentProps): import("react").JSX.Element | null;
60
+ /** @experimental */
61
+ export declare function ProofCarousel(props: ProofComponentProps & {
62
+ autoplayMs?: number;
63
+ }): import("react").JSX.Element | null;
64
+ /**
65
+ * Portrait and company logo on an avatar and logo strip (first party; present only when consent allows it).
66
+ * @experimental
67
+ */
68
+ export type ProofIdentityImages = NonNullable<ProofRenderItem["identityImages"]>;
69
+ /**
70
+ * A wrapping row of round portraits or company logos. No animation; images load lazily from YouSaid only.
71
+ * @experimental
72
+ */
73
+ export declare function ProofLogoStrip(props: ProofComponentProps): import("react").JSX.Element | null;
74
+ /** @experimental */
75
+ export declare function ProofBadge(props: ProofComponentProps): import("react").JSX.Element;
76
+ type MapPlace = {
77
+ key: string;
78
+ label: string;
79
+ lat: number;
80
+ lon: number;
81
+ count: number;
82
+ };
83
+ /**
84
+ * Groups located stories by place (country or region), most stories first.
85
+ * @experimental
86
+ */
87
+ export declare function mapPlaces(items: ProofRenderItem[]): MapPlace[];
88
+ /**
89
+ * Customer map: an inline SVG outline (no map tiles, no third-party requests)
90
+ * with one marker per country or region the customers allowed, and a list of
91
+ * place buttons (the accessible way in) that filters the stories below.
92
+ * @experimental
93
+ */
94
+ export declare function ProofMap(props: ProofComponentProps): import("react").JSX.Element | null;
95
+ /**
96
+ * A horizontal row of stories. It scrolls gently unless the visitor prefers reduced motion, with a pause button, and stops on hover and focus.
97
+ * @experimental
98
+ */
99
+ export declare function ProofMarquee(props: ProofComponentProps): import("react").JSX.Element | null;
100
+ /**
101
+ * Floating video bubble. Only approved video stories with captions; it never
102
+ * plays by itself, plays with captions on, opens with Enter or Space, closes
103
+ * with Escape (focus returns to the bubble) and can be hidden for the page
104
+ * view (`onDismiss` lets your app remember that; nothing is stored here).
105
+ * `inline` keeps it inside its container, for previews.
106
+ * @experimental
107
+ */
108
+ export declare function ProofVideoBubble(props: ProofComponentProps & {
109
+ inline?: boolean;
110
+ onDismiss?: () => void;
111
+ }): import("react").JSX.Element | null;
112
+ export {};
113
+ //# sourceMappingURL=proof.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proof.d.ts","sourceRoot":"","sources":["../src/proof.tsx"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AACH,OAAO,EAAE,KAAK,aAAa,EAAyG,MAAM,OAAO,CAAC;AAClJ,OAAO,EAC0F,KAAK,WAAW,EAAE,KAAK,eAAe,EAAkC,KAAK,UAAU,EAEvL,MAAM,sBAAsB,CAAC;AAE9B;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,IAAI,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAE7D;AAED,oBAAoB;AACpB,MAAM,MAAM,mBAAmB,GAAG;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qFAAqF;IACrF,QAAQ,CAAC,EAAE,WAAW,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,WAAW,CAAC;IACvB,6BAA6B;IAC7B,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC;IAClC,kGAAkG;IAClG,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yCAAyC;IACzC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AA6BF,oBAAoB;AACpB,wBAAgB,eAAe,CAAC,KAAK,EAAE,UAAU,GAAG,SAAS,EAAE,QAAQ,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,aAAa,CAclH;AAED,oBAAoB;AACpB,MAAM,MAAM,gBAAgB,GAAG;IAAE,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,SAAS,GAAG,OAAO,GAAG,UAAU,GAAG,OAAO,CAAA;CAAE,CAAC;AAElH;;;GAGG;AACH,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,GAAE,IAAI,CAAC,mBAAmB,EAAE,UAAU,GAAG,UAAU,GAAG,UAAU,CAAM,GAAG,gBAAgB,CAkBnJ;AAsHD,oBAAoB;AACpB,wBAAgB,SAAS,CAAC,KAAK,EAAE,mBAAmB,sCAoBnD;AAED,oBAAoB;AACpB,wBAAgB,SAAS,CAAC,KAAK,EAAE,mBAAmB,sCAKnD;AAED,oBAAoB;AACpB,wBAAgB,aAAa,CAAC,KAAK,EAAE,mBAAmB,GAAG;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,sCAyBjF;AAED;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAAG,WAAW,CAAC,eAAe,CAAC,gBAAgB,CAAC,CAAC,CAAC;AAiBjF;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,sCAOxD;AAED,oBAAoB;AACpB,wBAAgB,UAAU,CAAC,KAAK,EAAE,mBAAmB,+BAapD;AAED,KAAK,QAAQ,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAExF;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,eAAe,EAAE,GAAG,QAAQ,EAAE,CAU9D;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,mBAAmB,sCAyBlD;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,mBAAmB,sCAmBtD;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,mBAAmB,GAAG;IAAE,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;CAAE,sCAqDzG"}
package/dist/proof.js ADDED
@@ -0,0 +1,383 @@
1
+ "use client";
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * Published proof components. They consume the same eligibility-gated render
5
+ * manifest as the <yousaid-proof> Web Component and the headless API: only
6
+ * eligible items, only allowed identity fields, with required disclosures.
7
+ * Widget text follows the `locale` prop, then the page language, then the
8
+ * browser (PROOF_STRINGS in @sansavision/yousaid). Moving formats always have a
9
+ * pause control and stay still for visitors who prefer reduced motion.
10
+ */
11
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
12
+ import { createProofBeacon, fetchProofRender, isRenderFresh, mapPoint, proofLanguage, proofText, sendProofConversion, verifiedProofFallback, worldOutlinePath, } from "@sansavision/yousaid";
13
+ /**
14
+ * Reports a site conversion (for example "signup", "trial" or "purchase") to
15
+ * every proof placement rendered on this page, so Publish analytics can show
16
+ * conversions that came after a proof impression in the same page load. Names
17
+ * are short lowercase slugs; each is sent once per page load. No cookie or
18
+ * visitor id is involved, and analytics labels the result as an association.
19
+ *
20
+ * const convert = useProofConversion();
21
+ * onSignupSuccess(() => convert("signup"));
22
+ * @experimental
23
+ */
24
+ export function useProofConversion() {
25
+ return useCallback((name) => sendProofConversion(name), []);
26
+ }
27
+ const MIN_HEIGHT = { wall: 360, carousel: 260, card: 180, badge: 44, logo_strip: 96, map: 360, marquee: 220 };
28
+ const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
29
+ const LIGHT = { bg: "#ffffff", fg: "#17151f", muted: "#5d5a6b", card: "#f6f5fa", border: "#e4e2ec" };
30
+ const DARK = { bg: "#111016", fg: "#f4f2fb", muted: "#aaa6b8", card: "#1b1a22", border: "#2d2b36" };
31
+ function prefersDark() {
32
+ return typeof window !== "undefined" && Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
33
+ }
34
+ function prefersReducedMotion() {
35
+ return typeof window !== "undefined" && Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches);
36
+ }
37
+ /** Starts still (server render and first paint) and moves only once the browser says motion is welcome. */
38
+ function useReducedMotion() {
39
+ const [reduced, setReduced] = useState(true);
40
+ useEffect(() => { setReduced(prefersReducedMotion()); }, []);
41
+ return reduced;
42
+ }
43
+ function useLanguage(locale) {
44
+ const [language, setLanguage] = useState(() => proofLanguage(locale));
45
+ useEffect(() => {
46
+ setLanguage(proofLanguage(locale, typeof document !== "undefined" ? document.documentElement.lang : null, typeof navigator !== "undefined" ? navigator.language : null));
47
+ }, [locale]);
48
+ return language;
49
+ }
50
+ /** @experimental */
51
+ export function proofThemeStyle(theme, override) {
52
+ const scheme = override ?? theme?.scheme ?? "auto";
53
+ const base = scheme === "dark" || (scheme === "auto" && prefersDark()) ? DARK : LIGHT;
54
+ return {
55
+ "--ys-accent": theme && HEX.test(theme.accent) ? theme.accent : "#6957e8",
56
+ "--ys-bg": theme?.background && HEX.test(theme.background) ? theme.background : base.bg,
57
+ "--ys-fg": theme?.foreground && HEX.test(theme.foreground) ? theme.foreground : base.fg,
58
+ "--ys-muted": base.muted,
59
+ "--ys-card": base.card,
60
+ "--ys-border": base.border,
61
+ "--ys-radius": `${Math.max(0, Math.min(32, theme?.radius ?? 16))}px`,
62
+ "--ys-font": (theme?.fontStack ?? "system-ui, sans-serif").replace(/[;{}<>]/g, ""),
63
+ "--ys-gap": theme?.density === "compact" ? "10px" : "16px",
64
+ };
65
+ }
66
+ /**
67
+ * Loads (or accepts) a render manifest. Stale or unverified fallbacks are never shown.
68
+ * @experimental
69
+ */
70
+ export function useProofRender(placementId, options = {}) {
71
+ const [state, setState] = useState(() => options.manifest ? { render: options.manifest, status: "ready" } : { render: null, status: "loading" });
72
+ useEffect(() => {
73
+ if (options.manifest) {
74
+ setState({ render: options.manifest, status: "ready" });
75
+ return;
76
+ }
77
+ const controller = new AbortController();
78
+ fetchProofRender(placementId, { endpoint: options.endpoint, signal: controller.signal })
79
+ .then((render) => setState({ render, status: "ready" }))
80
+ .catch(async () => {
81
+ if (controller.signal.aborted)
82
+ return;
83
+ const fallback = options.fallback && isRenderFresh(options.fallback)
84
+ ? await verifiedProofFallback(options.fallback, { placementId, endpoint: options.endpoint })
85
+ : null;
86
+ if (controller.signal.aborted)
87
+ return;
88
+ setState(fallback ? { render: fallback, status: "fallback" } : { render: null, status: "error" });
89
+ });
90
+ return () => controller.abort();
91
+ }, [placementId, options.endpoint, options.manifest, options.fallback]);
92
+ return state;
93
+ }
94
+ function useBeacon(render, status, endpoint, preview) {
95
+ const [beacon, setBeacon] = useState(null);
96
+ useEffect(() => {
97
+ if (!render || preview || render.preview)
98
+ return;
99
+ const created = createProofBeacon(render.eventToken, { endpoint });
100
+ created.push({ t: "view" }, "view");
101
+ if (status === "fallback")
102
+ created.push({ t: "fallback" }, "fallback");
103
+ setBeacon(created);
104
+ return () => created.close();
105
+ }, [render, status, endpoint, preview]);
106
+ return beacon;
107
+ }
108
+ /** Qualified impression: at least 50% visible for at least 1 second (PRD §13.16). */
109
+ function useQualifiedImpression(itemId, beacon) {
110
+ const ref = useRef(null);
111
+ useEffect(() => {
112
+ const node = ref.current;
113
+ if (!node || !beacon || typeof IntersectionObserver === "undefined")
114
+ return;
115
+ let timer = 0;
116
+ const observer = new IntersectionObserver(([entry]) => {
117
+ if (entry && entry.intersectionRatio >= 0.5 && !entry.target.hidden) {
118
+ if (!timer)
119
+ timer = window.setTimeout(() => beacon.push({ t: "impression", i: itemId }, `imp:${itemId}`), 1000);
120
+ }
121
+ else {
122
+ window.clearTimeout(timer);
123
+ timer = 0;
124
+ }
125
+ }, { threshold: [0, 0.5, 1] });
126
+ observer.observe(node);
127
+ return () => { observer.disconnect(); window.clearTimeout(timer); };
128
+ }, [itemId, beacon]);
129
+ return ref;
130
+ }
131
+ const styles = `.ysp-r{background:var(--ys-bg);color:var(--ys-fg);font-family:var(--ys-font);border-radius:var(--ys-radius);padding:var(--ys-gap)}.ysp-r *,.ysp-bb *{box-sizing:border-box}.ysp-r [hidden],.ysp-bb [hidden]{display:none!important}.ysp-r h2{margin:0 0 var(--ys-gap);font-size:1.25rem}.ysp-g{columns:var(--ys-cols,3) 240px;column-gap:var(--ys-gap)}.ysp-c{break-inside:avoid;margin:0 0 var(--ys-gap);background:var(--ys-card);border:1px solid var(--ys-border);border-radius:var(--ys-radius);padding:18px;display:flex;flex-direction:column;gap:10px}.ysp-c blockquote{margin:0;line-height:1.55}.ysp-c h3{margin:0;font-size:1rem}.ysp-w{margin:0;font-size:.88rem}.ysp-w span{display:block;color:var(--ys-muted);font-size:.8rem}.ysp-s{margin:0;color:var(--ys-accent);letter-spacing:.1em}.ysp-d{margin:0;font-size:.76rem;color:var(--ys-muted);border-left:3px solid var(--ys-accent);padding-left:8px}.ysp-m{display:flex;justify-content:space-between;gap:8px;margin:0;font-size:.72rem;color:var(--ys-muted)}.ysp-m a{color:inherit}.ysp-c video,.ysp-c img{display:block;width:100%;height:auto;border-radius:calc(var(--ys-radius)*.6)}.ysp-c video{aspect-ratio:16/9;background:#000}.ysp-k{display:flex;align-items:center;gap:8px;margin-top:var(--ys-gap)}.ysp-k button,.ysp-pl button,.ysp-pz,.ysp-bh button,.ysp-bn button,.ysp-bd{min-width:44px;min-height:44px;padding:0 14px;border-radius:999px;border:1px solid var(--ys-border);background:var(--ys-card);color:var(--ys-fg);font:inherit;cursor:pointer}.ysp-b{display:inline-flex;align-items:center;gap:8px;min-height:44px;padding:0 16px;border-radius:999px;background:var(--ys-card);border:1px solid var(--ys-border);color:var(--ys-fg);text-decoration:none}.ysp-b strong{color:var(--ys-accent)}.ysp-a{display:inline-flex;align-items:center;min-height:44px;padding:0 18px;border-radius:999px;background:var(--ys-accent);color:#fff;font-weight:600;text-decoration:none}.ysp-r a:focus-visible,.ysp-r button:focus-visible,.ysp-r [tabindex]:focus-visible,.ysp-bb button:focus-visible,.ysp-bb a:focus-visible,.ysp-bb video:focus-visible{outline:3px solid var(--ys-accent);outline-offset:2px}.ysp-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.ysp-l{display:flex;flex-wrap:wrap;gap:var(--ys-gap);list-style:none;margin:0;padding:0}.ysp-l figure{margin:0;display:flex;flex-direction:column;align-items:center;gap:6px;max-width:112px;text-align:center;font-size:.8rem}.ysp-l img{display:block;width:64px;height:64px;border-radius:50%;object-fit:cover;background:var(--ys-card);border:1px solid var(--ys-border)}.ysp-l img[data-logo]{object-fit:contain;padding:6px;background:#fff}.ysp-l span{display:block;color:var(--ys-muted);font-size:.72rem}.ysp-mp svg{display:block;width:100%;height:auto;margin:0 0 var(--ys-gap)}.ysp-mp path{fill:var(--ys-card);stroke:var(--ys-border);stroke-width:.5}.ysp-mk{fill:var(--ys-accent);fill-opacity:.8;stroke:var(--ys-bg);stroke-width:.7;cursor:pointer}.ysp-mk[data-on]{fill-opacity:1;stroke:var(--ys-fg)}.ysp-pl{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:0 0 var(--ys-gap);padding:0}.ysp-pl button[aria-pressed=true]{border-color:var(--ys-accent);box-shadow:inset 0 0 0 2px var(--ys-accent)}.ysp-pz{margin:0 0 var(--ys-gap)}.ysp-mq{overflow:hidden}.ysp-mq:not([data-run]){overflow-x:auto}.ysp-mt{display:flex;gap:var(--ys-gap);width:max-content}.ysp-ms{display:flex;gap:var(--ys-gap)}.ysp-mq .ysp-c{width:300px;margin:0;flex:none}`;
132
+ /** Only the moving formats (marquee, scrolling wall) carry animation rules; everything else ships none. */
133
+ const motionStyles = `.ysp-mq[data-run] .ysp-mt{animation:ysp-x var(--ys-dur,40s) linear infinite}@keyframes ysp-x{to{transform:translateX(calc(-50% - var(--ys-gap)/2))}}.ysp-wv[data-run]{max-height:560px;overflow:hidden}.ysp-wv[data-run] .ysp-wt{animation:ysp-y var(--ys-dur,60s) linear infinite}@keyframes ysp-y{to{transform:translateY(-50%)}}.ysp-mq:hover .ysp-mt,.ysp-mq:focus-within .ysp-mt,.ysp-mq[data-paused] .ysp-mt,.ysp-wv:hover .ysp-wt,.ysp-wv:focus-within .ysp-wt,.ysp-wv[data-paused] .ysp-wt{animation-play-state:paused}@media (prefers-reduced-motion:reduce){.ysp-mt,.ysp-wt{animation:none!important}[data-dup],.ysp-pz{display:none!important}.ysp-mq{overflow-x:auto!important}.ysp-wv{max-height:none!important}}`;
134
+ const bubbleStyles = `.ysp-bb{position:fixed;z-index:2147483000;bottom:20px;right:20px;display:flex;flex-direction:column;align-items:flex-end;gap:10px;max-width:calc(100vw - 40px);font-family:var(--ys-font);color:var(--ys-fg)}.ysp-bb[data-pos=bottom-left]{right:auto;left:20px;align-items:flex-start}.ysp-bb[data-inline]{position:absolute}.ysp-br{display:flex;align-items:center;gap:6px}.ysp-bd{padding:0}.ysp-bo{position:relative;width:72px;height:72px;border-radius:50%;padding:0;overflow:hidden;border:3px solid var(--ys-accent);background:var(--ys-card);box-shadow:0 6px 24px rgba(0,0,0,.25);cursor:pointer}.ysp-bo img{width:100%;height:100%;object-fit:cover}.ysp-bo span{position:absolute;inset:0;display:grid;place-items:center;background:rgba(0,0,0,.3)}.ysp-bo span::before{content:"";margin-left:5px;border-left:16px solid #fff;border-top:10px solid transparent;border-bottom:10px solid transparent}.ysp-bp{width:min(360px,calc(100vw - 40px));max-height:calc(100vh - 140px);overflow:auto;background:var(--ys-bg);color:var(--ys-fg);border:1px solid var(--ys-border);border-radius:var(--ys-radius);box-shadow:0 12px 40px rgba(0,0,0,.3);padding:12px}.ysp-bp .ysp-c{margin:0}.ysp-bh,.ysp-bn{display:flex;justify-content:space-between;align-items:center;gap:8px;margin:0 0 8px}.ysp-bn{margin:8px 0 0}.ysp-bh p{margin:0;font-weight:600;font-size:.9rem}@media (prefers-reduced-motion:no-preference){.ysp-bb{animation:ysp-in .3s ease-out}@keyframes ysp-in{from{opacity:0;transform:translateY(8px)}}}`;
135
+ function Shell({ render, status, format, props, children, moving = false }) {
136
+ if (!render)
137
+ return status === "loading" ? _jsx("div", { className: props.className, style: { minHeight: MIN_HEIGHT[format] }, "aria-busy": "true" }) : null;
138
+ const cta = render.placement.cta && /^https?:\/\//.test(render.placement.cta.url) ? render.placement.cta : null;
139
+ return _jsxs("div", { className: `ysp-r ${props.className ?? ""}`, style: { ...proofThemeStyle(render.placement.theme, props.theme), minHeight: format === "badge" ? undefined : MIN_HEIGHT[format] }, "data-placement": render.placement.id, children: [_jsx("style", { children: moving ? styles + motionStyles : styles }), render.placement.headline && format !== "badge" ? _jsx("h2", { children: render.placement.headline }) : null, children, cta && format !== "badge" ? _jsx("a", { className: "ysp-a", href: cta.url, rel: "noopener", children: cta.label }) : null] });
140
+ }
141
+ function Stars({ rating, language }) {
142
+ if (rating === null)
143
+ return null;
144
+ const value = Math.max(0, Math.min(5, Math.round(rating > 5 ? rating / 2 : rating)));
145
+ return _jsxs("p", { className: "ysp-s", role: "img", "aria-label": proofText(language, "rated", { n: value }), children: ["★".repeat(value), "☆".repeat(5 - value)] });
146
+ }
147
+ function ProofMediaView({ item, beacon, language, eager = false, captionsOn = false }) {
148
+ const [armed, setArmed] = useState(eager);
149
+ const ref = useRef(null);
150
+ useEffect(() => { if (eager)
151
+ setArmed(true); }, [eager]);
152
+ useEffect(() => {
153
+ const node = ref.current;
154
+ if (!node || armed || typeof IntersectionObserver === "undefined") {
155
+ if (typeof IntersectionObserver === "undefined")
156
+ setArmed(true);
157
+ return;
158
+ }
159
+ const observer = new IntersectionObserver(([entry]) => { if (entry?.isIntersecting) {
160
+ setArmed(true);
161
+ observer.disconnect();
162
+ } }, { rootMargin: "200px" });
163
+ observer.observe(node);
164
+ return () => observer.disconnect();
165
+ }, [armed]);
166
+ const media = item.media;
167
+ if (!media)
168
+ return null;
169
+ if (media.kind === "image")
170
+ return _jsx("img", { src: media.src, alt: media.alt, loading: "lazy", decoding: "async", width: media.width ?? 640, height: media.height ?? 400 });
171
+ if (media.kind === "audio")
172
+ return _jsx("audio", { controls: true, preload: "none", src: media.src, "aria-label": proofText(language, "audio") });
173
+ return _jsxs("video", { ref: ref, controls: true, playsInline: true, preload: "none", crossOrigin: "anonymous", poster: media.poster ?? undefined, width: media.width ?? 640, height: media.height ?? 360, "aria-label": item.attribution.name ? proofText(language, "videoFrom", { name: item.attribution.name }) : proofText(language, "video"), onPlay: (event) => {
174
+ setArmed(true);
175
+ if (captionsOn) {
176
+ try {
177
+ const track = event.currentTarget.textTracks[0];
178
+ if (track)
179
+ track.mode = "showing";
180
+ }
181
+ catch { /* default track stays */ }
182
+ }
183
+ beacon?.push({ t: "video_start", i: item.id }, `vs:${item.id}`);
184
+ }, onTimeUpdate: (event) => { const video = event.currentTarget; if (!video.duration)
185
+ return; const pct = (video.currentTime / video.duration) * 100; for (const q of [25, 50, 75])
186
+ if (pct >= q)
187
+ beacon?.push({ t: "video_progress", i: item.id, q }, `v${q}:${item.id}`); }, onEnded: () => beacon?.push({ t: "video_complete", i: item.id }, `vc:${item.id}`), children: [armed && media.adaptive ? _jsx("source", { src: media.adaptive.hls, type: "application/vnd.apple.mpegurl" }) : null, armed && media.adaptive?.dash ? _jsx("source", { src: media.adaptive.dash, type: "application/dash+xml" }) : null, armed ? _jsx("source", { src: media.src, type: media.mimeType }) : null, armed ? media.captions.map((track, index) => _jsx("track", { kind: "captions", src: track.src, srcLang: track.lang, label: track.label, default: index === 0 }, track.src)) : null] });
188
+ }
189
+ function ProofCardBody({ item, render, beacon, language, hidden, slideLabel, copy = false, eagerMedia, captionsOn }) {
190
+ // Copies (the hidden half of a looping marquee) never count impressions and carry no media.
191
+ const ref = useQualifiedImpression(item.id, copy ? null : beacon);
192
+ const detail = [item.attribution.role_title, item.attribution.company].filter(Boolean).join(", ");
193
+ const key = item.verification === "customer_approved" ? "approved" : item.verification === "imported_public_review" ? "imported" : "permission";
194
+ const label = render.placement.layout.showDate && item.sourceDate ? item.sourceDate : proofText(language, key);
195
+ return _jsxs("article", { ref: ref, className: "ysp-c", hidden: hidden, ...(copy ? {} : { "data-proof-item": item.id }), ...(item.location ? { "data-place": `${item.location.level}:${item.location.code}` } : {}), ...(slideLabel ? { role: "group", "aria-roledescription": "slide", "aria-label": slideLabel } : {}), children: [copy ? null : _jsx(ProofMediaView, { item: item, beacon: beacon, language: language, eager: eagerMedia, captionsOn: captionsOn }), _jsx(Stars, { rating: item.rating, language: language }), item.title ? _jsx("h3", { children: item.title }) : null, item.quote ? _jsx("blockquote", { children: item.quote }) : null, item.attribution.name || detail ? _jsxs("p", { className: "ysp-w", children: [item.attribution.name ?? proofText(language, "customer"), detail ? _jsx("span", { children: detail }) : null] }) : null, item.location ? _jsx("p", { className: "ysp-m", children: _jsx("span", { children: proofText(language, "fromPlace", { place: item.location.label }) }) }) : null, item.disclosure ? _jsx("p", { className: "ysp-d", children: item.disclosure }) : null, _jsxs("p", { className: "ysp-m", children: [_jsx("span", { children: label }), _jsx("a", { href: item.receiptUrl, target: "_blank", rel: "noopener", ...(copy ? { tabIndex: -1 } : { onClick: () => beacon?.push({ t: "click", i: item.id }) }), children: proofText(language, "about") })] })] });
196
+ }
197
+ function useProof(props) {
198
+ const state = useProofRender(props.placementId, props);
199
+ const beacon = useBeacon(state.render, state.status, props.endpoint, props.preview);
200
+ const language = useLanguage(props.locale);
201
+ return { ...state, beacon, language };
202
+ }
203
+ /** Pause control for moving formats. Its text says what it will do next. */
204
+ function MotionToggle({ paused, onToggle, language }) {
205
+ return _jsx("button", { type: "button", className: "ysp-pz", onClick: onToggle, children: proofText(language, paused ? "play" : "pause") });
206
+ }
207
+ /** @experimental */
208
+ export function ProofWall(props) {
209
+ const { render, status, beacon, language } = useProof(props);
210
+ const reduced = useReducedMotion();
211
+ const [paused, setPaused] = useState(false);
212
+ const items = render?.items ?? [];
213
+ if (render && !items.length)
214
+ return null;
215
+ const columns = { "--ys-cols": String(render?.placement.layout.columns ?? 3) };
216
+ const moving = Boolean(render && render.placement.motion === "scroll" && !reduced && items.length > 1);
217
+ const cards = (copy) => render ? items.map((item) => _jsx(ProofCardBody, { item: item, render: render, beacon: beacon, language: language, copy: copy }, item.id)) : null;
218
+ return _jsx(Shell, { render: render, status: status, format: "wall", props: props, moving: moving, children: render && moving ? _jsxs(_Fragment, { children: [_jsx(MotionToggle, { paused: paused, onToggle: () => setPaused((value) => !value), language: language }), _jsx("div", { className: "ysp-wv", role: "region", "aria-label": render.placement.headline ?? proofText(language, "stories"), "data-run": "", ...(paused ? { "data-paused": "" } : {}), children: _jsxs("div", { className: "ysp-wt", style: { "--ys-dur": `${Math.max(20, items.length * 5)}s` }, children: [_jsx("div", { className: "ysp-g", style: columns, children: cards(false) }), _jsx("div", { className: "ysp-g", style: columns, "data-dup": "", "aria-hidden": "true", inert: true, children: cards(true) })] }) })] }) : render ? _jsx("div", { className: "ysp-g", style: columns, children: cards(false) }) : null });
219
+ }
220
+ /** @experimental */
221
+ export function ProofCard(props) {
222
+ const { render, status, beacon, language } = useProof(props);
223
+ const item = render?.items[0];
224
+ if (render && !item)
225
+ return null;
226
+ return _jsx(Shell, { render: render, status: status, format: "card", props: props, children: render && item ? _jsx(ProofCardBody, { item: item, render: render, beacon: beacon, language: language }) : null });
227
+ }
228
+ /** @experimental */
229
+ export function ProofCarousel(props) {
230
+ const { render, status, beacon, language } = useProof(props);
231
+ const items = useMemo(() => render?.items ?? [], [render]);
232
+ const [index, setIndex] = useState(0);
233
+ const [playing, setPlaying] = useState(false);
234
+ useEffect(() => { setPlaying(Boolean(render?.placement.layout.autoplay) && items.length > 1 && !prefersReducedMotion()); }, [render, items.length]);
235
+ useEffect(() => {
236
+ if (!playing)
237
+ return;
238
+ const timer = window.setInterval(() => setIndex((current) => (current + 1) % items.length), props.autoplayMs ?? 7000);
239
+ return () => window.clearInterval(timer);
240
+ }, [playing, items.length, props.autoplayMs]);
241
+ if (render && !items.length)
242
+ return null;
243
+ const go = (step) => { setPlaying(false); setIndex((current) => (current + step + items.length) % items.length); };
244
+ const onKey = (event) => { if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
245
+ event.preventDefault();
246
+ go(event.key === "ArrowRight" ? 1 : -1);
247
+ } };
248
+ return _jsx(Shell, { render: render, status: status, format: "carousel", props: props, children: render ? _jsxs("div", { role: "region", "aria-roledescription": "carousel", "aria-label": render.placement.headline ?? proofText(language, "stories"), tabIndex: 0, onKeyDown: onKey, onMouseEnter: () => setPlaying(false), onFocus: () => setPlaying(false), children: [items.map((item, position) => _jsx(ProofCardBody, { item: item, render: render, beacon: beacon, language: language, hidden: position !== index, slideLabel: `${position + 1} / ${items.length}` }, item.id)), _jsxs("div", { className: "ysp-k", children: [_jsx("button", { type: "button", onClick: () => go(-1), "aria-label": proofText(language, "prev"), children: "\u2039" }), _jsx("button", { type: "button", onClick: () => go(1), "aria-label": proofText(language, "next"), children: "\u203A" }), playing ? _jsx("button", { type: "button", onClick: () => setPlaying(false), "aria-label": proofText(language, "pauseRotation"), children: "\u275A\u275A" }) : null, _jsxs("span", { "aria-live": "polite", children: [index + 1, " / ", items.length] })] })] }) : null });
249
+ }
250
+ function StripFigure({ item, beacon }) {
251
+ const ref = useQualifiedImpression(item.id, beacon);
252
+ const images = item.identityImages;
253
+ const image = images?.portrait ?? images?.companyLogo;
254
+ if (!image || !/^https?:\/\//.test(image.src))
255
+ return null;
256
+ const name = item.attribution.name;
257
+ const company = item.attribution.company;
258
+ return _jsx("li", { ref: ref, "data-proof-item": item.id, children: _jsxs("figure", { children: [_jsx("img", { src: image.src, alt: image.alt, width: 64, height: 64, loading: "lazy", decoding: "async", referrerPolicy: "no-referrer", ...(images?.portrait ? {} : { "data-logo": "" }) }), name || company || item.disclosure ? _jsxs("figcaption", { children: [name ?? company, name && company ? _jsx("span", { children: company }) : null, item.disclosure ? _jsx("span", { children: item.disclosure }) : null] }) : null] }) });
259
+ }
260
+ /**
261
+ * A wrapping row of round portraits or company logos. No animation; images load lazily from YouSaid only.
262
+ * @experimental
263
+ */
264
+ export function ProofLogoStrip(props) {
265
+ const { render, status, beacon, language } = useProof(props);
266
+ const items = (render?.items ?? []).filter((item) => item.identityImages?.portrait || item.identityImages?.companyLogo);
267
+ if (render && !items.length)
268
+ return null;
269
+ return _jsx(Shell, { render: render, status: status, format: "logo_strip", props: props, children: render ? _jsx("ul", { className: "ysp-l", role: "list", "aria-label": render.placement.headline ?? proofText(language, "strip"), children: items.map((item) => _jsx(StripFigure, { item: item, beacon: beacon }, item.id)) }) : null });
270
+ }
271
+ /** @experimental */
272
+ export function ProofBadge(props) {
273
+ const { render, status, beacon, language } = useProof(props);
274
+ if (!render)
275
+ return _jsx(Shell, { render: null, status: status, format: "badge", props: props, children: null });
276
+ const summary = render.summary;
277
+ const cta = render.placement.cta && /^https?:\/\//.test(render.placement.cta.url) ? render.placement.cta : null;
278
+ const noun = (count) => proofText(language, count === 1 ? "storyOne" : "storyMany");
279
+ const text = summary.averageRating !== null && summary.ratedCount
280
+ ? _jsxs(_Fragment, { children: [_jsxs("strong", { children: ["\u2605 ", summary.averageRating] }), " ", proofText(language, "from"), " ", summary.ratedCount, " ", noun(summary.ratedCount)] })
281
+ : _jsxs(_Fragment, { children: [_jsx("strong", { children: summary.count }), " ", noun(summary.count)] });
282
+ return _jsxs(Shell, { render: render, status: status, format: "badge", props: props, children: [cta ? _jsx("a", { className: "ysp-b", href: cta.url, rel: "noopener", onClick: () => beacon?.push({ t: "click" }), children: text }) : _jsx("span", { className: "ysp-b", children: text }), _jsx("span", { className: "ysp-sr", children: summary.scopeNote })] });
283
+ }
284
+ /**
285
+ * Groups located stories by place (country or region), most stories first.
286
+ * @experimental
287
+ */
288
+ export function mapPlaces(items) {
289
+ const byKey = new Map();
290
+ for (const item of items) {
291
+ if (!item.location)
292
+ continue;
293
+ const key = `${item.location.level}:${item.location.code}`;
294
+ const place = byKey.get(key) ?? { key, label: item.location.label, lat: item.location.lat, lon: item.location.lon, count: 0 };
295
+ place.count += 1;
296
+ byKey.set(key, place);
297
+ }
298
+ return [...byKey.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
299
+ }
300
+ /**
301
+ * Customer map: an inline SVG outline (no map tiles, no third-party requests)
302
+ * with one marker per country or region the customers allowed, and a list of
303
+ * place buttons (the accessible way in) that filters the stories below.
304
+ * @experimental
305
+ */
306
+ export function ProofMap(props) {
307
+ const { render, status, beacon, language } = useProof(props);
308
+ const [place, setPlace] = useState("");
309
+ const items = (render?.items ?? []).filter((item) => item.location && Number.isFinite(item.location.lat) && Number.isFinite(item.location.lon));
310
+ const places = useMemo(() => mapPlaces(items), [items]);
311
+ const outline = useMemo(() => worldOutlinePath(), []);
312
+ if (render && !items.length)
313
+ return null;
314
+ const summary = places.map((entry) => `${entry.label} (${entry.count})`).join(", ");
315
+ const selected = places.find((entry) => entry.key === place);
316
+ return _jsx(Shell, { render: render, status: status, format: "map", props: props, children: render ? _jsxs("div", { className: "ysp-mp", children: [_jsxs("svg", { viewBox: "0 0 360 142", role: "img", "aria-label": `${proofText(language, "map")}: ${summary}`, focusable: "false", children: [_jsx("path", { d: outline }), places.map((entry) => { const point = mapPoint(entry.lat, entry.lon); return _jsx("circle", { className: "ysp-mk", cx: point.x, cy: point.y, r: Math.min(9, 2.6 + Math.sqrt(entry.count) * 1.6).toFixed(1), ...(entry.key === place ? { "data-on": "" } : {}), onClick: () => setPlace(entry.key) }, entry.key); })] }), _jsxs("ul", { className: "ysp-pl", role: "list", "aria-label": proofText(language, "places"), children: [_jsx("li", { children: _jsxs("button", { type: "button", "aria-pressed": place === "", onClick: () => setPlace(""), children: [proofText(language, "allPlaces"), " (", items.length, ")"] }) }), places.map((entry) => _jsx("li", { children: _jsxs("button", { type: "button", "aria-pressed": place === entry.key, onClick: () => setPlace(entry.key), children: [entry.label, " (", entry.count, ")"] }) }, entry.key))] }), _jsx("p", { className: "ysp-sr", "aria-live": "polite", children: selected ? proofText(language, "storiesFrom", { place: selected.label }) : "" }), _jsx("div", { className: "ysp-g", style: { "--ys-cols": String(render.placement.layout.columns) }, children: items.map((item) => _jsx(ProofCardBody, { item: item, render: render, beacon: beacon, language: language, hidden: Boolean(place) && `${item.location.level}:${item.location.code}` !== place }, item.id)) })] }) : null });
317
+ }
318
+ /**
319
+ * A horizontal row of stories. It scrolls gently unless the visitor prefers reduced motion, with a pause button, and stops on hover and focus.
320
+ * @experimental
321
+ */
322
+ export function ProofMarquee(props) {
323
+ const { render, status, beacon, language } = useProof(props);
324
+ const reduced = useReducedMotion();
325
+ const [paused, setPaused] = useState(false);
326
+ const items = render?.items ?? [];
327
+ if (render && !items.length)
328
+ return null;
329
+ const moving = Boolean(render && render.placement.motion !== "none" && !reduced && items.length > 1);
330
+ const cards = (copy) => render ? items.map((item) => _jsx(ProofCardBody, { item: item, render: render, beacon: beacon, language: language, copy: copy }, item.id)) : null;
331
+ return _jsx(Shell, { render: render, status: status, format: "marquee", props: props, moving: moving, children: render ? _jsxs(_Fragment, { children: [moving ? _jsx(MotionToggle, { paused: paused, onToggle: () => setPaused((value) => !value), language: language }) : null, _jsx("div", { className: "ysp-mq", role: "region", "aria-label": render.placement.headline ?? proofText(language, "stories"), ...(moving ? { "data-run": "" } : { tabIndex: 0 }), ...(paused ? { "data-paused": "" } : {}), children: _jsxs("div", { className: "ysp-mt", style: { "--ys-dur": `${Math.max(20, items.length * 7)}s` }, children: [_jsx("div", { className: "ysp-ms", children: cards(false) }), moving ? _jsx("div", { className: "ysp-ms", "data-dup": "", "aria-hidden": "true", inert: true, children: cards(true) }) : null] }) })] }) : null });
332
+ }
333
+ /**
334
+ * Floating video bubble. Only approved video stories with captions; it never
335
+ * plays by itself, plays with captions on, opens with Enter or Space, closes
336
+ * with Escape (focus returns to the bubble) and can be hidden for the page
337
+ * view (`onDismiss` lets your app remember that; nothing is stored here).
338
+ * `inline` keeps it inside its container, for previews.
339
+ * @experimental
340
+ */
341
+ export function ProofVideoBubble(props) {
342
+ const { render, beacon, language } = useProof(props);
343
+ const items = (render?.items ?? []).filter((item) => item.media?.kind === "video" && item.media.captions.length > 0);
344
+ const [shown, setShown] = useState(Boolean(props.inline));
345
+ const [open, setOpen] = useState(false);
346
+ const [dismissed, setDismissed] = useState(false);
347
+ const [index, setIndex] = useState(0);
348
+ const bubble = useRef(null);
349
+ const panel = useRef(null);
350
+ const delay = render?.placement.bubble?.delaySeconds ?? 3;
351
+ useEffect(() => {
352
+ if (props.inline || !render) {
353
+ setShown(true);
354
+ return;
355
+ }
356
+ const timer = window.setTimeout(() => setShown(true), Math.max(0, Math.min(120, delay)) * 1000);
357
+ return () => window.clearTimeout(timer);
358
+ }, [props.inline, render, delay]);
359
+ useEffect(() => {
360
+ if (!open)
361
+ return;
362
+ const target = panel.current?.querySelector("article:not([hidden]) video") ?? panel.current?.querySelector("[data-close]");
363
+ target?.focus();
364
+ }, [open, index]);
365
+ if (!render || !items.length || dismissed || !shown)
366
+ return null;
367
+ const first = items[0];
368
+ const id = `ysp-bp-${first.id}`;
369
+ const close = () => {
370
+ panel.current?.querySelectorAll("video").forEach((video) => video.pause());
371
+ setOpen(false);
372
+ window.setTimeout(() => bubble.current?.focus(), 0);
373
+ };
374
+ const step = (delta) => {
375
+ panel.current?.querySelectorAll("video").forEach((video) => video.pause());
376
+ setIndex((current) => (current + delta + items.length) % items.length);
377
+ };
378
+ const poster = first.media?.kind === "video" ? first.media.poster : null;
379
+ return _jsxs("div", { className: `ysp-bb ${props.className ?? ""}`, style: proofThemeStyle(render.placement.theme, props.theme), "data-pos": render.placement.bubble?.position ?? "bottom-right", ...(props.inline ? { "data-inline": "" } : {}), onKeyDown: (event) => { if (event.key === "Escape" && open) {
380
+ event.preventDefault();
381
+ close();
382
+ } }, children: [_jsx("style", { children: styles + bubbleStyles }), _jsxs("div", { ref: panel, className: "ysp-bp", id: id, role: "dialog", "aria-label": proofText(language, "stories"), hidden: !open, children: [_jsxs("div", { className: "ysp-bh", children: [_jsx("p", { children: proofText(language, "stories") }), _jsx("button", { type: "button", "data-close": "", "aria-label": proofText(language, "close"), onClick: close, children: "\u00D7" })] }), items.map((item, position) => _jsx(ProofCardBody, { item: item, render: render, beacon: beacon, language: language, hidden: position !== index, eagerMedia: open && position === index, captionsOn: true }, item.id)), items.length > 1 ? _jsxs("div", { className: "ysp-bn", children: [_jsx("button", { type: "button", "aria-label": proofText(language, "prev"), onClick: () => step(-1), children: "\u2039" }), _jsxs("span", { "aria-live": "polite", children: [index + 1, " / ", items.length] }), _jsx("button", { type: "button", "aria-label": proofText(language, "next"), onClick: () => step(1), children: "\u203A" })] }) : null] }), _jsxs("div", { className: "ysp-br", children: [_jsxs("button", { ref: bubble, type: "button", className: "ysp-bo", "aria-expanded": open, "aria-controls": id, onClick: () => (open ? close() : setOpen(true)), "aria-label": first.attribution.name ? proofText(language, "watchFrom", { name: first.attribution.name }) : proofText(language, "watch"), children: [poster ? _jsx("img", { src: poster, alt: "", width: 72, height: 72, decoding: "async" }) : null, _jsx("span", {})] }), _jsx("button", { type: "button", className: "ysp-bd", "aria-label": proofText(language, "dismiss"), onClick: () => { close(); setDismissed(true); props.onDismiss?.(); }, children: "\u00D7" })] })] });
383
+ }