@cimplify/sdk 0.48.2 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.mjs CHANGED
@@ -39,7 +39,19 @@ var tags = {
39
39
  collection: (id) => `cimplify:collection:${id}`,
40
40
  collectionProducts: (id) => `cimplify:collection:${id}:products`,
41
41
  business: () => "cimplify:business",
42
+ brand: () => "cimplify:brand",
42
43
  locations: () => "cimplify:locations",
44
+ location: (id) => `cimplify:location:${id}`,
45
+ locale: () => "cimplify:locale",
46
+ pricing: () => "cimplify:pricing",
47
+ // Product-level tag (e.g. "vegan", "bestseller"), not "cache tag".
48
+ tag: (name) => `cimplify:tag:${name}`,
49
+ addons: () => "cimplify:addons",
50
+ addon: (id) => `cimplify:addon:${id}`,
51
+ subscriptions: () => "cimplify:subscriptions",
52
+ subscription: (id) => `cimplify:subscription:${id}`,
53
+ stock: () => "cimplify:stock",
54
+ stockFor: (productId) => `cimplify:stock:${productId}`,
43
55
  orders: (customerId) => `cimplify:orders:${customerId}`,
44
56
  order: (id) => `cimplify:order:${id}`
45
57
  };
@@ -86,8 +98,111 @@ async function revalidateCollection(id) {
86
98
  async function revalidateBusiness() {
87
99
  return revalidate(tags.business());
88
100
  }
101
+ async function revalidateBrand() {
102
+ return revalidate(tags.brand());
103
+ }
104
+ async function revalidateLocations() {
105
+ return revalidate(tags.locations());
106
+ }
107
+ async function revalidateLocation(id) {
108
+ return revalidate(tags.location(id), tags.locations());
109
+ }
110
+ async function revalidatePricing() {
111
+ return revalidate(tags.pricing(), tags.products());
112
+ }
113
+ async function revalidateAddOns() {
114
+ return revalidate(tags.addons());
115
+ }
116
+ async function revalidateAddOn(id) {
117
+ return revalidate(tags.addon(id), tags.addons());
118
+ }
119
+ async function revalidateSubscriptions() {
120
+ return revalidate(tags.subscriptions());
121
+ }
122
+ async function revalidateSubscription(id) {
123
+ return revalidate(tags.subscription(id), tags.subscriptions());
124
+ }
125
+ async function revalidateStock(productId) {
126
+ return productId ? revalidate(tags.stockFor(productId), tags.stock()) : revalidate(tags.stock());
127
+ }
89
128
  async function revalidateByTag(tag) {
90
129
  return revalidate(tag);
91
130
  }
92
131
 
93
- export { getServerClient, revalidateBusiness, revalidateByTag, revalidateCategories, revalidateCategory, revalidateCollection, revalidateCollections, revalidateProduct, revalidateProducts, tags };
132
+ // src/server/revalidate-route.ts
133
+ var TIMESTAMP_HEADER = "x-cimplify-timestamp";
134
+ var SIGNATURE_HEADER = "x-cimplify-signature";
135
+ var SIGNATURE_PREFIX = "sha256=";
136
+ var MAX_SKEW_MS = 5 * 60 * 1e3;
137
+ var SECRET_ENV = "CIMPLIFY_REVALIDATE_SECRET";
138
+ async function revalidateRouteHandler(req, options = {}) {
139
+ const secret = options.secret ?? envSecret();
140
+ if (!secret) return text(`revalidate disabled: ${SECRET_ENV} not set`, 500);
141
+ const timestamp = req.headers.get(TIMESTAMP_HEADER);
142
+ const signature = req.headers.get(SIGNATURE_HEADER);
143
+ if (!timestamp || !signature) return text("missing auth headers", 401);
144
+ const ts = Number.parseInt(timestamp, 10);
145
+ const now = options.now ?? Date.now;
146
+ if (!Number.isFinite(ts) || Math.abs(now() - ts) > MAX_SKEW_MS) {
147
+ return text("stale or invalid timestamp", 401);
148
+ }
149
+ const body = await req.text();
150
+ if (!await verifyHmac(secret, `${timestamp}.${body}`, signature)) {
151
+ return text("invalid signature", 401);
152
+ }
153
+ let parsed;
154
+ try {
155
+ parsed = JSON.parse(body);
156
+ } catch {
157
+ return text("invalid json", 400);
158
+ }
159
+ const tags2 = Array.isArray(parsed.tags) ? parsed.tags.filter((t) => typeof t === "string" && t.length > 0) : [];
160
+ if (tags2.length === 0) return text("no tags", 400);
161
+ const revalidate2 = options.revalidateTag ?? await loadRevalidateTag();
162
+ for (const tag of tags2) revalidate2(tag);
163
+ return Response.json({ ok: true, revalidated: tags2.length });
164
+ }
165
+ var cachedRevalidateTag = null;
166
+ async function loadRevalidateTag() {
167
+ if (cachedRevalidateTag) return cachedRevalidateTag;
168
+ const specifier = "next/cache";
169
+ const mod = await import(
170
+ /* webpackIgnore: true */
171
+ /* @vite-ignore */
172
+ specifier
173
+ );
174
+ cachedRevalidateTag = mod.revalidateTag;
175
+ return cachedRevalidateTag;
176
+ }
177
+ async function verifyHmac(secret, payload, signatureHeader) {
178
+ if (!signatureHeader.startsWith(SIGNATURE_PREFIX)) return false;
179
+ const providedBytes = hexToBytes(signatureHeader.slice(SIGNATURE_PREFIX.length));
180
+ if (!providedBytes) return false;
181
+ const enc = new TextEncoder();
182
+ const key = await crypto.subtle.importKey(
183
+ "raw",
184
+ enc.encode(secret),
185
+ { name: "HMAC", hash: "SHA-256" },
186
+ false,
187
+ ["verify"]
188
+ );
189
+ return crypto.subtle.verify("HMAC", key, providedBytes, enc.encode(payload));
190
+ }
191
+ function hexToBytes(hex) {
192
+ if (hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return null;
193
+ const buf = new ArrayBuffer(hex.length / 2);
194
+ const out = new Uint8Array(buf);
195
+ for (let i = 0; i < hex.length; i += 2) {
196
+ out[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16);
197
+ }
198
+ return out;
199
+ }
200
+ function envSecret() {
201
+ const proc = globalThis.process;
202
+ return proc?.env?.[SECRET_ENV];
203
+ }
204
+ function text(message, status) {
205
+ return new Response(message, { status, headers: { "content-type": "text/plain" } });
206
+ }
207
+
208
+ export { getServerClient, revalidateAddOn, revalidateAddOns, revalidateBrand, revalidateBusiness, revalidateByTag, revalidateCategories, revalidateCategory, revalidateCollection, revalidateCollections, revalidateLocation, revalidateLocations, revalidatePricing, revalidateProduct, revalidateProducts, revalidateRouteHandler, revalidateStock, revalidateSubscription, revalidateSubscriptions, tags };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cimplify/sdk",
3
- "version": "0.48.2",
3
+ "version": "0.49.0",
4
4
  "description": "Cimplify Commerce SDK for storefronts",
5
5
  "keywords": [
6
6
  "cimplify",
@@ -71,6 +71,30 @@
71
71
  "type": "component",
72
72
  "registryDependencies": []
73
73
  },
74
+ {
75
+ "name": "store-video",
76
+ "title": "StoreVideo",
77
+ "description": "Autoplay-muted-loop video for storefront heroes / product demos. Lazy-loaded, mobile-safe (playsInline), poster fallback for browsers without video.",
78
+ "type": "component",
79
+ "registryDependencies": []
80
+ },
81
+ {
82
+ "name": "product-model-3d",
83
+ "title": "ProductModel3D",
84
+ "description": "Interactive 3D product viewer wrapping Google's <model-viewer>. Loads the web component on demand; AR mode lights up on iOS (Quick Look) and Android (Scene Viewer) automatically.",
85
+ "type": "component",
86
+ "registryDependencies": []
87
+ },
88
+ {
89
+ "name": "media-gallery",
90
+ "title": "MediaGallery",
91
+ "description": "Like ProductImageGallery but accepts mixed image/video/3D items in one carousel. Thumbnails show a poster + small overlay icon for video/3D.",
92
+ "type": "component",
93
+ "registryDependencies": [
94
+ "store-video",
95
+ "product-model-3d"
96
+ ]
97
+ },
74
98
  {
75
99
  "name": "cart-summary",
76
100
  "title": "CartSummary",
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "media-gallery",
3
+ "title": "MediaGallery",
4
+ "description": "Like ProductImageGallery but accepts mixed image/video/3D items in one carousel. Thumbnails show a poster + small overlay icon for video/3D.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "store-video",
8
+ "product-model-3d"
9
+ ],
10
+ "files": [
11
+ {
12
+ "path": "media-gallery.tsx",
13
+ "content": "\"use client\";\n\nimport React, { useEffect, useMemo, useState } from \"react\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport { Radio } from \"@base-ui/react/radio\";\nimport { StoreVideo } from \"@cimplify/sdk/react\";\nimport { ProductModel3D } from \"@cimplify/sdk/react\";\n\nexport type MediaItem =\n | { type: \"image\"; src: string; alt?: string }\n | { type: \"video\"; src: string; poster?: string; alt?: string }\n | { type: \"model\"; src: string; iosSrc?: string; poster?: string; alt?: string };\n\nexport interface MediaGalleryProps {\n items: MediaItem[];\n productName: string;\n aspectRatio?: \"square\" | \"4/3\" | \"16/10\" | \"3/4\";\n className?: string;\n}\n\nconst ASPECT_STYLES: Record<string, React.CSSProperties> = {\n square: { aspectRatio: \"1/1\" },\n \"4/3\": { aspectRatio: \"4/3\" },\n \"16/10\": { aspectRatio: \"16/10\" },\n \"3/4\": { aspectRatio: \"3/4\" },\n};\n\nfunction thumbnailFor(item: MediaItem): string | null {\n if (item.type === \"image\") return item.src;\n return item.poster ?? null;\n}\n\nfunction thumbIcon(item: MediaItem): string | null {\n if (item.type === \"video\") return \"▶\";\n if (item.type === \"model\") return \"◆\";\n return null;\n}\n\n/**\n * MediaGallery — like ProductImageGallery, but accepts mixed image/video/3D\n * items in one carousel. The active item renders via the matching SDK\n * component (img / StoreVideo / ProductModel3D); thumbnails show a poster\n * (or the image itself) with a small overlay icon for video/3D.\n */\nexport function MediaGallery({\n items,\n productName,\n aspectRatio = \"4/3\",\n className,\n}: MediaGalleryProps): React.ReactElement | null {\n const validItems = useMemo(\n () => items.filter((i) => typeof i.src === \"string\" && i.src.trim().length > 0),\n [items],\n );\n\n const [selected, setSelected] = useState(0);\n\n useEffect(() => {\n setSelected(0);\n }, [validItems.length, productName]);\n\n if (validItems.length === 0) return null;\n\n const active = validItems[selected] ?? validItems[0];\n\n return (\n <div data-cimplify-media-gallery className={className}>\n <div\n data-cimplify-media-gallery-main\n style={{ position: \"relative\", overflow: \"hidden\", ...ASPECT_STYLES[aspectRatio] }}\n >\n {active.type === \"image\" ? (\n <img\n src={active.src}\n alt={active.alt ?? productName}\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n data-cimplify-media-gallery-active\n />\n ) : active.type === \"video\" ? (\n <StoreVideo\n src={active.src}\n poster={active.poster}\n alt={active.alt ?? productName}\n aspectRatio=\"square\"\n lazy={false}\n />\n ) : (\n <ProductModel3D\n src={active.src}\n iosSrc={active.iosSrc}\n poster={active.poster}\n alt={active.alt ?? productName}\n aspectRatio=\"square\"\n />\n )}\n </div>\n\n {validItems.length > 1 && (\n <RadioGroup\n aria-label={`${productName} media thumbnails`}\n value={String(selected)}\n onValueChange={(v) => setSelected(Number(v))}\n data-cimplify-media-gallery-thumbnails\n style={{ display: \"flex\", gap: \"0.5rem\", marginTop: \"0.75rem\" }}\n >\n {validItems.map((item, index) => {\n const thumb = thumbnailFor(item);\n const icon = thumbIcon(item);\n const isSelected = selected === index;\n return (\n <Radio.Root\n key={`${item.src}-${index}`}\n value={String(index)}\n data-cimplify-media-gallery-thumb\n data-selected={isSelected || undefined}\n data-type={item.type}\n style={{\n position: \"relative\",\n width: \"4rem\",\n height: \"4rem\",\n overflow: \"hidden\",\n padding: 0,\n border: \"none\",\n cursor: \"pointer\",\n backgroundColor: \"var(--muted, #f3f4f6)\",\n }}\n >\n {thumb ? (\n <img\n src={thumb}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : null}\n {icon ? (\n <span\n aria-hidden\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n color: \"white\",\n fontSize: \"0.875rem\",\n textShadow: \"0 1px 2px rgba(0,0,0,0.6)\",\n pointerEvents: \"none\",\n }}\n >\n {icon}\n </span>\n ) : null}\n </Radio.Root>\n );\n })}\n </RadioGroup>\n )}\n </div>\n );\n}\n"
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "product-model-3d",
3
+ "title": "ProductModel3D",
4
+ "description": "Interactive 3D product viewer wrapping Google's <model-viewer>. Loads the web component on demand; AR mode lights up on iOS (Quick Look) and Android (Scene Viewer) automatically.",
5
+ "type": "component",
6
+ "registryDependencies": [],
7
+ "files": [
8
+ {
9
+ "path": "product-model-3d.tsx",
10
+ "content": "\"use client\";\n\nimport React, { useEffect, useState } from \"react\";\n\nexport interface ProductModel3DProps {\n /** glTF or GLB URL. */\n src: string;\n /** Optional USDZ URL for iOS AR Quick Look (Safari/WebKit). */\n iosSrc?: string;\n /** Poster image shown while the model loads (and as a fallback). */\n poster?: string;\n alt?: string;\n aspectRatio?: \"square\" | \"4/3\" | \"16/9\" | \"3/4\";\n /** Enable AR mode. Lights up on iOS (Quick Look) + Android (Scene Viewer). Default `true`. */\n ar?: boolean;\n autoRotate?: boolean;\n cameraControls?: boolean;\n className?: string;\n}\n\nconst ASPECT_STYLES: Record<string, React.CSSProperties> = {\n square: { aspectRatio: \"1/1\" },\n \"4/3\": { aspectRatio: \"4/3\" },\n \"16/9\": { aspectRatio: \"16/9\" },\n \"3/4\": { aspectRatio: \"3/4\" },\n};\n\nconst MODEL_VIEWER_CDN =\n \"https://unpkg.com/@google/model-viewer@4.0.0/dist/model-viewer.min.js\";\n\n// Module-level cache so multiple <ProductModel3D> instances share one script load.\nlet modelViewerLoadPromise: Promise<void> | null = null;\nfunction ensureModelViewer(): Promise<void> {\n if (typeof window === \"undefined\") return Promise.resolve();\n if (modelViewerLoadPromise) return modelViewerLoadPromise;\n if (window.customElements?.get(\"model-viewer\")) {\n modelViewerLoadPromise = Promise.resolve();\n return modelViewerLoadPromise;\n }\n modelViewerLoadPromise = new Promise<void>((resolve, reject) => {\n const script = document.createElement(\"script\");\n script.type = \"module\";\n script.src = MODEL_VIEWER_CDN;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error(\"Failed to load model-viewer\"));\n document.head.appendChild(script);\n });\n return modelViewerLoadPromise;\n}\n\n/**\n * ProductModel3D — interactive 3D product viewer wrapping Google's\n * `<model-viewer>` web component. Script loads on demand (one tag per page,\n * cached). AR mode auto-resolves the right OS surface: USDZ Quick Look on iOS,\n * Scene Viewer on Android, WebXR where available. Poster image shows during\n * load and as a fallback if the script can't load.\n */\nexport function ProductModel3D({\n src,\n iosSrc,\n poster,\n alt,\n aspectRatio = \"square\",\n ar = true,\n autoRotate = true,\n cameraControls = true,\n className,\n}: ProductModel3DProps): React.ReactElement {\n const [ready, setReady] = useState(false);\n\n useEffect(() => {\n let cancelled = false;\n ensureModelViewer().then(\n () => {\n if (!cancelled) setReady(true);\n },\n () => {\n // Stay on the poster fallback if the CDN script fails.\n },\n );\n return () => {\n cancelled = true;\n };\n }, []);\n\n return (\n <div\n data-cimplify-product-model-3d\n className={className}\n style={{ position: \"relative\", overflow: \"hidden\", ...ASPECT_STYLES[aspectRatio] }}\n >\n {ready\n ? React.createElement(\"model-viewer\", {\n src,\n \"ios-src\": iosSrc,\n alt,\n poster,\n ar: ar || undefined,\n \"ar-modes\": ar ? \"webxr scene-viewer quick-look\" : undefined,\n \"camera-controls\": cameraControls || undefined,\n \"auto-rotate\": autoRotate || undefined,\n \"shadow-intensity\": \"1\",\n style: { width: \"100%\", height: \"100%\", backgroundColor: \"transparent\" },\n })\n : poster\n ? React.createElement(\"img\", {\n src: poster,\n alt: alt ?? \"\",\n \"data-cimplify-product-model-3d-loading\": true,\n style: { width: \"100%\", height: \"100%\", objectFit: \"cover\" },\n })\n : React.createElement(\"div\", {\n \"data-cimplify-product-model-3d-loading\": true,\n style: { width: \"100%\", height: \"100%\", backgroundColor: \"var(--muted, #f3f4f6)\" },\n })}\n </div>\n );\n}\n"
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "store-video",
3
+ "title": "StoreVideo",
4
+ "description": "Autoplay-muted-loop video for storefront heroes / product demos. Lazy-loaded, mobile-safe (playsInline), poster fallback for browsers without video.",
5
+ "type": "component",
6
+ "registryDependencies": [],
7
+ "files": [
8
+ {
9
+ "path": "store-video.tsx",
10
+ "content": "\"use client\";\n\nimport React, { useEffect, useRef, useState } from \"react\";\n\nexport interface StoreVideoProps {\n src: string;\n /** Poster shown before play + as the fallback if `<video>` isn't supported. */\n poster?: string;\n /** Accessible label; also the alt of the fallback `<img>`. */\n alt?: string;\n aspectRatio?: \"square\" | \"4/3\" | \"16/9\" | \"16/10\" | \"3/4\";\n /** Show native browser controls. Default off for autoplay-friendly hero clips. */\n controls?: boolean;\n /** Default `true` — muted is the only way mobile browsers allow autoplay. */\n autoplay?: boolean;\n loop?: boolean;\n muted?: boolean;\n /** Lazy-load: defer the src until the player scrolls into view. Default `true`. */\n lazy?: boolean;\n className?: string;\n}\n\nconst ASPECT_STYLES: Record<string, React.CSSProperties> = {\n square: { aspectRatio: \"1/1\" },\n \"4/3\": { aspectRatio: \"4/3\" },\n \"16/9\": { aspectRatio: \"16/9\" },\n \"16/10\": { aspectRatio: \"16/10\" },\n \"3/4\": { aspectRatio: \"3/4\" },\n};\n\n/**\n * StoreVideo — `<video>` with mobile-safe defaults (muted + playsInline + loop),\n * IntersectionObserver-driven lazy load, and a poster `<img>` fallback for\n * browsers without video support. Drop-in replacement for a static hero image.\n */\nexport function StoreVideo({\n src,\n poster,\n alt,\n aspectRatio = \"16/9\",\n controls = false,\n autoplay = true,\n loop = true,\n muted = true,\n lazy = true,\n className,\n}: StoreVideoProps): React.ReactElement {\n const ref = useRef<HTMLVideoElement>(null);\n const [inView, setInView] = useState(!lazy);\n\n useEffect(() => {\n if (!lazy || inView) return;\n const node = ref.current;\n if (!node || typeof IntersectionObserver === \"undefined\") {\n setInView(true);\n return;\n }\n const observer = new IntersectionObserver(\n (entries) => {\n if (entries.some((e) => e.isIntersecting)) {\n setInView(true);\n observer.disconnect();\n }\n },\n { rootMargin: \"200px\" },\n );\n observer.observe(node);\n return () => observer.disconnect();\n }, [lazy, inView]);\n\n return (\n <div\n data-cimplify-store-video\n className={className}\n style={{ position: \"relative\", overflow: \"hidden\", ...ASPECT_STYLES[aspectRatio] }}\n >\n <video\n ref={ref}\n src={inView ? src : undefined}\n poster={poster}\n autoPlay={autoplay}\n loop={loop}\n muted={muted}\n playsInline\n controls={controls}\n preload={lazy ? \"metadata\" : \"auto\"}\n aria-label={alt}\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\", display: \"block\" }}\n >\n {poster ? (\n <img\n src={poster}\n alt={alt ?? \"\"}\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : null}\n </video>\n </div>\n );\n}\n"
11
+ }
12
+ ]
13
+ }