@cimplify/sdk 0.48.1 → 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/react.d.mts +96 -3
- package/dist/react.d.ts +96 -3
- package/dist/react.js +304 -4
- package/dist/react.mjs +302 -5
- package/dist/server.d.mts +28 -1
- package/dist/server.d.ts +28 -1
- package/dist/server.js +125 -0
- package/dist/server.mjs +116 -1
- package/package.json +1 -1
- package/registry/date-slot-picker.json +1 -1
- package/registry/index.json +24 -0
- package/registry/media-gallery.json +16 -0
- package/registry/product-model-3d.json +13 -0
- package/registry/slot-picker.json +1 -1
- package/registry/store-video.json +13 -0
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
|
-
|
|
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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
12
12
|
"path": "date-slot-picker.tsx",
|
|
13
|
-
"content": "\"use client\";\n\nimport React, { useState, useMemo, useCallback } from \"react\";\nimport { Tabs } from \"@base-ui/react/tabs\";\nimport type { AvailableSlot, DayAvailability } from \"@cimplify/sdk\";\nimport type { DurationUnit, SchedulingMode } from \"@cimplify/sdk\";\nimport { useServiceAvailability } from \"@cimplify/sdk/react\";\nimport { SlotPicker } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface DateSlotPickerClassNames {\n root?: string;\n dateStrip?: string;\n dateButton?: string;\n nav?: string;\n navButton?: string;\n slots?: string;\n loading?: string;\n}\n\nexport interface DateSlotPickerProps {\n /** Service ID to fetch availability and slots for. */\n serviceId: string;\n /** Number of days to show in the date strip. Default: 7. */\n daysToShow?: number;\n /** Number of participants. */\n participantCount?: number;\n /** Currently selected slot. */\n selectedSlot?: AvailableSlot | null;\n /** Called when a slot is selected. */\n onSlotSelect?: (slot: AvailableSlot, date: string) => void;\n /** Pre-fetched availability data (skips fetch). */\n availability?: DayAvailability[];\n /** Show price on slots. Default: true. */\n showPrice?: boolean;\n /** Forwarded to `<SlotPicker>` to render multi-day stay labels. */\n schedulingMode?: SchedulingMode;\n /** Forwarded to `<SlotPicker>` — unit for the stay summary in multi-day mode. */\n durationUnit?: DurationUnit;\n /** Forwarded to `<SlotPicker>` — value for the stay summary in multi-day mode. */\n durationValue?: number;\n className?: string;\n classNames?: DateSlotPickerClassNames;\n}\n\nfunction formatDate(dateStr: string): string {\n const date = new Date(dateStr + \"T00:00:00\");\n return date.toLocaleDateString(undefined, { weekday: \"short\", month: \"short\", day: \"numeric\" });\n}\n\nfunction toDateString(date: Date): string {\n return date.toISOString().split(\"T\")[0];\n}\n\nfunction addDays(date: Date, days: number): Date {\n const result = new Date(date);\n result.setDate(result.getDate() + days);\n return result;\n}\n\nexport function DateSlotPicker({\n serviceId,\n daysToShow = 7,\n participantCount,\n selectedSlot,\n onSlotSelect,\n availability: availabilityProp,\n showPrice = true,\n schedulingMode,\n durationUnit,\n durationValue,\n className,\n classNames,\n}: DateSlotPickerProps): React.ReactElement {\n const [offset, setOffset] = useState(0);\n const [selectedDate, setSelectedDate] = useState<string>(toDateString(new Date()));\n\n const dateRange = useMemo(() => {\n const today = new Date();\n const start = addDays(today, offset);\n const dates: string[] = [];\n for (let i = 0; i < daysToShow; i++) {\n dates.push(toDateString(addDays(start, i)));\n }\n return {\n dates,\n startDate: dates[0],\n endDate: dates[dates.length - 1],\n };\n }, [offset, daysToShow]);\n\n const { days: fetchedDays, isLoading: availabilityLoading } = useServiceAvailability(\n serviceId,\n dateRange.startDate,\n dateRange.endDate,\n {\n participantCount,\n enabled: availabilityProp === undefined,\n },\n );\n\n const days = availabilityProp ?? fetchedDays;\n\n const availabilityMap = useMemo(() => {\n const map = new Map<string, DayAvailability>();\n for (const day of days) {\n map.set(day.date, day);\n }\n return map;\n }, [days]);\n\n const handlePrev = useCallback(() => {\n setOffset((prev) => Math.max(0, prev - daysToShow));\n }, [daysToShow]);\n\n const handleNext = useCallback(() => {\n setOffset((prev) => prev + daysToShow);\n }, [daysToShow]);\n\n const handleDateChange = useCallback((value: string | number | null) => {\n if (typeof value === \"string\") {\n setSelectedDate(value);\n }\n }, []);\n\n const handleSlotSelect = useCallback(\n (slot: AvailableSlot) => {\n onSlotSelect?.(slot, selectedDate);\n },\n [onSlotSelect, selectedDate],\n );\n\n return (\n <Tabs.Root\n value={selectedDate}\n onValueChange={handleDateChange}\n data-cimplify-date-slot-picker\n className={cn(\"flex flex-col gap-4\", className, classNames?.root)}\n >\n <div\n data-cimplify-date-nav\n className={cn(\"flex items-center justify-end gap-2\", classNames?.nav)}\n >\n <button\n type=\"button\"\n onClick={handlePrev}\n disabled={offset === 0}\n aria-label=\"Previous dates\"\n data-cimplify-date-nav-prev\n className={cn(\n \"grid place-items-center w-8 h-8 rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40\",\n classNames?.navButton,\n )}\n >\n ←\n </button>\n <button\n type=\"button\"\n onClick={handleNext}\n aria-label=\"Next dates\"\n data-cimplify-date-nav-next\n className={cn(\n \"grid place-items-center w-8 h-8 rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\",\n classNames?.navButton,\n )}\n >\n →\n </button>\n </div>\n\n <Tabs.List\n data-cimplify-date-strip\n className={cn(\"grid grid-cols-7 gap-1 sm:gap-2\", classNames?.dateStrip)}\n >\n {dateRange.dates.map((date) => {\n const dayInfo = availabilityMap.get(date);\n const hasAvailability = dayInfo?.has_availability !== false;\n const isSelected = selectedDate === date;\n return (\n <Tabs.Tab\n key={date}\n value={date}\n data-cimplify-date-button\n data-selected={isSelected || undefined}\n data-available={hasAvailability || undefined}\n data-fully-booked={(!hasAvailability) || undefined}\n className={cn(\n \"flex flex-col items-center justify-center rounded-md border border-border bg-background px-1 py-2 text-center text-xs font-medium text-foreground transition-colors hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background data-[fully-booked]:cursor-not-allowed data-[fully-booked]:opacity-40\",\n classNames?.dateButton,\n )}\n >\n {formatDate(date)}\n </Tabs.Tab>\n );\n })}\n </Tabs.List>\n\n {availabilityLoading && (\n <div\n data-cimplify-date-slot-loading\n aria-busy=\"true\"\n className={cn(\"h-32 rounded-md bg-muted/40 animate-pulse\", classNames?.loading)}\n />\n )}\n\n <div data-cimplify-date-slots className={classNames?.slots}>\n <SlotPicker\n serviceId={serviceId}\n date={selectedDate}\n participantCount={participantCount}\n selectedSlot={selectedSlot}\n onSlotSelect={handleSlotSelect}\n showPrice={showPrice}\n schedulingMode={schedulingMode}\n durationUnit={durationUnit}\n durationValue={durationValue}\n />\n </div>\n </Tabs.Root>\n );\n}\n"
|
|
13
|
+
"content": "\"use client\";\n\nimport React, { useState, useMemo, useCallback } from \"react\";\nimport { Tabs } from \"@base-ui/react/tabs\";\nimport type { AvailableSlot, DayAvailability } from \"@cimplify/sdk\";\nimport type { DurationUnit, SchedulingMode } from \"@cimplify/sdk\";\nimport { useServiceAvailability } from \"@cimplify/sdk/react\";\nimport { SlotPicker } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface DateSlotPickerClassNames {\n root?: string;\n dateStrip?: string;\n dateButton?: string;\n nav?: string;\n navButton?: string;\n slots?: string;\n loading?: string;\n}\n\nexport interface DateSlotPickerProps {\n /** Service ID to fetch availability and slots for. */\n serviceId: string;\n /** Number of days to show in the date strip. Default: 7. */\n daysToShow?: number;\n /** Number of participants. */\n participantCount?: number;\n /** Currently selected slot. */\n selectedSlot?: AvailableSlot | null;\n /** Called when a slot is selected. */\n onSlotSelect?: (slot: AvailableSlot, date: string) => void;\n /** Pre-fetched availability data (skips fetch). */\n availability?: DayAvailability[];\n /** Show price on slots. Default: true. */\n showPrice?: boolean;\n /** Forwarded to `<SlotPicker>` to render multi-day stay labels. */\n schedulingMode?: SchedulingMode;\n /** Forwarded to `<SlotPicker>` — unit for the stay summary in multi-day mode. */\n durationUnit?: DurationUnit;\n /** Forwarded to `<SlotPicker>` — value for the stay summary in multi-day mode. */\n durationValue?: number;\n /** Forwarded to `<SlotPicker>` — hide slots already in the past. Default: true. */\n hideElapsedSlots?: boolean;\n /** Forwarded to `<SlotPicker>` — minimum lead time in minutes. Default: 0. */\n minLeadMinutes?: number;\n className?: string;\n classNames?: DateSlotPickerClassNames;\n}\n\nfunction formatDate(dateStr: string): string {\n const date = new Date(dateStr + \"T00:00:00\");\n return date.toLocaleDateString(undefined, { weekday: \"short\", month: \"short\", day: \"numeric\" });\n}\n\nfunction toDateString(date: Date): string {\n return date.toISOString().split(\"T\")[0];\n}\n\nfunction addDays(date: Date, days: number): Date {\n const result = new Date(date);\n result.setDate(result.getDate() + days);\n return result;\n}\n\nexport function DateSlotPicker({\n serviceId,\n daysToShow = 7,\n participantCount,\n selectedSlot,\n onSlotSelect,\n availability: availabilityProp,\n showPrice = true,\n schedulingMode,\n durationUnit,\n durationValue,\n hideElapsedSlots = true,\n minLeadMinutes = 0,\n className,\n classNames,\n}: DateSlotPickerProps): React.ReactElement {\n const [offset, setOffset] = useState(0);\n const [selectedDate, setSelectedDate] = useState<string>(toDateString(new Date()));\n\n const dateRange = useMemo(() => {\n const today = new Date();\n const start = addDays(today, offset);\n const dates: string[] = [];\n for (let i = 0; i < daysToShow; i++) {\n dates.push(toDateString(addDays(start, i)));\n }\n return {\n dates,\n startDate: dates[0],\n endDate: dates[dates.length - 1],\n };\n }, [offset, daysToShow]);\n\n const { days: fetchedDays, isLoading: availabilityLoading } = useServiceAvailability(\n serviceId,\n dateRange.startDate,\n dateRange.endDate,\n {\n participantCount,\n enabled: availabilityProp === undefined,\n },\n );\n\n const days = availabilityProp ?? fetchedDays;\n\n const availabilityMap = useMemo(() => {\n const map = new Map<string, DayAvailability>();\n for (const day of days) {\n map.set(day.date, day);\n }\n return map;\n }, [days]);\n\n const handlePrev = useCallback(() => {\n setOffset((prev) => Math.max(0, prev - daysToShow));\n }, [daysToShow]);\n\n const handleNext = useCallback(() => {\n setOffset((prev) => prev + daysToShow);\n }, [daysToShow]);\n\n const handleDateChange = useCallback((value: string | number | null) => {\n if (typeof value === \"string\") {\n setSelectedDate(value);\n }\n }, []);\n\n const handleSlotSelect = useCallback(\n (slot: AvailableSlot) => {\n onSlotSelect?.(slot, selectedDate);\n },\n [onSlotSelect, selectedDate],\n );\n\n return (\n <Tabs.Root\n value={selectedDate}\n onValueChange={handleDateChange}\n data-cimplify-date-slot-picker\n className={cn(\"flex flex-col gap-4\", className, classNames?.root)}\n >\n <div\n data-cimplify-date-nav\n className={cn(\"flex items-center justify-end gap-2\", classNames?.nav)}\n >\n <button\n type=\"button\"\n onClick={handlePrev}\n disabled={offset === 0}\n aria-label=\"Previous dates\"\n data-cimplify-date-nav-prev\n className={cn(\n \"grid place-items-center w-8 h-8 rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40\",\n classNames?.navButton,\n )}\n >\n ←\n </button>\n <button\n type=\"button\"\n onClick={handleNext}\n aria-label=\"Next dates\"\n data-cimplify-date-nav-next\n className={cn(\n \"grid place-items-center w-8 h-8 rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\",\n classNames?.navButton,\n )}\n >\n →\n </button>\n </div>\n\n <Tabs.List\n data-cimplify-date-strip\n className={cn(\"grid grid-cols-7 gap-1 sm:gap-2\", classNames?.dateStrip)}\n >\n {dateRange.dates.map((date) => {\n const dayInfo = availabilityMap.get(date);\n const hasAvailability = dayInfo?.has_availability !== false;\n const isSelected = selectedDate === date;\n return (\n <Tabs.Tab\n key={date}\n value={date}\n data-cimplify-date-button\n data-selected={isSelected || undefined}\n data-available={hasAvailability || undefined}\n data-fully-booked={(!hasAvailability) || undefined}\n className={cn(\n \"flex flex-col items-center justify-center rounded-md border border-border bg-background px-1 py-2 text-center text-xs font-medium text-foreground transition-colors hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background data-[fully-booked]:cursor-not-allowed data-[fully-booked]:opacity-40\",\n classNames?.dateButton,\n )}\n >\n {formatDate(date)}\n </Tabs.Tab>\n );\n })}\n </Tabs.List>\n\n {availabilityLoading && (\n <div\n data-cimplify-date-slot-loading\n aria-busy=\"true\"\n className={cn(\"h-32 rounded-md bg-muted/40 animate-pulse\", classNames?.loading)}\n />\n )}\n\n <div data-cimplify-date-slots className={classNames?.slots}>\n <SlotPicker\n serviceId={serviceId}\n date={selectedDate}\n participantCount={participantCount}\n selectedSlot={selectedSlot}\n onSlotSelect={handleSlotSelect}\n showPrice={showPrice}\n schedulingMode={schedulingMode}\n durationUnit={durationUnit}\n durationValue={durationValue}\n hideElapsedSlots={hideElapsedSlots}\n minLeadMinutes={minLeadMinutes}\n />\n </div>\n </Tabs.Root>\n );\n}\n"
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
}
|
package/registry/index.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
12
12
|
"path": "slot-picker.tsx",
|
|
13
|
-
"content": "\"use client\";\n\nimport { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport React from \"react\";\nimport type { AvailableSlot } from \"@cimplify/sdk\";\nimport type { DurationUnit, SchedulingMode } from \"@cimplify/sdk\";\nimport { useAvailableSlots } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface SlotPickerClassNames {\n root?: string;\n group?: string;\n groupLabel?: string;\n slot?: string;\n slotTime?: string;\n slotPrice?: string;\n loading?: string;\n empty?: string;\n}\n\nexport interface SlotPickerProps {\n /** Pre-fetched slots (skips fetch). */\n slots?: AvailableSlot[];\n /** Service ID — used to fetch slots when `slots` prop is not provided. */\n serviceId?: string;\n /** Date string (YYYY-MM-DD) — used to fetch slots when `slots` prop is not provided. */\n date?: string;\n /** Number of participants for capacity-based availability. */\n participantCount?: number;\n /** Currently selected slot. */\n selectedSlot?: AvailableSlot | null;\n /** Called when a slot is selected. */\n onSlotSelect?: (slot: AvailableSlot) => void;\n /** Whether to group slots by time of day. Default: true. Ignored when `schedulingMode` is `\"multi_day\"`. */\n groupByTimeOfDay?: boolean;\n /** Show price on each slot. Default: true. */\n showPrice?: boolean;\n /**\n * Service scheduling mode. When `\"multi_day\"`, each slot renders as a\n * stay summary (`\"3 nights: Fri Apr 5, 3:00 PM → Mon Apr 8, 11:00 AM\"`)\n * instead of the time-of-day label. Defaults to `\"intraday\"`.\n */\n schedulingMode?: SchedulingMode;\n /** Service duration unit — used for the stay summary in multi-day mode. */\n durationUnit?: DurationUnit;\n /** Service duration value — used for the stay summary in multi-day mode. */\n durationValue?: number;\n /** Text shown when no slots available. */\n emptyMessage?: string;\n className?: string;\n classNames?: SlotPickerClassNames;\n}\n\ninterface SlotGroup {\n label: string;\n slots: AvailableSlot[];\n}\n\nfunction getTimeOfDay(timeStr: string): \"morning\" | \"afternoon\" | \"evening\" {\n const hour = parseInt(timeStr.split(\"T\").pop()?.split(\":\")[0] ?? timeStr.split(\":\")[0], 10);\n if (hour < 12) return \"morning\";\n if (hour < 17) return \"afternoon\";\n return \"evening\";\n}\n\nconst TIME_OF_DAY_LABELS: Record<string, string> = {\n morning: \"Morning\",\n afternoon: \"Afternoon\",\n evening: \"Evening\",\n};\n\nfunction groupSlots(slots: AvailableSlot[]): SlotGroup[] {\n const groups: Record<string, AvailableSlot[]> = {};\n for (const slot of slots) {\n const tod = getTimeOfDay(slot.start_time);\n if (!groups[tod]) groups[tod] = [];\n groups[tod].push(slot);\n }\n return ([\"morning\", \"afternoon\", \"evening\"] as const)\n .filter((tod) => groups[tod]?.length)\n .map((tod) => ({ label: TIME_OF_DAY_LABELS[tod], slots: groups[tod] }));\n}\n\nfunction formatTime(timeStr: string): string {\n try {\n const date = new Date(timeStr);\n if (!isNaN(date.getTime())) {\n return date.toLocaleTimeString(undefined, { hour: \"numeric\", minute: \"2-digit\" });\n }\n } catch {\n // noop\n }\n\n const parts = timeStr.split(\":\");\n if (parts.length >= 2) {\n const hour = parseInt(parts[0], 10);\n const minute = parts[1];\n const ampm = hour >= 12 ? \"PM\" : \"AM\";\n const displayHour = hour % 12 || 12;\n return `${displayHour}:${minute} ${ampm}`;\n }\n return timeStr;\n}\n\nfunction pluralizeUnit(unit: DurationUnit | undefined, value: number | undefined): string {\n if (!unit) return value === 1 ? \"day\" : \"days\";\n const v = value ?? 1;\n if (unit === \"minutes\") return v === 1 ? \"minute\" : \"minutes\";\n if (unit === \"hours\") return v === 1 ? \"hour\" : \"hours\";\n if (unit === \"days\") return v === 1 ? \"day\" : \"days\";\n if (unit === \"weeks\") return v === 1 ? \"week\" : \"weeks\";\n if (unit === \"months\") return v === 1 ? \"month\" : \"months\";\n return unit;\n}\n\nfunction formatStaySummary(\n slot: AvailableSlot,\n durationUnit: DurationUnit | undefined,\n durationValue: number | undefined,\n): string {\n const start = new Date(slot.start_time);\n const end = new Date(slot.end_time);\n const startLabel = start.toLocaleString(undefined, {\n weekday: \"short\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n });\n const endLabel = end.toLocaleString(undefined, {\n weekday: \"short\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n });\n const unitLabel = pluralizeUnit(durationUnit, durationValue);\n if (durationValue !== undefined) {\n return `${durationValue} ${unitLabel}: ${startLabel} → ${endLabel}`;\n }\n return `${startLabel} → ${endLabel}`;\n}\n\nfunction slotToValue(slot: AvailableSlot): string {\n return `${slot.start_time}|${slot.end_time}`;\n}\n\nexport function SlotPicker({\n slots: slotsProp,\n serviceId,\n date,\n participantCount,\n selectedSlot,\n onSlotSelect,\n groupByTimeOfDay = true,\n showPrice = true,\n schedulingMode = \"intraday\",\n durationUnit,\n durationValue,\n emptyMessage = \"No available slots\",\n className,\n classNames,\n}: SlotPickerProps): React.ReactElement {\n const isMultiDay = schedulingMode === \"multi_day\";\n const { slots: fetched, isLoading } = useAvailableSlots(\n serviceId ?? null,\n date ?? null,\n {\n participantCount,\n enabled: slotsProp === undefined && !!serviceId && !!date,\n },\n );\n\n const slots = slotsProp ?? fetched;\n\n if (isLoading && slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n const groups = groupByTimeOfDay && !isMultiDay\n ? groupSlots(slots)\n : [{ label: \"\", slots }];\n\n const slotsByValue = new Map<string, AvailableSlot>();\n for (const slot of slots) {\n slotsByValue.set(slotToValue(slot), slot);\n }\n\n const selectedValue = selectedSlot ? slotToValue(selectedSlot) : \"\";\n\n return (\n <RadioGroup\n data-cimplify-slot-picker\n className={cn(\"flex flex-col gap-4\", className, classNames?.root)}\n value={selectedValue}\n onValueChange={(value: string) => {\n const slot = slotsByValue.get(value);\n // Slots default to available; treat as unavailable only when the\n // backend explicitly returns `is_available: false`.\n if (slot && slot.is_available !== false) {\n onSlotSelect?.(slot);\n }\n }}\n >\n {groups.map((group) => (\n <div\n key={group.label || \"all\"}\n data-cimplify-slot-group\n className={cn(\"flex flex-col gap-2\", classNames?.group)}\n >\n {group.label && (\n <div\n data-cimplify-slot-group-label\n className={cn(\n \"text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground\",\n classNames?.groupLabel,\n )}\n >\n {group.label}\n </div>\n )}\n <div\n className={cn(\n isMultiDay\n ? \"flex flex-col gap-2\"\n : \"grid grid-cols-3 sm:grid-cols-4 gap-2\",\n )}\n >\n {group.slots.map((slot) => {\n const value = slotToValue(slot);\n const isSelected =\n selectedSlot?.start_time === slot.start_time &&\n selectedSlot?.end_time === slot.end_time;\n return (\n <Radio.Root\n key={value}\n value={value}\n disabled={slot.is_available === false}\n data-cimplify-slot\n data-selected={isSelected || undefined}\n data-unavailable={slot.is_available === false || undefined}\n className={cn(\n \"inline-flex items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition-colors hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background data-[unavailable]:cursor-not-allowed data-[unavailable]:opacity-40 data-[unavailable]:line-through\",\n isMultiDay && \"justify-between text-left\",\n classNames?.slot,\n )}\n >\n <span data-cimplify-slot-time className={classNames?.slotTime}>\n {isMultiDay\n ? formatStaySummary(slot, durationUnit, durationValue)\n : formatTime(slot.start_time)}\n </span>\n {showPrice && slot.price && (\n <span\n data-cimplify-slot-price\n className={cn(\"text-xs opacity-70\", classNames?.slotPrice)}\n >\n <Price amount={slot.price} />\n </span>\n )}\n </Radio.Root>\n );\n })}\n </div>\n </div>\n ))}\n </RadioGroup>\n );\n}\n"
|
|
13
|
+
"content": "\"use client\";\n\nimport { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport React, { useMemo } from \"react\";\nimport type { AvailableSlot } from \"@cimplify/sdk\";\nimport type { DurationUnit, SchedulingMode } from \"@cimplify/sdk\";\nimport { useAvailableSlots } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface SlotPickerClassNames {\n root?: string;\n group?: string;\n groupLabel?: string;\n slot?: string;\n slotTime?: string;\n slotPrice?: string;\n loading?: string;\n empty?: string;\n}\n\nexport interface SlotPickerProps {\n /** Pre-fetched slots (skips fetch). */\n slots?: AvailableSlot[];\n /** Service ID — used to fetch slots when `slots` prop is not provided. */\n serviceId?: string;\n /** Date string (YYYY-MM-DD) — used to fetch slots when `slots` prop is not provided. */\n date?: string;\n /** Number of participants for capacity-based availability. */\n participantCount?: number;\n /** Currently selected slot. */\n selectedSlot?: AvailableSlot | null;\n /** Called when a slot is selected. */\n onSlotSelect?: (slot: AvailableSlot) => void;\n /** Whether to group slots by time of day. Default: true. Ignored when `schedulingMode` is `\"multi_day\"`. */\n groupByTimeOfDay?: boolean;\n /** Show price on each slot. Default: true. */\n showPrice?: boolean;\n /**\n * Hide slots whose `start_time` is already in the past. Default: true.\n * Set to false to show elapsed slots greyed-out (still un-selectable).\n */\n hideElapsedSlots?: boolean;\n /**\n * Minimum lead time (in minutes) before a slot can be booked. Slots whose\n * start is sooner than `now + minLeadMinutes` are filtered out (when\n * `hideElapsedSlots` is true) or marked unavailable. Default: 0.\n */\n minLeadMinutes?: number;\n /**\n * Service scheduling mode. When `\"multi_day\"`, each slot renders as a\n * stay summary (`\"3 nights: Fri Apr 5, 3:00 PM → Mon Apr 8, 11:00 AM\"`)\n * instead of the time-of-day label. Defaults to `\"intraday\"`.\n */\n schedulingMode?: SchedulingMode;\n /** Service duration unit — used for the stay summary in multi-day mode. */\n durationUnit?: DurationUnit;\n /** Service duration value — used for the stay summary in multi-day mode. */\n durationValue?: number;\n /** Text shown when no slots available. */\n emptyMessage?: string;\n className?: string;\n classNames?: SlotPickerClassNames;\n}\n\ninterface SlotGroup {\n label: string;\n slots: AvailableSlot[];\n}\n\nfunction getTimeOfDay(timeStr: string): \"morning\" | \"afternoon\" | \"evening\" {\n const hour = parseInt(timeStr.split(\"T\").pop()?.split(\":\")[0] ?? timeStr.split(\":\")[0], 10);\n if (hour < 12) return \"morning\";\n if (hour < 17) return \"afternoon\";\n return \"evening\";\n}\n\nconst TIME_OF_DAY_LABELS: Record<string, string> = {\n morning: \"Morning\",\n afternoon: \"Afternoon\",\n evening: \"Evening\",\n};\n\nfunction groupSlots(slots: AvailableSlot[]): SlotGroup[] {\n const groups: Record<string, AvailableSlot[]> = {};\n for (const slot of slots) {\n const tod = getTimeOfDay(slot.start_time);\n if (!groups[tod]) groups[tod] = [];\n groups[tod].push(slot);\n }\n return ([\"morning\", \"afternoon\", \"evening\"] as const)\n .filter((tod) => groups[tod]?.length)\n .map((tod) => ({ label: TIME_OF_DAY_LABELS[tod], slots: groups[tod] }));\n}\n\nfunction formatTime(timeStr: string): string {\n try {\n const date = new Date(timeStr);\n if (!isNaN(date.getTime())) {\n return date.toLocaleTimeString(undefined, { hour: \"numeric\", minute: \"2-digit\" });\n }\n } catch {\n // noop\n }\n\n const parts = timeStr.split(\":\");\n if (parts.length >= 2) {\n const hour = parseInt(parts[0], 10);\n const minute = parts[1];\n const ampm = hour >= 12 ? \"PM\" : \"AM\";\n const displayHour = hour % 12 || 12;\n return `${displayHour}:${minute} ${ampm}`;\n }\n return timeStr;\n}\n\nfunction pluralizeUnit(unit: DurationUnit | undefined, value: number | undefined): string {\n if (!unit) return value === 1 ? \"day\" : \"days\";\n const v = value ?? 1;\n if (unit === \"minutes\") return v === 1 ? \"minute\" : \"minutes\";\n if (unit === \"hours\") return v === 1 ? \"hour\" : \"hours\";\n if (unit === \"days\") return v === 1 ? \"day\" : \"days\";\n if (unit === \"weeks\") return v === 1 ? \"week\" : \"weeks\";\n if (unit === \"months\") return v === 1 ? \"month\" : \"months\";\n return unit;\n}\n\nfunction formatStaySummary(\n slot: AvailableSlot,\n durationUnit: DurationUnit | undefined,\n durationValue: number | undefined,\n): string {\n const start = new Date(slot.start_time);\n const end = new Date(slot.end_time);\n const startLabel = start.toLocaleString(undefined, {\n weekday: \"short\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n });\n const endLabel = end.toLocaleString(undefined, {\n weekday: \"short\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n });\n const unitLabel = pluralizeUnit(durationUnit, durationValue);\n if (durationValue !== undefined) {\n return `${durationValue} ${unitLabel}: ${startLabel} → ${endLabel}`;\n }\n return `${startLabel} → ${endLabel}`;\n}\n\nfunction slotToValue(slot: AvailableSlot): string {\n return `${slot.start_time}|${slot.end_time}`;\n}\n\nexport function SlotPicker({\n slots: slotsProp,\n serviceId,\n date,\n participantCount,\n selectedSlot,\n onSlotSelect,\n groupByTimeOfDay = true,\n showPrice = true,\n schedulingMode = \"intraday\",\n durationUnit,\n durationValue,\n hideElapsedSlots = true,\n minLeadMinutes = 0,\n emptyMessage = \"No available slots\",\n className,\n classNames,\n}: SlotPickerProps): React.ReactElement {\n const isMultiDay = schedulingMode === \"multi_day\";\n const { slots: fetched, isLoading } = useAvailableSlots(\n serviceId ?? null,\n date ?? null,\n {\n participantCount,\n enabled: slotsProp === undefined && !!serviceId && !!date,\n },\n );\n\n const rawSlots = slotsProp ?? fetched;\n // Drop slots that have already elapsed (or fall within the lead-time\n // window). Default behaviour because nothing the merchant can do at\n // the backend stops a client clock from being slightly ahead of the\n // last availability response — this is the same defence other\n // booking flows ship by default.\n const slots = useMemo(() => {\n if (!hideElapsedSlots) return rawSlots;\n const cutoff = Date.now() + minLeadMinutes * 60_000;\n return rawSlots.filter((slot) => {\n const start = Date.parse(slot.start_time);\n return Number.isNaN(start) || start >= cutoff;\n });\n }, [rawSlots, hideElapsedSlots, minLeadMinutes]);\n\n if (isLoading && slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n const groups = groupByTimeOfDay && !isMultiDay\n ? groupSlots(slots)\n : [{ label: \"\", slots }];\n\n const slotsByValue = new Map<string, AvailableSlot>();\n for (const slot of slots) {\n slotsByValue.set(slotToValue(slot), slot);\n }\n\n const selectedValue = selectedSlot ? slotToValue(selectedSlot) : \"\";\n\n return (\n <RadioGroup\n data-cimplify-slot-picker\n className={cn(\"flex flex-col gap-4\", className, classNames?.root)}\n value={selectedValue}\n onValueChange={(value: string) => {\n const slot = slotsByValue.get(value);\n // Slots default to available; treat as unavailable only when the\n // backend explicitly returns `is_available: false`.\n if (slot && slot.is_available !== false) {\n onSlotSelect?.(slot);\n }\n }}\n >\n {groups.map((group) => (\n <div\n key={group.label || \"all\"}\n data-cimplify-slot-group\n className={cn(\"flex flex-col gap-2\", classNames?.group)}\n >\n {group.label && (\n <div\n data-cimplify-slot-group-label\n className={cn(\n \"text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground\",\n classNames?.groupLabel,\n )}\n >\n {group.label}\n </div>\n )}\n <div\n className={cn(\n isMultiDay\n ? \"flex flex-col gap-2\"\n : \"grid grid-cols-3 sm:grid-cols-4 gap-2\",\n )}\n >\n {group.slots.map((slot) => {\n const value = slotToValue(slot);\n const isSelected =\n selectedSlot?.start_time === slot.start_time &&\n selectedSlot?.end_time === slot.end_time;\n return (\n <Radio.Root\n key={value}\n value={value}\n disabled={slot.is_available === false}\n data-cimplify-slot\n data-selected={isSelected || undefined}\n data-unavailable={slot.is_available === false || undefined}\n className={cn(\n \"inline-flex items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition-colors hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background data-[unavailable]:cursor-not-allowed data-[unavailable]:opacity-40 data-[unavailable]:line-through\",\n isMultiDay && \"justify-between text-left\",\n classNames?.slot,\n )}\n >\n <span data-cimplify-slot-time className={classNames?.slotTime}>\n {isMultiDay\n ? formatStaySummary(slot, durationUnit, durationValue)\n : formatTime(slot.start_time)}\n </span>\n {showPrice && slot.price && (\n <span\n data-cimplify-slot-price\n className={cn(\"text-xs opacity-70\", classNames?.slotPrice)}\n >\n <Price amount={slot.price} />\n </span>\n )}\n </Radio.Root>\n );\n })}\n </div>\n </div>\n ))}\n </RadioGroup>\n );\n}\n"
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
}
|
|
@@ -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
|
+
}
|