@frontdesk-africa/store-js 0.1.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/LICENSE +21 -0
- package/README.md +242 -0
- package/dist/index.d.mts +2421 -0
- package/dist/index.d.ts +2421 -0
- package/dist/index.js +177 -0
- package/dist/index.mjs +142 -0
- package/package.json +50 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,2421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floating pill header — the styling contract for `header.layout === 'floating_pill'`.
|
|
3
|
+
*
|
|
4
|
+
* One rounded bar centred at the top of the page, carrying the brand, nav and actions. Everything a
|
|
5
|
+
* merchant can shape about it lives here so the React renderer, the pro-api normalizer and the Vue
|
|
6
|
+
* builder resolve identical geometry: colours (or `null` for the automatic tone), corner radius,
|
|
7
|
+
* inner padding, width, how far it floats from the page edges, its surface treatment, and what it
|
|
8
|
+
* does on scroll.
|
|
9
|
+
*
|
|
10
|
+
* The nullable colours are the important part of the model: `null` is not "no colour", it means
|
|
11
|
+
* "work it out" — the palette's ink over the page, or the tone already resolved as legible over a
|
|
12
|
+
* hero. A merchant who picks a colour overrides that for good.
|
|
13
|
+
*/
|
|
14
|
+
/** Depth under the pill. `soft` is the default lift; `lifted` reads as a floating card. */
|
|
15
|
+
type StorefrontPillShadow = "none" | "soft" | "lifted";
|
|
16
|
+
/** What the pill does as the visitor scrolls the page. */
|
|
17
|
+
type StorefrontPillScroll =
|
|
18
|
+
/** Stays exactly where it is. */
|
|
19
|
+
"stay"
|
|
20
|
+
/** Slides away on scroll down, returns the moment they scroll up. */
|
|
21
|
+
| "hide"
|
|
22
|
+
/** Tightens slightly once past the top of the page. */
|
|
23
|
+
| "shrink";
|
|
24
|
+
/**
|
|
25
|
+
* Which way round the pill sits against the site.
|
|
26
|
+
*
|
|
27
|
+
* `light` and `dark` are the PALETTE's own pair, not generic white and black: light takes the palette's
|
|
28
|
+
* surface colour with its ink on top, dark takes the ink with the palette's paper on top. On a dark
|
|
29
|
+
* palette that means "light" is the palette's dark panel — the names describe which of the two roles
|
|
30
|
+
* the pill borrows, not an absolute brightness, and a branded palette tints the pill either way.
|
|
31
|
+
*
|
|
32
|
+
* `auto` is the original behaviour: resolved from whatever the pill sits on, which means the hero on a
|
|
33
|
+
* home page and the palette ink everywhere else — so the pill can change between pages. Choosing light
|
|
34
|
+
* or dark is what makes it the same site-wide.
|
|
35
|
+
*/
|
|
36
|
+
type StorefrontPillTone = "auto" | "light" | "dark";
|
|
37
|
+
/** How wide the pill runs: hug its content (null), a % of the screen, or the page's content column. */
|
|
38
|
+
type StorefrontPillWidth = number | "content" | null;
|
|
39
|
+
interface StorefrontHeaderPillStyle {
|
|
40
|
+
/** Which of the palette's two roles the pill borrows. `bgColor`/`textColor` still override it. */
|
|
41
|
+
tone: StorefrontPillTone;
|
|
42
|
+
/** null = the tone decides. A colour here wins over the tone. */
|
|
43
|
+
bgColor: string | null;
|
|
44
|
+
/** null = automatic: ink or paper, whichever the background can carry. */
|
|
45
|
+
textColor: string | null;
|
|
46
|
+
/** Corner radius in px. 999 is a full capsule. */
|
|
47
|
+
radius: number;
|
|
48
|
+
/** Inner padding, px. */
|
|
49
|
+
paddingX: number;
|
|
50
|
+
paddingY: number;
|
|
51
|
+
/** null = hug the content (never under 600px on desktop). A number is a % of the screen.
|
|
52
|
+
* 'content' spans the same column the page's sections use, so the pill lines up with them. */
|
|
53
|
+
width: StorefrontPillWidth;
|
|
54
|
+
/** Gap between the pill and the top of the page, px. */
|
|
55
|
+
topOffset: number;
|
|
56
|
+
/** Gap kept clear at the left and right page edges, px. */
|
|
57
|
+
sideOffset: number;
|
|
58
|
+
shadow: StorefrontPillShadow;
|
|
59
|
+
/** A hairline outline; `borderColor` null = the pill's own text colour, faded. */
|
|
60
|
+
border: boolean;
|
|
61
|
+
borderColor: string | null;
|
|
62
|
+
/** Frosted glass: blurs whatever is behind and drops the pill to a translucent fill. */
|
|
63
|
+
blur: boolean;
|
|
64
|
+
scroll: StorefrontPillScroll;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Canonical storefront LINK model — the single "linking engine" shared by every linkable element
|
|
69
|
+
* (CTA buttons, header nav links, and anything that adopts it later). One shape, one resolver, one
|
|
70
|
+
* normalizer; no per-surface link types.
|
|
71
|
+
*
|
|
72
|
+
* - `resolveLinkHref` is framework-agnostic so the React renderer and the builder preview produce
|
|
73
|
+
* byte-identical hrefs (it mirrors the renderer's `pageHref`).
|
|
74
|
+
* - `normalizeLink` clamps arbitrary input to a valid link — reused by the pro-api normalizer and
|
|
75
|
+
* any safe read.
|
|
76
|
+
* The portal builder edits this shape through `<StorefrontLinkEditor>`.
|
|
77
|
+
*/
|
|
78
|
+
type StorefrontLinkType = "none" | "section" | "page" | "external" | "package" | "product" | "collection" | "catalog" | "event" | "contact_form" | "chat" | "chat_widget";
|
|
79
|
+
interface StorefrontLink {
|
|
80
|
+
type: StorefrontLinkType;
|
|
81
|
+
/** type==='section' — a section id on the current page (jumps to its #anchor). */
|
|
82
|
+
sectionId?: string | null;
|
|
83
|
+
/** type==='page' — a page slug. */
|
|
84
|
+
pageSlug?: string | null;
|
|
85
|
+
/** type==='external' — an absolute URL. */
|
|
86
|
+
url?: string | null;
|
|
87
|
+
/** type==='package' — the package's slug (its public detail page). */
|
|
88
|
+
packageSlug?: string | null;
|
|
89
|
+
/** type==='package' (optional) — a specific option ref; opens that option's checkout modal. */
|
|
90
|
+
packageOptionRef?: string | null;
|
|
91
|
+
/** type==='product' — the product's slug (its public Store detail page). */
|
|
92
|
+
productSlug?: string | null;
|
|
93
|
+
/** type==='collection' — the collection's slug (its public /collections page); null/'' = the
|
|
94
|
+
* all-collections index. */
|
|
95
|
+
collectionSlug?: string | null;
|
|
96
|
+
/** type==='catalog' — a media catalog / Portfolio slug (its public /catalog/media/{slug} page). */
|
|
97
|
+
catalogSlug?: string | null;
|
|
98
|
+
/** type==='event' — an event's slug (its public /events/{slug} page); null/'' = the all-events
|
|
99
|
+
* listing at /events. */
|
|
100
|
+
eventSlug?: string | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* People (CRM) shared contracts — FrontDesk spec §3, §10.4 (PV-003).
|
|
105
|
+
*
|
|
106
|
+
* One unified, tag-driven contact object per workspace. A single People record can carry
|
|
107
|
+
* multiple tags (Client / Customer / Vendor / Lead / Guest). Promotion = tag-add on the same
|
|
108
|
+
* record (D-8), never a merge of records. Storefront buyers + booking submitters upsert into
|
|
109
|
+
* People by matching on email OR phone (see `upsertContact` in the people service).
|
|
110
|
+
*/
|
|
111
|
+
type PeopleTag = 'client' | 'customer' | 'vendor' | 'lead' | 'guest' | 'team' | 'promoter' | 'subscriber';
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Storefront PROMO campaigns — the on-load lightbox and the exit-intent catcher.
|
|
115
|
+
*
|
|
116
|
+
* Site-wide content (it lives beside `tracking`, not in a section) because a promo is not part of any
|
|
117
|
+
* one page's layout: an on-load campaign belongs to the home page, and an exit-intent one follows the
|
|
118
|
+
* visitor across every page.
|
|
119
|
+
*
|
|
120
|
+
* Everything a visitor's browser needs to DECIDE whether to show a campaign is computed here, so the
|
|
121
|
+
* React renderer and the builder preview agree by construction — the same rule that keeps
|
|
122
|
+
* `resolveLinkHref` honest.
|
|
123
|
+
*/
|
|
124
|
+
/** What makes a campaign appear. */
|
|
125
|
+
type PromoTrigger =
|
|
126
|
+
/** Home page only, after `delayMs`. The classic "here's our sale" lightbox. */
|
|
127
|
+
"on_load"
|
|
128
|
+
/** Every page, when the visitor looks like they're leaving. */
|
|
129
|
+
| "exit_intent";
|
|
130
|
+
type PromoMediaType = "none" | "image" | "video";
|
|
131
|
+
/** A promo's call-to-action. Reuses the canonical link engine, so it can point anywhere a nav link can. */
|
|
132
|
+
interface PromoButton {
|
|
133
|
+
id: string;
|
|
134
|
+
label: string;
|
|
135
|
+
link: StorefrontLink;
|
|
136
|
+
}
|
|
137
|
+
interface PromoCampaign {
|
|
138
|
+
id: string;
|
|
139
|
+
/** Author-facing name. Never shown to buyers — it's how the merchant tells campaigns apart. */
|
|
140
|
+
name: string;
|
|
141
|
+
enabled: boolean;
|
|
142
|
+
trigger: PromoTrigger;
|
|
143
|
+
/** on_load only: how long after the page settles before it appears. Clamped to 0..30s. */
|
|
144
|
+
delayMs: number;
|
|
145
|
+
/** Optional window (ISO dates). Absent = starts/runs forever. A merchant setting an end date is how
|
|
146
|
+
* a sale promo stops on its own instead of being left up for weeks. */
|
|
147
|
+
startAt?: string | null;
|
|
148
|
+
endAt?: string | null;
|
|
149
|
+
mediaType: PromoMediaType;
|
|
150
|
+
imageUrl?: string | null;
|
|
151
|
+
videoUrl?: string | null;
|
|
152
|
+
/** Video autoplays muted + looping. When true the modal offers an unmute control; the buyer's click
|
|
153
|
+
* on it must not follow `mediaLink` (the media area is otherwise clickable). */
|
|
154
|
+
videoSoundEnabled?: boolean;
|
|
155
|
+
heading: string;
|
|
156
|
+
body: string;
|
|
157
|
+
/** Up to 2. The FIRST is the primary CTA and is what the clickable media area follows. */
|
|
158
|
+
buttons: PromoButton[];
|
|
159
|
+
/** Label for the "no thanks" action. Empty falls back to "Dismiss". */
|
|
160
|
+
dismissLabel?: string | null;
|
|
161
|
+
emailCaptureEnabled?: boolean;
|
|
162
|
+
emailPlaceholder?: string | null;
|
|
163
|
+
emailButtonLabel?: string | null;
|
|
164
|
+
/** Shown in place of the form once they submit. */
|
|
165
|
+
emailSuccessMessage?: string | null;
|
|
166
|
+
/** Tag applied to the captured contact in People. Constrained to the real People tag set (it is a DB
|
|
167
|
+
* enum, not free text), so this is a picker in the editor, not a text field. Absent = 'lead'. */
|
|
168
|
+
emailTag?: PeopleTag | null;
|
|
169
|
+
/**
|
|
170
|
+
* Bumped by the editor's "Show this again" button. The browser's dismissal record is keyed on
|
|
171
|
+
* `{id}:{version}`, so bumping it re-opens the campaign for everyone — including people who already
|
|
172
|
+
* dismissed it. Editing copy deliberately does NOT re-show it; that would mean a typo fix re-interrupts
|
|
173
|
+
* every visitor.
|
|
174
|
+
*/
|
|
175
|
+
version: number;
|
|
176
|
+
}
|
|
177
|
+
/** The site-wide promo block. One ordered list; each entry carries its own trigger. */
|
|
178
|
+
interface StorefrontPromoContent {
|
|
179
|
+
campaigns: PromoCampaign[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Storefront FOOTER TEMPLATES — the one registry of which footer layouts exist.
|
|
184
|
+
*
|
|
185
|
+
* This list was previously restated in six places (two content types, a zod enum, a hand-written
|
|
186
|
+
* normalizer, the builder's picker options and the renderer's branches). Adding a template meant
|
|
187
|
+
* finding all six, and missing the zod one silently stripped the value on save. Everything now
|
|
188
|
+
* derives from here.
|
|
189
|
+
*
|
|
190
|
+
* Adding a template: append to FOOTER_TEMPLATES + FOOTER_TEMPLATE_META, give it a palette in the
|
|
191
|
+
* builder's `footerTemplatePalette`, a branch in the renderer's `Footer`, a skeleton in the editor's
|
|
192
|
+
* picker, and an entry in FOOTER_TRUST_DIVIDER_DEFAULT.
|
|
193
|
+
*/
|
|
194
|
+
declare const FOOTER_TEMPLATES: readonly ["minimal", "brand_columns", "pale_grid", "bold_cta", "wordmark_band", "stark_columns", "business_local", "trust_grid"];
|
|
195
|
+
type FooterTemplate = (typeof FOOTER_TEMPLATES)[number];
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Optional own colors for a block. `null`/absent = inherit the footer palette.
|
|
199
|
+
*
|
|
200
|
+
* This is what makes a contrasting band possible (the lilac newsletter strip in the Parcel Monkey
|
|
201
|
+
* reference, the dark newsletter column in RELISTED) without the merchant hand-editing the whole
|
|
202
|
+
* footer palette to get one band.
|
|
203
|
+
*/
|
|
204
|
+
interface FooterBlockStyle {
|
|
205
|
+
backgroundColor?: string | null;
|
|
206
|
+
textColor?: string | null;
|
|
207
|
+
/** Button fill / icon color inside the block. Falls back to the footer accent. */
|
|
208
|
+
accentColor?: string | null;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Space above/below a block, in px, so it can be pushed off its neighbours. Mirrors the
|
|
212
|
+
* spaceTop/spaceBottom knobs on hero blocks and groups, including the 0..200 range, so the control
|
|
213
|
+
* reads as one the merchant already knows.
|
|
214
|
+
*
|
|
215
|
+
* Carried by the blocks that occupy a full-width ROW of the footer (newsletter, payments, trust) —
|
|
216
|
+
* the ones whose spacing a merchant can actually see. Address and hours sit inside a column where
|
|
217
|
+
* the surrounding grid gap owns the rhythm, so they deliberately have no spacing knobs.
|
|
218
|
+
*/
|
|
219
|
+
interface FooterBlockSpacing {
|
|
220
|
+
spaceTop?: number;
|
|
221
|
+
spaceBottom?: number;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* A FULL-BLEED hairline rule above a block — it escapes the footer's horizontal padding, so it reads
|
|
225
|
+
* as a band separator rather than a boxed underline. Carried by the blocks that occupy a full-width
|
|
226
|
+
* row (newsletter, trust).
|
|
227
|
+
*
|
|
228
|
+
* `showDivider` absent means "follow the template" (FOOTER_TRUST_DIVIDER_DEFAULT); an explicit
|
|
229
|
+
* true/false always wins. Resolve with `footerTrustDividerVisible`, never by reading the field.
|
|
230
|
+
*/
|
|
231
|
+
interface FooterBlockDivider {
|
|
232
|
+
showDivider?: boolean;
|
|
233
|
+
/** 0-100. Percentage of the block's text colour. */
|
|
234
|
+
dividerOpacity?: number;
|
|
235
|
+
/** Rule thickness in px, 1..8. */
|
|
236
|
+
dividerHeight?: number;
|
|
237
|
+
}
|
|
238
|
+
/** Field/button scale for the newsletter form. */
|
|
239
|
+
declare const FOOTER_FIELD_SIZES: readonly ["small", "normal", "big"];
|
|
240
|
+
type FooterFieldSize = (typeof FOOTER_FIELD_SIZES)[number];
|
|
241
|
+
interface StorefrontFooterNewsletter extends FooterBlockStyle, FooterBlockSpacing, FooterBlockDivider {
|
|
242
|
+
enabled: boolean;
|
|
243
|
+
heading: string;
|
|
244
|
+
/** Sub-copy under the heading. Blank hides the line. */
|
|
245
|
+
body: string;
|
|
246
|
+
/** Whether the name field is shown. Name is NEVER required to submit, even when shown. */
|
|
247
|
+
showNameField: boolean;
|
|
248
|
+
namePlaceholder: string;
|
|
249
|
+
emailPlaceholder: string;
|
|
250
|
+
buttonLabel: string;
|
|
251
|
+
/** Replaces the form in place once they subscribe. */
|
|
252
|
+
successMessage: string;
|
|
253
|
+
/** Small print under the field, in lieu of a consent checkbox. Blank hides it. */
|
|
254
|
+
consentNote: string;
|
|
255
|
+
/** People tag applied to the contact. Constrained to the real tag set (a DB enum), so this is a
|
|
256
|
+
* picker in the editor. Absent = 'subscriber'. */
|
|
257
|
+
tag?: PeopleTag | null;
|
|
258
|
+
/** Workspace-branded welcome email to whoever subscribed. */
|
|
259
|
+
welcomeEmailEnabled: boolean;
|
|
260
|
+
/** null = the platform default copy. Merchants who never open this still send a good email. */
|
|
261
|
+
welcomeSubject?: string | null;
|
|
262
|
+
welcomeBody?: string | null;
|
|
263
|
+
/** Input + button scale. Absent = 'normal'. */
|
|
264
|
+
fieldSize?: FooterFieldSize;
|
|
265
|
+
/** Corner radius of the fields and button, px. Absent = 8; 0 = hard square edges. */
|
|
266
|
+
radius?: number;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The payment-method logo row. Deliberately carries NO list of marks: the marks are derived
|
|
270
|
+
* server-side from the store's enabled methods (see ./payment-marks), so a footer can never
|
|
271
|
+
* advertise a method the checkout will not actually offer.
|
|
272
|
+
*/
|
|
273
|
+
interface StorefrontFooterPayments extends FooterBlockStyle, FooterBlockSpacing {
|
|
274
|
+
enabled: boolean;
|
|
275
|
+
/** Optional small heading, e.g. "We accept". Blank/null renders the marks alone. */
|
|
276
|
+
title?: string | null;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The address block. Reads `content.contact` — the address is entered once, in the contact section,
|
|
280
|
+
* and the footer shows the parts the merchant ticks.
|
|
281
|
+
*/
|
|
282
|
+
interface StorefrontFooterAddress extends FooterBlockStyle {
|
|
283
|
+
enabled: boolean;
|
|
284
|
+
/** Heading above the block. null = the storefront display name, but only when `showBusinessName`
|
|
285
|
+
* is on — which defaults OFF, because this block usually sits directly under the footer's brand
|
|
286
|
+
* text and would otherwise print the business name twice. */
|
|
287
|
+
title?: string | null;
|
|
288
|
+
showBusinessName: boolean;
|
|
289
|
+
showAddress: boolean;
|
|
290
|
+
/** Email and phone render as ICONS in the footer's social row, not as text lines in this block —
|
|
291
|
+
* they are contact handles like the socials, and reading as one row of icons is tidier. */
|
|
292
|
+
showEmail: boolean;
|
|
293
|
+
showPhone: boolean;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* The opening-hours block. Rows come from the workspace's front-desk `businessHours`, so there is
|
|
297
|
+
* nothing to retype; the block self-hides when hours were never configured.
|
|
298
|
+
*/
|
|
299
|
+
interface StorefrontFooterHours extends FooterBlockStyle {
|
|
300
|
+
enabled: boolean;
|
|
301
|
+
/** null = "Opening hours". */
|
|
302
|
+
title?: string | null;
|
|
303
|
+
/** Label for a day with no configured window. */
|
|
304
|
+
closedLabel?: string | null;
|
|
305
|
+
}
|
|
306
|
+
/** Curated icon set for trust items. A closed list beats exposing all of lucide to a picker. */
|
|
307
|
+
declare const FOOTER_TRUST_ICONS: readonly ["shield-check", "lock", "truck", "package", "headset", "star", "badge-check", "credit-card", "globe", "clock", "heart", "leaf"];
|
|
308
|
+
type FooterTrustIcon = (typeof FOOTER_TRUST_ICONS)[number];
|
|
309
|
+
interface StorefrontFooterTrustItem {
|
|
310
|
+
id: string;
|
|
311
|
+
icon: FooterTrustIcon;
|
|
312
|
+
/** The light first line, e.g. "100% SECURE". */
|
|
313
|
+
label: string;
|
|
314
|
+
/** The bold second line, e.g. "CHECKOUT". Blank renders a single-line item. */
|
|
315
|
+
emphasis: string;
|
|
316
|
+
/**
|
|
317
|
+
* This badge's icon colour. Absent/null = the strip's own icon colour (the block's `accentColor`),
|
|
318
|
+
* which in turn falls back to the footer accent. An OVERRIDE rather than a required value, so the
|
|
319
|
+
* strip still reads as one unit by default and a merchant only breaks that deliberately.
|
|
320
|
+
*/
|
|
321
|
+
iconColor?: string | null;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* 1 to 4 icon items. A single item renders as a plain inline trust line ("Secure checkout"); two or
|
|
325
|
+
* more render as the spread strip from the Parcel Monkey reference.
|
|
326
|
+
*/
|
|
327
|
+
interface StorefrontFooterTrust extends FooterBlockStyle, FooterBlockSpacing, FooterBlockDivider {
|
|
328
|
+
enabled: boolean;
|
|
329
|
+
items: StorefrontFooterTrustItem[];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* SOCIAL SHARE IMAGE — the one registry of how a storefront link previews on WhatsApp/iMessage/
|
|
334
|
+
* Facebook/X, and the only place the style + template lists exist.
|
|
335
|
+
*
|
|
336
|
+
* Three styles:
|
|
337
|
+
* 'card' — a designed 1200x630 card rendered by the storefront's /api/og, laid out by one of
|
|
338
|
+
* SOCIAL_CARD_TEMPLATES and written from the site's own name/description (or the
|
|
339
|
+
* owner's overrides).
|
|
340
|
+
* 'photo' — the owner's best real photo, full frame, no text.
|
|
341
|
+
* 'custom' — artwork the owner designed and uploaded, cover-cropped to 1200x630.
|
|
342
|
+
*
|
|
343
|
+
* Adding a template: append to SOCIAL_CARD_TEMPLATES + SOCIAL_CARD_TEMPLATE_META and give it a
|
|
344
|
+
* branch in the storefront's /api/og route. The builder picker renders straight from the META list,
|
|
345
|
+
* so it needs no edit.
|
|
346
|
+
*/
|
|
347
|
+
declare const SOCIAL_PREVIEW_STYLES: readonly ["card", "photo", "custom"];
|
|
348
|
+
type SocialPreviewStyle = (typeof SOCIAL_PREVIEW_STYLES)[number];
|
|
349
|
+
/**
|
|
350
|
+
* Card layouts. `scrim` is FIRST and the default because it renders exactly what every storefront
|
|
351
|
+
* on the old two-way 'card' | 'photo' switch already had — an existing site must keep its share
|
|
352
|
+
* image byte-identical when this list ships.
|
|
353
|
+
*/
|
|
354
|
+
declare const SOCIAL_CARD_TEMPLATES: readonly ["scrim", "bold", "split", "lockup"];
|
|
355
|
+
type SocialCardTemplate = (typeof SOCIAL_CARD_TEMPLATES)[number];
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Social embeds — shared platform detection + embed helpers (Tier 1).
|
|
359
|
+
*
|
|
360
|
+
* The `social` storefront section (and its future hero-block twin) lets a merchant paste links to
|
|
361
|
+
* their Instagram / TikTok / YouTube / Facebook / X posts and have them rendered inline on the
|
|
362
|
+
* storefront. Detection + canonicalisation live here so all three apps agree byte-for-byte: pro-api
|
|
363
|
+
* validates the pasted URL (unknown platform = dropped), the React renderer picks the right embed
|
|
364
|
+
* strategy, and the Nuxt builder shows the right icon + preview.
|
|
365
|
+
*
|
|
366
|
+
* v1 is CURATED (the merchant pastes each URL) and renders via each platform's own no-auth embed
|
|
367
|
+
* script (Meta SDK for IG/FB, X widgets.js, TikTok embed.js) or a plain iframe (YouTube). A synced
|
|
368
|
+
* auto-feed is a later phase; the item shape already carries a `source` so it can slot in.
|
|
369
|
+
*/
|
|
370
|
+
type SocialPlatform = "instagram" | "tiktok" | "youtube" | "facebook" | "x";
|
|
371
|
+
/**
|
|
372
|
+
* How a `social` section arranges its items. `auto` (the default) resolves at render time from the
|
|
373
|
+
* item count: a lone post shows large (featured), two or more tile into a grid — the renderer/editor
|
|
374
|
+
* both call `resolveSocialLayout`. The other three are explicit user choices.
|
|
375
|
+
*/
|
|
376
|
+
type SocialLayout = "auto" | "grid" | "carousel" | "featured";
|
|
377
|
+
/**
|
|
378
|
+
* One embedded social item. `url` is the canonical, embeddable permalink (never a shortener).
|
|
379
|
+
* `thumbnailUrl` is an optional cached poster (fetched from a public oEmbed endpoint on save, where
|
|
380
|
+
* the platform still returns one); the live embed hydrates over it. `source` is `curated` today; a
|
|
381
|
+
* synced feed writes `synced` rows into the same list later.
|
|
382
|
+
*/
|
|
383
|
+
interface SocialEmbedItem {
|
|
384
|
+
id: string;
|
|
385
|
+
platform: SocialPlatform;
|
|
386
|
+
url: string;
|
|
387
|
+
thumbnailUrl?: string | null;
|
|
388
|
+
caption?: string;
|
|
389
|
+
source?: "curated" | "synced";
|
|
390
|
+
visible: boolean;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* How uploaded images are encoded:
|
|
395
|
+
* - webp — re-encode everything to WebP (smallest; WebP keeps alpha). The default.
|
|
396
|
+
* - webp_preserve_png — photos → WebP, but keep PNG when the source is a PNG (safest for anything
|
|
397
|
+
* downstream that can't read WebP; slightly larger for transparent logos).
|
|
398
|
+
* - original — no resize, no re-encode; upload the file as-is (the high-fidelity escape
|
|
399
|
+
* hatch for a FrontDesk that genuinely needs full-quality images).
|
|
400
|
+
*/
|
|
401
|
+
type MediaImageFormat = 'webp' | 'webp_preserve_png' | 'original';
|
|
402
|
+
/**
|
|
403
|
+
* The resolved policy handed to an upload client. `quality` is a 0..1 float (canvas.toBlob units).
|
|
404
|
+
* This is what portal/storefront resize code consumes.
|
|
405
|
+
*/
|
|
406
|
+
interface MediaUploadPolicy {
|
|
407
|
+
maxDim: number;
|
|
408
|
+
quality: number;
|
|
409
|
+
format: MediaImageFormat;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
type BookingFieldType = 'short_text' | 'long_text' | 'number' | 'currency' | 'date' | 'time' | 'single_select' | 'multi_select' | 'radio' | 'checkbox' | 'toggle' | 'file';
|
|
413
|
+
/** Conditional logic (v1): show this field only when another field equals a value. */
|
|
414
|
+
interface BookingFieldCondition {
|
|
415
|
+
field: string;
|
|
416
|
+
equals: string | number | boolean;
|
|
417
|
+
}
|
|
418
|
+
interface BookingFormField {
|
|
419
|
+
key: string;
|
|
420
|
+
type: BookingFieldType;
|
|
421
|
+
label: string;
|
|
422
|
+
required?: boolean;
|
|
423
|
+
hidden?: boolean;
|
|
424
|
+
placeholder?: string;
|
|
425
|
+
helpText?: string;
|
|
426
|
+
options?: string[];
|
|
427
|
+
showIf?: BookingFieldCondition;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Who bears the FrontDesk charge on a paid line/order — added ON TOP for the customer, or netted from
|
|
431
|
+
* the merchant's credit. Mirrors the event ticket fee mode. */
|
|
432
|
+
type ChargeMode = 'pass_on' | 'absorb';
|
|
433
|
+
|
|
434
|
+
/** The rating distribution, keyed by star. Always all five keys, zeros included, so the bars render
|
|
435
|
+
* without the caller having to fill gaps. */
|
|
436
|
+
interface ProductRatingCounts {
|
|
437
|
+
1: number;
|
|
438
|
+
2: number;
|
|
439
|
+
3: number;
|
|
440
|
+
4: number;
|
|
441
|
+
5: number;
|
|
442
|
+
}
|
|
443
|
+
/** The aggregate that drives the header, the distribution bars and the JSON-LD. */
|
|
444
|
+
interface ProductReviewAggregate {
|
|
445
|
+
count: number;
|
|
446
|
+
/** Rounded to one decimal for display. Derived from sum/count, never stored. */
|
|
447
|
+
average: number;
|
|
448
|
+
counts: ProductRatingCounts;
|
|
449
|
+
/** The cached AI summary, when one has been built and is not stale. */
|
|
450
|
+
aiSummary?: string | null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Product Information (PV-111) — the enrichment layer on a store product: a detailed description, key
|
|
455
|
+
* highlights, retail identifiers, brand/make, weight + dimensions, category specifications, care,
|
|
456
|
+
* warranty, origin and sustainability.
|
|
457
|
+
*
|
|
458
|
+
* Everything here is OPTIONAL. A product with none of it behaves exactly as it did before; the portal
|
|
459
|
+
* nudges (never blocks) and the storefront hides every empty field.
|
|
460
|
+
*
|
|
461
|
+
* Three things live in this file rather than in the API or the portal, because all three surfaces need
|
|
462
|
+
* the SAME answer:
|
|
463
|
+
* - the category list + its suggested spec templates (portal renders them, pro-api validates against
|
|
464
|
+
* them, the AI prompt is built from them),
|
|
465
|
+
* - the spec/highlight normalizers (one definition of "clean", used on write),
|
|
466
|
+
* - `scoreProductInfo` (the portal completion ring, and any later analytics).
|
|
467
|
+
*
|
|
468
|
+
* Plan: `services/design-docs/frontdesk-docs/product-information-tab.md`.
|
|
469
|
+
*/
|
|
470
|
+
|
|
471
|
+
type WeightUnit = 'kg' | 'g' | 'lb' | 'oz';
|
|
472
|
+
type DimensionUnit = 'cm' | 'mm' | 'm' | 'in';
|
|
473
|
+
/** How a spec value is captured + rendered. Advisory: every value is stored as a string either way. */
|
|
474
|
+
type SpecKind = 'text' | 'number' | 'select' | 'boolean' | 'multi';
|
|
475
|
+
/**
|
|
476
|
+
* One row in a product's specification list. The list holds BOTH category-template answers and the
|
|
477
|
+
* merchant's own custom rows, in one ordered array, which is what lets a merchant reorder freely across
|
|
478
|
+
* the two. A custom row is simply one with `key: null`.
|
|
479
|
+
*/
|
|
480
|
+
interface ProductSpec {
|
|
481
|
+
/** Stable id so an edit or a reorder round-trips without re-keying by index. */
|
|
482
|
+
id: string;
|
|
483
|
+
/** Template key when this row came from a category template ('screen_size'), null when merchant-authored. */
|
|
484
|
+
key: string | null;
|
|
485
|
+
label: string;
|
|
486
|
+
value: string;
|
|
487
|
+
kind: SpecKind;
|
|
488
|
+
/** Optional unit suffix rendered after the value ('inch', 'ml', 'W'). */
|
|
489
|
+
unit?: string | null;
|
|
490
|
+
/** Display order, persisted so a drag-reorder survives a reload. */
|
|
491
|
+
sort: number;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* A product description is an ordered list of blocks, not one text field: merchants sell with pictures
|
|
495
|
+
* BETWEEN paragraphs (a size chart after the fit copy, a lifestyle shot after the story), the way
|
|
496
|
+
* Alibaba and Jumia product pages read.
|
|
497
|
+
*
|
|
498
|
+
* `long_description` is still stored, but DERIVED from the text blocks on write (see
|
|
499
|
+
* `blocksToPlainText`). That keeps one source of truth for the merchant to edit while search, the AI
|
|
500
|
+
* prompt, the completion score and the schema.org `description` all keep reading clean prose without
|
|
501
|
+
* knowing blocks exist.
|
|
502
|
+
*/
|
|
503
|
+
type DescriptionBlockKind = 'text' | 'image' | 'video' | 'embed';
|
|
504
|
+
/** How wide a media block renders in the storefront description column. */
|
|
505
|
+
type BlockWidth = 'full' | 'half';
|
|
506
|
+
interface ProductDescriptionBlock {
|
|
507
|
+
id: string;
|
|
508
|
+
kind: DescriptionBlockKind;
|
|
509
|
+
/** `text` blocks: a paragraph or a short heading. */
|
|
510
|
+
text?: string | null;
|
|
511
|
+
/** `image` / `video` blocks: the uploaded asset. `embed` blocks: the canonical post permalink. */
|
|
512
|
+
url?: string | null;
|
|
513
|
+
/**
|
|
514
|
+
* `embed` blocks only: which platform the link resolved to. Detected SERVER-SIDE from the pasted URL
|
|
515
|
+
* via the shared `normalizeSocialUrl`, never trusted from the client, so the storefront can pick the
|
|
516
|
+
* right embed strategy without re-parsing.
|
|
517
|
+
*/
|
|
518
|
+
platform?: SocialPlatform | null;
|
|
519
|
+
/** Accessibility text for an image. Also what a screen reader and Google see. */
|
|
520
|
+
alt?: string | null;
|
|
521
|
+
/** Optional caption rendered under the media. */
|
|
522
|
+
caption?: string | null;
|
|
523
|
+
width?: BlockWidth;
|
|
524
|
+
sort: number;
|
|
525
|
+
}
|
|
526
|
+
type ProductInfoCategory = 'fashion' | 'footwear' | 'electronics' | 'phones' | 'beauty' | 'food' | 'home' | 'furniture' | 'appliances' | 'auto' | 'health' | 'baby' | 'digital' | 'other';
|
|
527
|
+
/**
|
|
528
|
+
* What a product actually stores in `infoCategory`. The 14 built-ins keep their slugs so no existing
|
|
529
|
+
* row moves; a category a merchant added through the `product_category` vocab stores its LABEL
|
|
530
|
+
* verbatim. Everything reading the field goes through `productInfoCategoryLabel`, which handles both.
|
|
531
|
+
*/
|
|
532
|
+
type ProductCategoryValue = ProductInfoCategory | (string & {});
|
|
533
|
+
/** Everything the Product Information tab owns, as it reads back. Folded into `ProductView`. */
|
|
534
|
+
interface ProductInfoView {
|
|
535
|
+
infoCategory: ProductCategoryValue | null;
|
|
536
|
+
/** DERIVED from `descriptionBlocks` whenever there are any (see `blocksToPlainText`); authored
|
|
537
|
+
* directly only on a product that has no blocks. Never edit both. */
|
|
538
|
+
longDescription: string | null;
|
|
539
|
+
/** The authored description: paragraphs and media, in order. */
|
|
540
|
+
descriptionBlocks: ProductDescriptionBlock[];
|
|
541
|
+
highlights: string[];
|
|
542
|
+
sku: string | null;
|
|
543
|
+
barcode: string | null;
|
|
544
|
+
mpn: string | null;
|
|
545
|
+
hsCode: string | null;
|
|
546
|
+
brand: string | null;
|
|
547
|
+
manufacturer: string | null;
|
|
548
|
+
modelName: string | null;
|
|
549
|
+
weightValue: number | null;
|
|
550
|
+
weightUnit: WeightUnit | null;
|
|
551
|
+
dimLength: number | null;
|
|
552
|
+
dimWidth: number | null;
|
|
553
|
+
dimHeight: number | null;
|
|
554
|
+
dimUnit: DimensionUnit | null;
|
|
555
|
+
/** ISO-3166 alpha-2. */
|
|
556
|
+
countryOfOrigin: string | null;
|
|
557
|
+
warrantyText: string | null;
|
|
558
|
+
warrantyMonths: number | null;
|
|
559
|
+
careInstructions: string | null;
|
|
560
|
+
sustainabilityText: string | null;
|
|
561
|
+
specs: ProductSpec[];
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* What the STOREFRONT receives. The HS code is customs paperwork the merchant keeps for shipping
|
|
565
|
+
* documents; it means nothing to a buyer, so it never leaves the portal.
|
|
566
|
+
*/
|
|
567
|
+
type PublicProductInfo = Omit<ProductInfoView, 'hsCode'>;
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Store — products with variants (size/colour), inventory, delivery zones. The storefront "Store" lets a
|
|
571
|
+
* pro sell physical (or digital) products directly + attach them to events. Mirrors Packages: a Product
|
|
572
|
+
* owns 1..N Variants (each its own price/stock/image); a product with one variant = a simple product.
|
|
573
|
+
* Money is in MINOR units. Cross-DB refs are UUIDv7 `ref`s, never FKs.
|
|
574
|
+
*/
|
|
575
|
+
|
|
576
|
+
/** How a package product's options are chosen: one tier (radio) vs a menu (pick many + quantity). */
|
|
577
|
+
type SelectionMode = 'choose_one' | 'choose_many';
|
|
578
|
+
/** How the storefront draws a product's option picker. A variant missing an image/hex falls back to text. */
|
|
579
|
+
type VariantDisplay = 'text' | 'image' | 'color';
|
|
580
|
+
/** Product/option payment modes: the shipped package modes + Pay Later (Save-For-It deferred). */
|
|
581
|
+
type ProductPricingMode = PackagePricingMode | 'pay_later';
|
|
582
|
+
interface ProductAddonOption {
|
|
583
|
+
ref: string;
|
|
584
|
+
kind: AddonOptionKind;
|
|
585
|
+
label: string;
|
|
586
|
+
description: string | null;
|
|
587
|
+
priceMinor: number;
|
|
588
|
+
/** This add-on's price per accepted-secondary currency (public payloads only): the linked variant's
|
|
589
|
+
* explicit per-currency price when set, else the server's FX default (rounded up clean) — exactly
|
|
590
|
+
* what a booking in that currency will charge. Currency absent = add-on unavailable in it. */
|
|
591
|
+
prices?: VariantPrice[];
|
|
592
|
+
imageUrl: string | null;
|
|
593
|
+
allowQuantity: boolean;
|
|
594
|
+
maxQty: number | null;
|
|
595
|
+
/** For `linked` add-ons, the referenced product/variant (price-only link in v1); else null. */
|
|
596
|
+
linkedProductRef: string | null;
|
|
597
|
+
linkedVariantRef: string | null;
|
|
598
|
+
}
|
|
599
|
+
interface ProductAddonGroup {
|
|
600
|
+
ref: string;
|
|
601
|
+
name: string;
|
|
602
|
+
selectionType: AddonSelectionType;
|
|
603
|
+
required: boolean;
|
|
604
|
+
minSelect: number | null;
|
|
605
|
+
maxSelect: number | null;
|
|
606
|
+
sort: number;
|
|
607
|
+
/** null = applies to every option; set = scoped to one option (variant), e.g. Drone only for Gold. */
|
|
608
|
+
variantRef: string | null;
|
|
609
|
+
options: ProductAddonOption[];
|
|
610
|
+
}
|
|
611
|
+
/** Buyer-facing payment methods a Store/checkout can offer (the unified payment layer). */
|
|
612
|
+
type StorePaymentMethod = 'paystack' | 'flutterwave' | 'pvb' | 'stripe';
|
|
613
|
+
/**
|
|
614
|
+
* A variant's price in ONE currency. Multi-currency pricing: the variant's base `priceMinor` + `currency`
|
|
615
|
+
* is the PRIMARY price; these rows are the accepted-secondary prices. Money in MINOR units, so cents are
|
|
616
|
+
* representable (e.g. USD 24.99 = 2499). A currency with no row (and ≠ the primary) = not sold in it.
|
|
617
|
+
*/
|
|
618
|
+
interface VariantPrice {
|
|
619
|
+
currency: string;
|
|
620
|
+
priceMinor: number;
|
|
621
|
+
compareAtMinor: number | null;
|
|
622
|
+
}
|
|
623
|
+
/** Availability summary so the storefront booking calendar can grey out unavailable days (mirrors the
|
|
624
|
+
* legacy package-option summary). Derived from the option's AvailabilityConfig on the server. */
|
|
625
|
+
interface StoreAvailabilitySummary {
|
|
626
|
+
/** Weekdays the business is open (sun..sat); empty = open every day (no weekly config). */
|
|
627
|
+
weekdays: string[];
|
|
628
|
+
/** Explicit blocks that remove a day / weekday / range. */
|
|
629
|
+
blocks: Array<{
|
|
630
|
+
type: 'date';
|
|
631
|
+
date: string;
|
|
632
|
+
} | {
|
|
633
|
+
type: 'range';
|
|
634
|
+
start: string;
|
|
635
|
+
end: string;
|
|
636
|
+
} | {
|
|
637
|
+
type: 'weekday';
|
|
638
|
+
weekday: string;
|
|
639
|
+
}>;
|
|
640
|
+
/** Dates (YYYY-MM-DD) force-opened even if outside the weekly rule. */
|
|
641
|
+
overrideDates: string[];
|
|
642
|
+
/** How many days ahead bookings are accepted. */
|
|
643
|
+
windowDays: number;
|
|
644
|
+
/** Minimum lead time before the earliest bookable slot. */
|
|
645
|
+
leadTimeMinutes: number;
|
|
646
|
+
}
|
|
647
|
+
type PreorderDepositType = 'percent' | 'fixed';
|
|
648
|
+
interface PublicProductVariant {
|
|
649
|
+
ref: string;
|
|
650
|
+
name: string;
|
|
651
|
+
priceMinor: number;
|
|
652
|
+
compareAtMinor: number | null;
|
|
653
|
+
currency: string;
|
|
654
|
+
/** Accepted-secondary prices (multi-currency); excludes the primary `currency`. The storefront picks
|
|
655
|
+
* the price matching the buyer's selected currency and hides the variant when there's no match. */
|
|
656
|
+
prices?: VariantPrice[];
|
|
657
|
+
media: string[];
|
|
658
|
+
/** EFFECTIVE sold-out: the merchant declared it (variant or product master) OR the tracked stock ran
|
|
659
|
+
* out. Shown but not purchasable — and while sold out the chip STAYS selectable, because a saver still
|
|
660
|
+
* has to pick which variant their plan (and its locked price) is for. */
|
|
661
|
+
soldOut: boolean;
|
|
662
|
+
/**
|
|
663
|
+
* Units a buyer may still put in the cart. `null` = untracked stock, so no cap at all (the ∞ the
|
|
664
|
+
* portal shows). A tracked variant sends `stockQty - reservedQty`, and 0 whenever it reads sold out
|
|
665
|
+
* for any reason. The storefront caps its quantity stepper on this and only reveals the number once
|
|
666
|
+
* it drops to `LOW_STOCK_THRESHOLD` or below.
|
|
667
|
+
*/
|
|
668
|
+
availableQty?: number | null;
|
|
669
|
+
/** '#RRGGBB' for the colour swatch; null under 'color' mode falls back to a text chip. */
|
|
670
|
+
swatchHex?: string | null;
|
|
671
|
+
description?: string | null;
|
|
672
|
+
badge?: string | null;
|
|
673
|
+
features?: PackageFeature[];
|
|
674
|
+
fineprint?: string | null;
|
|
675
|
+
durationMinutes?: number | null;
|
|
676
|
+
pricingMode?: ProductPricingMode | null;
|
|
677
|
+
/** Effective scheduling mode for this option (its own override, else the product default). null = not scheduled. */
|
|
678
|
+
schedulingType?: 'slots' | 'single_date' | 'date_range' | null;
|
|
679
|
+
/** Effective availability summary so the calendar can block unavailable days. null = not scheduled. */
|
|
680
|
+
availability?: StoreAvailabilitySummary | null;
|
|
681
|
+
/** Local dates (YYYY-MM-DD) already fully booked for this option, so the calendar can block them. */
|
|
682
|
+
takenDates?: string[];
|
|
683
|
+
/** Extra checkout questions for THIS option (asked in addition to the product-level questions). */
|
|
684
|
+
formFields?: BookingFormField[];
|
|
685
|
+
}
|
|
686
|
+
interface PublicProduct {
|
|
687
|
+
ref: string;
|
|
688
|
+
name: string;
|
|
689
|
+
slug: string;
|
|
690
|
+
description: string | null;
|
|
691
|
+
coverUrl: string | null;
|
|
692
|
+
needsDelivery: boolean;
|
|
693
|
+
currency: string;
|
|
694
|
+
/** Who-pays override for the FrontDesk charge (FD-CHG). null = inherit the store default. */
|
|
695
|
+
feeMode?: ChargeMode | null;
|
|
696
|
+
/** Lowest available variant price (minor). */
|
|
697
|
+
priceMinorFrom: number | null;
|
|
698
|
+
/** Gallery: cover + variant media, de-duped. */
|
|
699
|
+
media: string[];
|
|
700
|
+
variants: PublicProductVariant[];
|
|
701
|
+
isPackage?: boolean;
|
|
702
|
+
selectionMode?: SelectionMode;
|
|
703
|
+
/** How the option picker is drawn: text chips (default), image swatches, or colour swatches. */
|
|
704
|
+
variantDisplay?: VariantDisplay;
|
|
705
|
+
schedulingEnabled?: boolean;
|
|
706
|
+
/** Add-on groups the buyer can pick from (option-scoped groups carry variantRef). */
|
|
707
|
+
addonGroups?: ProductAddonGroup[];
|
|
708
|
+
/** Product-level checkout questions (asked on every option; merged with the option's own extras). */
|
|
709
|
+
formFields?: BookingFormField[];
|
|
710
|
+
/** Buyers may start an installment plan toward this product (shown even when it's sold out). */
|
|
711
|
+
saveForItEnabled?: boolean;
|
|
712
|
+
/** Rolling window ceiling in DAYS (decision 11a); the finish-date picker is capped at start + this. */
|
|
713
|
+
saveForItMaxDays?: number | null;
|
|
714
|
+
/** Fixed cohort finish dates (ISO, decision 11b) the buyer can pick instead of a rolling date. */
|
|
715
|
+
saveForItFixedDates?: string[];
|
|
716
|
+
/** Whether the price is frozen for savers (default true). Off = live price at checkout, saved balance
|
|
717
|
+
* applied as a credit. Drives the disclosure the buyer sees at plan start. */
|
|
718
|
+
saveForItFreezePrice?: boolean;
|
|
719
|
+
/** Fuzzed social proof for the savings panel — "About 100 people". Already vague when it reaches the
|
|
720
|
+
* browser (the real count is never sent) and null whenever the crowd is too small, the store opted out,
|
|
721
|
+
* or Ops switched it off. Only present on the product DETAIL fetch, never on list payloads. */
|
|
722
|
+
saveForItCrowdLabel?: string | null;
|
|
723
|
+
/** Resolved server-side: a plan can actually be started right now. Enabled, and — when the product is
|
|
724
|
+
* sold out — backorders are on AND the unit cap still has room. The storefront gates the whole save
|
|
725
|
+
* path on this rather than on `saveForItEnabled`, so a closed backorder reads as a plain sold-out item
|
|
726
|
+
* instead of offering a plan that would be rejected at submit. */
|
|
727
|
+
saveForItSaveable?: boolean;
|
|
728
|
+
/** Resolved server-side: a buyer can pre-order this RIGHT NOW. Sold out, the switch on, and the unit
|
|
729
|
+
* cap still has room. The storefront gates its whole pre-order path on this, so a full pre-order book
|
|
730
|
+
* reads as a plain sold-out item instead of offering a checkout that would be rejected. */
|
|
731
|
+
preorderable?: boolean;
|
|
732
|
+
/** The merchant's free-text promise, shown wherever the buyer is committing ("Ships in 4 to 6 weeks").
|
|
733
|
+
* Absent when they did not write one. */
|
|
734
|
+
preorderEtaText?: string | null;
|
|
735
|
+
/** Units still open against the pre-order cap (`preorderLimit - unitsCommitted`). The storefront caps
|
|
736
|
+
* its quantity stepper on THIS while pre-ordering, since `availableQty` is 0 by definition — without
|
|
737
|
+
* it a buyer could build a basket of 50 that checkout would reject. Absent when not pre-orderable. */
|
|
738
|
+
preorderRemaining?: number | null;
|
|
739
|
+
/** PO-2 — the deposit RULE when this pre-order takes one; absent means pay in full. Sent as the rule
|
|
740
|
+
* rather than an amount because the deposit is struck on the order total (delivery included), which a
|
|
741
|
+
* product page cannot know: "Pay 25% now" is exact, a guessed figure would drift from the charge. */
|
|
742
|
+
preorderDepositType?: PreorderDepositType | null;
|
|
743
|
+
/** Whole percent under 'percent'; minor units of `currency` under 'fixed'. */
|
|
744
|
+
preorderDepositValue?: number | null;
|
|
745
|
+
/** Effective sold-out for the product as a whole (declared or every variant gone). */
|
|
746
|
+
soldOut?: boolean;
|
|
747
|
+
/** Full review aggregate for the detail page: header stars, distribution bars, and the cached AI
|
|
748
|
+
* summary. Absent when the store has reviews off or the cold-start floor is not met. */
|
|
749
|
+
reviews?: ProductReviewAggregate;
|
|
750
|
+
/** The store's reviews toggle, resolved server-side. Drives whether the product page renders the
|
|
751
|
+
* reviews block under the Similar strip at all. */
|
|
752
|
+
reviewsEnabled?: boolean;
|
|
753
|
+
/** PV-117: the store's restock-alert toggle, resolved server-side. The storefront offers "Email me
|
|
754
|
+
* when it is back" only on a DEAD END — sold out with `saveForItSaveable` false — so a page that can
|
|
755
|
+
* still start a savings plan keeps that as its one call to action. */
|
|
756
|
+
restockAlertsEnabled?: boolean;
|
|
757
|
+
/** Drives the brand byline, the always-visible description prose and the accordion sections on the
|
|
758
|
+
* product page, plus the schema.org Product block. Absent when the merchant has added nothing, so the
|
|
759
|
+
* storefront can skip the whole block on a bare product. HS code is stripped (merchant-only). */
|
|
760
|
+
info?: PublicProductInfo;
|
|
761
|
+
}
|
|
762
|
+
/** Bookable slots for one scheduled product on one date. */
|
|
763
|
+
interface PublicProductSlots {
|
|
764
|
+
date: string;
|
|
765
|
+
slots: Array<{
|
|
766
|
+
startUtc: string;
|
|
767
|
+
startLocal: string;
|
|
768
|
+
}>;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Live availability for the variants a cart already holds — a cart is localStorage, so its lines can be
|
|
772
|
+
* days old by the time the buyer comes back. Keyed by variantRef: units left, or null when stock is
|
|
773
|
+
* untracked (no cap). A variantRef that is missing from the map no longer exists or is no longer
|
|
774
|
+
* purchasable, and the cart drops the line.
|
|
775
|
+
*/
|
|
776
|
+
interface PublicStoreAvailability {
|
|
777
|
+
variants: Record<string, number | null>;
|
|
778
|
+
/**
|
|
779
|
+
* Variant refs whose product is PRE-ORDERABLE right now (PO-1). Their `variants` entry is 0 — a
|
|
780
|
+
* pre-orderable product is sold out by definition — but the line is still perfectly purchasable, so
|
|
781
|
+
* the cart must neither drop it nor clamp it to zero. Instead it caps on `preorderRemaining` below.
|
|
782
|
+
*/
|
|
783
|
+
preorderable?: string[];
|
|
784
|
+
/** Units still open against each pre-orderable product's cap, keyed by VARIANT ref for the cart's
|
|
785
|
+
* convenience (the cap itself is product-level, so sibling variants share a figure). */
|
|
786
|
+
preorderRemaining?: Record<string, number>;
|
|
787
|
+
}
|
|
788
|
+
interface PublicProductSummary {
|
|
789
|
+
ref: string;
|
|
790
|
+
name: string;
|
|
791
|
+
slug: string;
|
|
792
|
+
/** Distinguishes bookable services/packages from standard retail products. */
|
|
793
|
+
isPackage?: boolean;
|
|
794
|
+
coverUrl: string | null;
|
|
795
|
+
currency: string;
|
|
796
|
+
priceMinorFrom: number | null;
|
|
797
|
+
/** Cheapest available price per accepted-secondary currency (minor units), for the storefront card when
|
|
798
|
+
* the buyer switches currency. Keyed by ISO code; excludes the primary `currency`. A currency absent
|
|
799
|
+
* here (and ≠ primary) means the product isn't sold in it → the card hides it. */
|
|
800
|
+
pricesFrom?: Record<string, number>;
|
|
801
|
+
/** Compare-at of the cheapest available variant (primary currency) — drives the storefront card's
|
|
802
|
+
* strikethrough + "% OFF" badge; null when not on sale. */
|
|
803
|
+
compareAtMinorFrom: number | null;
|
|
804
|
+
/** Compare-at "from" per accepted-secondary currency (minor units), aligned with `pricesFrom` so the
|
|
805
|
+
* badge/strikethrough stay correct after a currency switch. Keyed by ISO code; excludes the primary. */
|
|
806
|
+
compareAtFrom?: Record<string, number>;
|
|
807
|
+
/** Resolved: show the "% OFF" discount badge for this product (product override ?? store default). The
|
|
808
|
+
* storefront still only renders it when the product is actually on sale. */
|
|
809
|
+
showSaleBadge?: boolean;
|
|
810
|
+
/** Number of variants — when > 1 the storefront card shows the price as "From ₦X". */
|
|
811
|
+
variantCount: number;
|
|
812
|
+
soldOut: boolean;
|
|
813
|
+
/** A plan can be started from this card's product right now (see PublicProduct.saveForItSaveable). Drives
|
|
814
|
+
* the green "Save for it" pill, which REPLACES the "Sold out" pill on a sold-out card — the card keeps
|
|
815
|
+
* its full colour and the sold-out fact moves to the price line, because the item is still gettable. */
|
|
816
|
+
saveForItSaveable?: boolean;
|
|
817
|
+
/** A buyer can pre-order this card's product right now (see PublicProduct.preorderable). Drives the
|
|
818
|
+
* PRE-ORDER pill, which like the save pill REPLACES the grey sold-out one — the item is still gettable. */
|
|
819
|
+
preorderable?: boolean;
|
|
820
|
+
/** Review average (1 decimal) for the card's star line. Absent when the store's cold-start floor is not
|
|
821
|
+
* met — the card then shows no stars at all, rather than a 5.0 built from one review. */
|
|
822
|
+
ratingAvg?: number;
|
|
823
|
+
/** Published review count behind `ratingAvg`. Absent under the floor, for the same reason. */
|
|
824
|
+
ratingCount?: number;
|
|
825
|
+
}
|
|
826
|
+
type CollectionCoverKind = 'image' | 'video';
|
|
827
|
+
/** Buyer-facing collection in the storefront payload / public endpoints. */
|
|
828
|
+
interface PublicCollectionSummary {
|
|
829
|
+
ref: string;
|
|
830
|
+
name: string;
|
|
831
|
+
slug: string;
|
|
832
|
+
description: string | null;
|
|
833
|
+
coverUrl: string | null;
|
|
834
|
+
coverKind: CollectionCoverKind | null;
|
|
835
|
+
gallery: string[];
|
|
836
|
+
/** Ordered refs into the storefront's public product summaries — membership already resolved
|
|
837
|
+
* (manual order or sort preset applied) and filtered to publicly visible products. */
|
|
838
|
+
productRefs: string[];
|
|
839
|
+
itemCount: number;
|
|
840
|
+
seoTitle: string | null;
|
|
841
|
+
seoDescription: string | null;
|
|
842
|
+
}
|
|
843
|
+
/** A delivery zone's fee in ONE currency. Public payloads carry one per accepted currency (`prices`):
|
|
844
|
+
* the authored-currency row is the exact fee, the rest are FX-converted (rounded up clean). */
|
|
845
|
+
interface ZoneFee {
|
|
846
|
+
currency: string;
|
|
847
|
+
feeMinor: number;
|
|
848
|
+
}
|
|
849
|
+
interface DeliveryZone {
|
|
850
|
+
ref: string;
|
|
851
|
+
name: string;
|
|
852
|
+
/** The fee in the zone's authored `currency`. */
|
|
853
|
+
feeMinor: number;
|
|
854
|
+
/** The currency `feeMinor` is authored in (the store primary when the zone was created). */
|
|
855
|
+
currency: string;
|
|
856
|
+
sort: number;
|
|
857
|
+
/** Public (storefront) payload only: the fee FX-converted into every accepted currency, so the
|
|
858
|
+
* checkout can switch currency instantly without a refetch. The authored-currency entry is exact;
|
|
859
|
+
* the rest are `convertMinor` + `roundUpCleanMinor`. Omitted on portal/admin (single-currency)
|
|
860
|
+
* payloads and on single-currency stores. */
|
|
861
|
+
prices?: ZoneFee[];
|
|
862
|
+
}
|
|
863
|
+
interface StoreCheckoutItem {
|
|
864
|
+
variantRef: string;
|
|
865
|
+
quantity: number;
|
|
866
|
+
}
|
|
867
|
+
interface StoreCheckoutContact {
|
|
868
|
+
name: string;
|
|
869
|
+
email: string;
|
|
870
|
+
phone?: string | null;
|
|
871
|
+
}
|
|
872
|
+
interface StoreShippingAddress {
|
|
873
|
+
addressLine1: string;
|
|
874
|
+
addressLine2?: string | null;
|
|
875
|
+
city: string;
|
|
876
|
+
state?: string | null;
|
|
877
|
+
country?: string | null;
|
|
878
|
+
/** Buyer's pinned delivery coordinates (optional; from the checkout map). */
|
|
879
|
+
lat?: number | null;
|
|
880
|
+
lng?: number | null;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Packages — sellable offerings + priced tiers (options). Spec §13.1, §10.18 (PV-012/054).
|
|
885
|
+
*
|
|
886
|
+
* A Package is the top-level offering (type product/service/consultation/rental). It owns 1..N
|
|
887
|
+
* Options (tiers like Basic/Standard/Premium). A package with one option = a flat offering
|
|
888
|
+
* (D-11). Each option carries its own pricing, an optional attached Booking Form (scheduling +
|
|
889
|
+
* intake, §12), and an occupancy mode (exclusive vs shared) used by the unified calendar later.
|
|
890
|
+
*/
|
|
891
|
+
|
|
892
|
+
type PackagePricingMode = 'full_payment' | 'deposit' | 'free' | 'request';
|
|
893
|
+
type AddonSelectionType = 'single' | 'multi';
|
|
894
|
+
/** inline = a typed-in extra; linked = points at another package (price-only in v1). */
|
|
895
|
+
type AddonOptionKind = 'inline' | 'linked';
|
|
896
|
+
/** A "what's included" line. `included` distinguishes a green tick from a muted cross (not included). */
|
|
897
|
+
interface PackageFeature {
|
|
898
|
+
text: string;
|
|
899
|
+
included: boolean;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Hosted checkout (Storefront API Surface B, §8). D2: hosted now, headless later.
|
|
904
|
+
*
|
|
905
|
+
* The split that makes this safe: the merchant's SERVER declares WHAT is being bought, and the BUYER
|
|
906
|
+
* chooses HOW to pay, on a FrontDesk page. That is why there is no `provider` here — payment method,
|
|
907
|
+
* card entry, Paystack inline, PVB transfer, Save For It credit and every rail behind them stay on
|
|
908
|
+
* our origin, so a merchant-built frontend never touches money and we add no new money code.
|
|
909
|
+
*
|
|
910
|
+
* The flow:
|
|
911
|
+
* 1. their server POSTs /v1/store/checkouts with an Idempotency-Key -> { ref, hostedUrl, expiresAt }
|
|
912
|
+
* 2. they redirect the buyer to hostedUrl (our origin, their brand if a checkout host is connected)
|
|
913
|
+
* 3. the buyer pays with the existing storefront checkout
|
|
914
|
+
* 4. we redirect to their returnUrl?checkout=<ref>
|
|
915
|
+
* 5. their server confirms via GET /v1/store/checkouts/:ref, or the checkout.completed webhook
|
|
916
|
+
*
|
|
917
|
+
* Step 5 matters: the return redirect is a browser navigation and can be lost, faked or replayed, so
|
|
918
|
+
* it is a UX signal only. NEVER fulfil on the redirect — confirm server-side.
|
|
919
|
+
*/
|
|
920
|
+
type StoreCheckoutSessionStatus = 'open' | 'completed' | 'expired' | 'cancelled';
|
|
921
|
+
/**
|
|
922
|
+
* 'test' sessions are opened by an `fd_sk_test_…` key and can be completed WITHOUT paying, so an
|
|
923
|
+
* integration is buildable without a bank account.
|
|
924
|
+
*
|
|
925
|
+
* 🔴 A test checkout deliberately produces NO order, NO wallet credit and NO ledger posting. It
|
|
926
|
+
* proves the INTEGRATION LOOP (open → hosted page → return → confirm → webhook), not fulfilment.
|
|
927
|
+
* That boundary is the point: the alternative is a second, fake money path beside the real one, and
|
|
928
|
+
* only provider-verified inflows may ever credit a wallet.
|
|
929
|
+
*/
|
|
930
|
+
type StoreCheckoutMode = 'live' | 'test';
|
|
931
|
+
/** What a merchant's server sends to open a checkout. */
|
|
932
|
+
interface StoreCheckoutSessionInput {
|
|
933
|
+
items: StoreCheckoutItem[];
|
|
934
|
+
/** Prefills the hosted page. The buyer can still correct it before paying. */
|
|
935
|
+
contact?: Partial<StoreCheckoutContact> | null;
|
|
936
|
+
deliveryZoneRef?: string | null;
|
|
937
|
+
shipping?: StoreShippingAddress | null;
|
|
938
|
+
/** Must be a currency the store accepts. Omit for the store primary. */
|
|
939
|
+
currency?: string | null;
|
|
940
|
+
/** Where we send the buyer after payment. Must be https (or localhost while testing). */
|
|
941
|
+
returnUrl: string;
|
|
942
|
+
}
|
|
943
|
+
/** One attendee on a ticket line. `responses` answers that tier's `formFields`. */
|
|
944
|
+
interface StoreTicketAttendee {
|
|
945
|
+
name?: string | null;
|
|
946
|
+
email?: string | null;
|
|
947
|
+
phone?: string | null;
|
|
948
|
+
/** YYYY-MM-DD. Required when the event or tier sets an age gate and dobMode is per attendee. */
|
|
949
|
+
dateOfBirth?: string | null;
|
|
950
|
+
responses?: Record<string, unknown> | null;
|
|
951
|
+
}
|
|
952
|
+
interface StoreTicketItem {
|
|
953
|
+
ticketTypeRef: string;
|
|
954
|
+
quantity: number;
|
|
955
|
+
/** Donation tiers only. Per ticket, in minor units, and must clear the tier floor. */
|
|
956
|
+
amountMinor?: number | null;
|
|
957
|
+
/** Length = quantity x groupSize. Omit to keep every seat under the buyer. */
|
|
958
|
+
attendees?: StoreTicketAttendee[] | null;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Opening a ticket checkout (D18).
|
|
962
|
+
*
|
|
963
|
+
* D19: the merchant COLLECTS attendee data, the server still VALIDATES all of it. What to collect is
|
|
964
|
+
* discoverable per tier on GET /v1/store/events/:slug: `formFields`, `requiresAttendeeDetails`,
|
|
965
|
+
* `groupSize`, `minPerOrder`/`maxPerOrder`, plus `minAge`/`dobMode` on the event.
|
|
966
|
+
*/
|
|
967
|
+
interface StoreEventCheckoutInput {
|
|
968
|
+
tickets: StoreTicketItem[];
|
|
969
|
+
/** Products already attached to this event. Not the general catalogue. */
|
|
970
|
+
products?: StoreCheckoutItem[] | null;
|
|
971
|
+
contact: StoreCheckoutContact;
|
|
972
|
+
/** Buyer DOB, when the event uses order-level age checks. YYYY-MM-DD. */
|
|
973
|
+
dateOfBirth?: string | null;
|
|
974
|
+
/** Order-level answers to the event's own questions. */
|
|
975
|
+
responses?: Record<string, unknown> | null;
|
|
976
|
+
discountCode?: string | null;
|
|
977
|
+
/** Reveals hidden and invite-only tiers. Re-verified server-side. */
|
|
978
|
+
unlockCodes?: string[] | null;
|
|
979
|
+
/** Affiliate attribution from a ?p=CODE share link. Ignored silently if unknown. */
|
|
980
|
+
promoterCode?: string | null;
|
|
981
|
+
currency?: string | null;
|
|
982
|
+
returnUrl: string;
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Headless ticket checkout (§16). The organizer owns the entire UI, including the payment picker.
|
|
986
|
+
*
|
|
987
|
+
* The rail is named HERE rather than every rail being minted up front: three live payment paths on
|
|
988
|
+
* one order means a buyer can pay by card AND transfer, and refunds are merchant-manual. Fetch the
|
|
989
|
+
* rails with GET /v1/store/payment-methods, render your own picker, then call this with the choice.
|
|
990
|
+
*/
|
|
991
|
+
interface StoreHeadlessCheckoutInput extends Omit<StoreEventCheckoutInput, 'returnUrl'> {
|
|
992
|
+
provider: StorePaymentMethod;
|
|
993
|
+
/** Where a redirecting rail (Stripe) returns the buyer. Ignored by rails that stay in place. */
|
|
994
|
+
returnUrl?: string | null;
|
|
995
|
+
}
|
|
996
|
+
interface StoreHeadlessCheckoutView {
|
|
997
|
+
ref: string;
|
|
998
|
+
orderRef: string;
|
|
999
|
+
status: StoreCheckoutSessionStatus;
|
|
1000
|
+
mode: StoreCheckoutMode;
|
|
1001
|
+
amount: {
|
|
1002
|
+
subtotalMinor: number;
|
|
1003
|
+
feeMinor: number;
|
|
1004
|
+
totalMinor: number;
|
|
1005
|
+
currency: string;
|
|
1006
|
+
};
|
|
1007
|
+
provider: StorePaymentMethod;
|
|
1008
|
+
payment: {
|
|
1009
|
+
/** Paystack: open the inline popup with this. The card fields are Paystack's, never yours. */
|
|
1010
|
+
accessCode?: string | null;
|
|
1011
|
+
/** Stripe: send the buyer here. */
|
|
1012
|
+
checkoutUrl?: string | null;
|
|
1013
|
+
/** Bank transfer: attached once the provider confirms it, so poll if it is null. */
|
|
1014
|
+
account?: {
|
|
1015
|
+
accountNumber: string;
|
|
1016
|
+
accountName: string | null;
|
|
1017
|
+
bankName: string | null;
|
|
1018
|
+
} | null;
|
|
1019
|
+
reference: string | null;
|
|
1020
|
+
};
|
|
1021
|
+
expiresAt: string;
|
|
1022
|
+
}
|
|
1023
|
+
/** What they get back, and what GET /v1/store/checkouts/:ref returns thereafter. */
|
|
1024
|
+
interface StoreCheckoutSessionView {
|
|
1025
|
+
ref: string;
|
|
1026
|
+
status: StoreCheckoutSessionStatus;
|
|
1027
|
+
/** Set from the KEY that opened it, never chosen by the caller. */
|
|
1028
|
+
mode: StoreCheckoutMode;
|
|
1029
|
+
/** Send the buyer here. Valid until `expiresAt`. */
|
|
1030
|
+
hostedUrl: string;
|
|
1031
|
+
returnUrl: string;
|
|
1032
|
+
currency: string | null;
|
|
1033
|
+
/**
|
|
1034
|
+
* The order this became, once the buyer paid. Null while open.
|
|
1035
|
+
*
|
|
1036
|
+
* On a TEST session this is a synthetic `test_ord_…` ref that resolves to no real order, because a
|
|
1037
|
+
* test checkout never creates one. Do not try to fetch it.
|
|
1038
|
+
*/
|
|
1039
|
+
orderRef: string | null;
|
|
1040
|
+
expiresAt: string;
|
|
1041
|
+
completedAt: string | null;
|
|
1042
|
+
createdAt: string;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** A resolved commission rate (after applying any per-workspace override over the platform default). */
|
|
1046
|
+
interface EventCommissionConfig {
|
|
1047
|
+
/** Percentage cut on the gross (0..100). */
|
|
1048
|
+
pct: number;
|
|
1049
|
+
/** Flat fee in minor units added on top of the percentage cut. */
|
|
1050
|
+
flatMinor: number;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** One slot type at launch; secondary slots (category sponsor, media partner) are a data change later. */
|
|
1054
|
+
type SponsorshipSlotType = 'official_ticket_sponsor';
|
|
1055
|
+
/** The public branding payload injected into event pages / checkout / ticket surfaces. */
|
|
1056
|
+
interface PublicEventSponsorship {
|
|
1057
|
+
slotType: SponsorshipSlotType;
|
|
1058
|
+
brandName: string;
|
|
1059
|
+
logoUrl: string | null;
|
|
1060
|
+
tagline: string | null;
|
|
1061
|
+
clickUrl: string | null;
|
|
1062
|
+
sponsorshipRef: string;
|
|
1063
|
+
/** Already contrast-clamped server-side via `sponsorQrColor()` — safe to render as-is. */
|
|
1064
|
+
qrColor?: string | null;
|
|
1065
|
+
/** The banner creative. When present it IS the placement; the text lockup is the fallback. */
|
|
1066
|
+
bannerUrl?: string | null;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* FrontDesk public storefront contract (Storefront API §3.2). These shapes are what a
|
|
1071
|
+
* merchant-built frontend receives, so they are additive-only: never remove a field, never retype
|
|
1072
|
+
* one, never narrow an enum. Guarded by `pnpm --filter pro-api store-contract:check`.
|
|
1073
|
+
*/
|
|
1074
|
+
|
|
1075
|
+
interface PublicPackageSummary {
|
|
1076
|
+
ref: string;
|
|
1077
|
+
type: string;
|
|
1078
|
+
name: string;
|
|
1079
|
+
slug: string;
|
|
1080
|
+
coverUrl: string | null;
|
|
1081
|
+
priceMinorFrom: number | null;
|
|
1082
|
+
currency: string | null;
|
|
1083
|
+
optionCount: number;
|
|
1084
|
+
}
|
|
1085
|
+
interface PublicPackageOption {
|
|
1086
|
+
ref: string;
|
|
1087
|
+
name: string;
|
|
1088
|
+
pricingMode: 'full_payment' | 'deposit' | 'free' | 'request';
|
|
1089
|
+
priceMinor: number | null;
|
|
1090
|
+
compareAtMinor: number | null;
|
|
1091
|
+
currency: string;
|
|
1092
|
+
badge: string | null;
|
|
1093
|
+
/** Option images; the first is used as the card image when the section uses the "Media" card style. */
|
|
1094
|
+
media?: string[];
|
|
1095
|
+
features: Array<{
|
|
1096
|
+
text: string;
|
|
1097
|
+
included: boolean;
|
|
1098
|
+
} | string>;
|
|
1099
|
+
/** Optional fine print (terms / small print) shown under the package. */
|
|
1100
|
+
fineprint: string | null;
|
|
1101
|
+
occupancyMode: 'exclusive' | 'shared';
|
|
1102
|
+
schedulingType: 'slots' | 'single_date' | 'date_range' | null;
|
|
1103
|
+
/** Availability summary so the checkout calendar can grey out unavailable days. */
|
|
1104
|
+
availability?: {
|
|
1105
|
+
weekdays: string[];
|
|
1106
|
+
blocks: Array<{
|
|
1107
|
+
type: 'date';
|
|
1108
|
+
date: string;
|
|
1109
|
+
} | {
|
|
1110
|
+
type: 'range';
|
|
1111
|
+
start: string;
|
|
1112
|
+
end: string;
|
|
1113
|
+
} | {
|
|
1114
|
+
type: 'weekday';
|
|
1115
|
+
weekday: string;
|
|
1116
|
+
}>;
|
|
1117
|
+
overrideDates: string[];
|
|
1118
|
+
windowDays: number;
|
|
1119
|
+
leadTimeMinutes: number;
|
|
1120
|
+
} | null;
|
|
1121
|
+
/** Effective add-ons for this option (its own override if any, else the package default). */
|
|
1122
|
+
addonGroups?: PublicAddonGroup[];
|
|
1123
|
+
/** Custom checkout questions for this option (Phase B). The renderer evaluates showIf client-side. */
|
|
1124
|
+
formFields?: BookingFormField[];
|
|
1125
|
+
/** Local dates (YYYY-MM-DD) already fully booked for this option, so the calendar can block them. */
|
|
1126
|
+
takenDates?: string[];
|
|
1127
|
+
}
|
|
1128
|
+
/** A selectable add-on option (linked options arrive with their name/price already resolved). */
|
|
1129
|
+
interface PublicAddonOption {
|
|
1130
|
+
ref: string;
|
|
1131
|
+
kind: 'inline' | 'linked';
|
|
1132
|
+
label: string;
|
|
1133
|
+
description: string | null;
|
|
1134
|
+
priceMinor: number;
|
|
1135
|
+
imageUrl: string | null;
|
|
1136
|
+
allowQuantity: boolean;
|
|
1137
|
+
maxQty: number | null;
|
|
1138
|
+
linkedPackageRef: string | null;
|
|
1139
|
+
linkedOptionRef: string | null;
|
|
1140
|
+
}
|
|
1141
|
+
/** A group of add-on options (single = pick one, multi = pick any within min/max). */
|
|
1142
|
+
interface PublicAddonGroup {
|
|
1143
|
+
ref: string;
|
|
1144
|
+
name: string;
|
|
1145
|
+
selectionType: 'single' | 'multi';
|
|
1146
|
+
required: boolean;
|
|
1147
|
+
minSelect: number | null;
|
|
1148
|
+
maxSelect: number | null;
|
|
1149
|
+
sort: number;
|
|
1150
|
+
options: PublicAddonOption[];
|
|
1151
|
+
}
|
|
1152
|
+
interface PublicPackage {
|
|
1153
|
+
ref: string;
|
|
1154
|
+
type: string;
|
|
1155
|
+
name: string;
|
|
1156
|
+
slug: string;
|
|
1157
|
+
description: string;
|
|
1158
|
+
coverUrl: string | null;
|
|
1159
|
+
currency: string;
|
|
1160
|
+
options: PublicPackageOption[];
|
|
1161
|
+
/** Service-level add-on groups shown in checkout (empty when none). May be absent on older payloads. */
|
|
1162
|
+
addonGroups?: PublicAddonGroup[];
|
|
1163
|
+
brand: {
|
|
1164
|
+
ref: string;
|
|
1165
|
+
displayName: string;
|
|
1166
|
+
avatarUrl: string | null;
|
|
1167
|
+
} | null;
|
|
1168
|
+
storefrontPublished?: boolean;
|
|
1169
|
+
}
|
|
1170
|
+
interface PublicCollectionDetail {
|
|
1171
|
+
collection: PublicCollectionSummary;
|
|
1172
|
+
products: PublicProductSummary[];
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Save For It — a buyer saves toward a purchase over time; the money sits in walletless escrow neither
|
|
1177
|
+
* side can spend; the merchant is paid on completion (design: services/design-docs/saveforit.md).
|
|
1178
|
+
* First slice: invoices, manual top-up (no auto-debit, no card-on-file).
|
|
1179
|
+
*/
|
|
1180
|
+
|
|
1181
|
+
/** How a seat is held against capacity while a buyer saves toward a ticket (decision D-14, per event): none
|
|
1182
|
+
* (save freely, refund/credit if it sells out) · from_start (reserve immediately) · threshold (reserve once
|
|
1183
|
+
* saved ≥ the threshold %). */
|
|
1184
|
+
type SaveForItHoldPolicy = 'none' | 'from_start' | 'threshold';
|
|
1185
|
+
/** Which price a ticket saver pays: freeze the price at plan start, or the tier live at completion. */
|
|
1186
|
+
type SaveForItPriceMode = 'freeze' | 'current';
|
|
1187
|
+
/** The resolved (event default ← per-ticket-type override) Save For It config for one ticket type. */
|
|
1188
|
+
interface EventTicketSaveForItConfig {
|
|
1189
|
+
enabled: boolean;
|
|
1190
|
+
holdPolicy: SaveForItHoldPolicy;
|
|
1191
|
+
holdThresholdPct: number;
|
|
1192
|
+
priceMode: SaveForItPriceMode;
|
|
1193
|
+
cutoffHours: number;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/** The event's primary intent — tailors the builder, the public flow, and the CTA (doc 48 §1). */
|
|
1197
|
+
type EventMode = 'rsvp' | 'ticketed' | 'registration' | 'mixed';
|
|
1198
|
+
type EventFormat = 'physical' | 'online' | 'hybrid';
|
|
1199
|
+
/** Who supplies the date of birth when an event has an age restriction. */
|
|
1200
|
+
type EventDobMode = 'order' | 'attendee';
|
|
1201
|
+
interface EventMediaItem {
|
|
1202
|
+
type: 'image' | 'video';
|
|
1203
|
+
url: string;
|
|
1204
|
+
thumbnail?: string;
|
|
1205
|
+
}
|
|
1206
|
+
/** Where the event happens. Physical fields + an online URL; a hybrid event uses both. */
|
|
1207
|
+
interface EventLocation {
|
|
1208
|
+
venueName: string | null;
|
|
1209
|
+
address: string | null;
|
|
1210
|
+
city: string | null;
|
|
1211
|
+
state: string | null;
|
|
1212
|
+
country: string | null;
|
|
1213
|
+
lat: number | null;
|
|
1214
|
+
lng: number | null;
|
|
1215
|
+
onlineUrl: string | null;
|
|
1216
|
+
onlineProvider: string | null;
|
|
1217
|
+
}
|
|
1218
|
+
/** Who may see the exact venue (venue name / address / map pin / online link). `public` = everyone
|
|
1219
|
+
* (default, today's behaviour); `confirmed_only` = stripped from the public page + pending/waitlist
|
|
1220
|
+
* comms, revealed only to attendees whose spot is confirmed (organizer feedback 2026-07-03). The
|
|
1221
|
+
* coarse area (city/state/country) stays public either way so buyers know where it roughly is. */
|
|
1222
|
+
type EventVenueVisibility = 'public' | 'confirmed_only';
|
|
1223
|
+
/** Public day (storefront schedule). */
|
|
1224
|
+
interface PublicEventDay {
|
|
1225
|
+
ref: string;
|
|
1226
|
+
label: string;
|
|
1227
|
+
dayDate: string | null;
|
|
1228
|
+
startAt: string | null;
|
|
1229
|
+
endAt: string | null;
|
|
1230
|
+
/** Per-day timezone override (IANA). Null = the event's timezone. */
|
|
1231
|
+
timezone: string | null;
|
|
1232
|
+
/** Per-day location override (null = the event's location). Exact-venue fields are stripped
|
|
1233
|
+
* server-side for unconfirmed viewers when the event is `confirmed_only`, same as the event's. */
|
|
1234
|
+
location: EventLocation | null;
|
|
1235
|
+
/** Day-level capacity (null = unlimited). */
|
|
1236
|
+
capacity: number | null;
|
|
1237
|
+
/** Admissions still available on this day (null = unlimited). */
|
|
1238
|
+
remaining: number | null;
|
|
1239
|
+
/** True when the day's overall capacity is full (independent of per-ticket stock). */
|
|
1240
|
+
soldOut: boolean;
|
|
1241
|
+
}
|
|
1242
|
+
type EventTicketKind = 'free' | 'paid' | 'donation' | 'deposit';
|
|
1243
|
+
/** Where a deposit ticket's remaining balance is settled. `door` = the organizer collects it in
|
|
1244
|
+
* person (no money moves through FrontDesk); `online` = the buyer clears it on the order page
|
|
1245
|
+
* before the cutoff, or the seat is released and the deposit forfeited. */
|
|
1246
|
+
type EventDepositBalanceMode = 'door' | 'online';
|
|
1247
|
+
/** Who pays FrontDesk's ticket charge for this ticket type. */
|
|
1248
|
+
type EventTicketFeeMode = 'absorb' | 'pass_on';
|
|
1249
|
+
/** Lightweight event card for the storefront section + listing. */
|
|
1250
|
+
interface PublicEventSummary {
|
|
1251
|
+
ref: string;
|
|
1252
|
+
title: string;
|
|
1253
|
+
slug: string;
|
|
1254
|
+
tagline: string | null;
|
|
1255
|
+
coverUrl: string | null;
|
|
1256
|
+
format: EventFormat;
|
|
1257
|
+
mode: EventMode;
|
|
1258
|
+
startAt: string | null;
|
|
1259
|
+
endAt: string | null;
|
|
1260
|
+
/** When the event was first published — used by the storefront "recent" (newest added) sort. */
|
|
1261
|
+
publishedAt: string | null;
|
|
1262
|
+
/** Venue name + city compose the storefront card location line ("Venue, City"). */
|
|
1263
|
+
venueName: string | null;
|
|
1264
|
+
city: string | null;
|
|
1265
|
+
category: string | null;
|
|
1266
|
+
}
|
|
1267
|
+
/** A sellable/registerable ticket type as seen publicly (public-visibility free + paid; PV-073). */
|
|
1268
|
+
interface PublicEventTicketType {
|
|
1269
|
+
ref: string;
|
|
1270
|
+
name: string;
|
|
1271
|
+
description: string | null;
|
|
1272
|
+
/** Optional cover image (hosted URL); null when none. */
|
|
1273
|
+
imageUrl: string | null;
|
|
1274
|
+
kind: EventTicketKind;
|
|
1275
|
+
/** pass_on (default) = this ticket's amount is included in the event-level customer checkout charge;
|
|
1276
|
+
* absorb = exempt (organizer bears the FrontDesk charge on it). */
|
|
1277
|
+
frontdeskFeeMode: EventTicketFeeMode;
|
|
1278
|
+
/** Per-ticket price in minor units (0 for free) — the EFFECTIVE price now (early-bird applied). */
|
|
1279
|
+
priceMinor: number;
|
|
1280
|
+
currency: string;
|
|
1281
|
+
/** Accepted-secondary prices (multi-currency, flat — no windows). The buyer's switcher resolves the
|
|
1282
|
+
* shown price via `resolveVariantPrice`; a ticket with no row in the selected currency is hidden. */
|
|
1283
|
+
prices?: VariantPrice[];
|
|
1284
|
+
/** Optional struck-through compare-at price (minor units). */
|
|
1285
|
+
compareAtMinor: number | null;
|
|
1286
|
+
/** DONATION — the floor the buyer's typed amount must clear. Null = any amount above zero. The
|
|
1287
|
+
* amount is per ticket: the quantity stepper multiplies it. */
|
|
1288
|
+
minPriceMinor: number | null;
|
|
1289
|
+
/** DONATION — suggested amounts (minor units) rendered as chips beside the "Other amount" box. */
|
|
1290
|
+
donationPresets: number[];
|
|
1291
|
+
/** DEPOSIT — the rule splitting `priceMinor` into a part paid now and a balance owed later. */
|
|
1292
|
+
depositType: 'percent' | 'fixed' | null;
|
|
1293
|
+
depositValue: number | null;
|
|
1294
|
+
/** DEPOSIT — what one ticket charges today, resolved from the rule against the live price. The
|
|
1295
|
+
* storefront shows this as the CTA amount; the balance is `priceMinor - depositDueNowMinor`. */
|
|
1296
|
+
depositDueNowMinor: number | null;
|
|
1297
|
+
/** DEPOSIT — where the balance is settled, and (online mode) the resolved cutoff instant, derived
|
|
1298
|
+
* from the event start minus the ticket's days-before setting. Null cutoff = door mode. */
|
|
1299
|
+
depositBalanceMode: EventDepositBalanceMode | null;
|
|
1300
|
+
depositBalanceDueAt: string | null;
|
|
1301
|
+
/** When the current early-bird/timed price ends (ISO), for a "price goes up on …" hint; null = no schedule. */
|
|
1302
|
+
priceEndsAt: string | null;
|
|
1303
|
+
/** Tickets left at the CURRENT early-bird price (its quantity cap minus sold); null = uncapped / no window. */
|
|
1304
|
+
priceRemaining: number | null;
|
|
1305
|
+
capacity: number | null;
|
|
1306
|
+
soldCount: number;
|
|
1307
|
+
remaining: number | null;
|
|
1308
|
+
soldOut: boolean;
|
|
1309
|
+
/** Resolved low-stock threshold (per-ticket override → event default → 10). Show "Only N left" when
|
|
1310
|
+
* remaining ≤ this and > 0; 0 = never show. */
|
|
1311
|
+
lowStockThreshold: number;
|
|
1312
|
+
/** Quantity bounds per order (the box-office stepper clamps to these + `remaining`). */
|
|
1313
|
+
minPerOrder: number;
|
|
1314
|
+
maxPerOrder: number | null;
|
|
1315
|
+
/** Effective minimum age for this ticket (max of event + type; 0 = no restriction). */
|
|
1316
|
+
minAge: number;
|
|
1317
|
+
approvalRequired: boolean;
|
|
1318
|
+
/** Admissions issued per unit (1 = normal; >1 = group/table ticket that explodes into N seats). */
|
|
1319
|
+
groupSize: number;
|
|
1320
|
+
/** Day labels this ticket admits (empty = all days = "Full pass"). Multi-day. */
|
|
1321
|
+
validDays: string[];
|
|
1322
|
+
/** Whether each seat must be named at checkout (vs. allowing "keep all under the buyer"). */
|
|
1323
|
+
requiresAttendeeDetails: boolean;
|
|
1324
|
+
/** Per-ticket custom questions (visible ones; showIf evaluated client-side). */
|
|
1325
|
+
formFields: BookingFormField[];
|
|
1326
|
+
/** PV-106 Save For It — the RESOLVED (event default ← type override) config for this ticket. */
|
|
1327
|
+
saveForIt: EventTicketSaveForItConfig;
|
|
1328
|
+
/** Whether the storefront should offer "Save for it" on this ticket: enabled + priced at/above the
|
|
1329
|
+
* platform minimum. The saver commits to the frozen (or live-at-completion) price of `saveForIt`. */
|
|
1330
|
+
saveForItSaveable: boolean;
|
|
1331
|
+
}
|
|
1332
|
+
interface PublicEvent {
|
|
1333
|
+
ref: string;
|
|
1334
|
+
title: string;
|
|
1335
|
+
slug: string;
|
|
1336
|
+
tagline: string | null;
|
|
1337
|
+
description: string | null;
|
|
1338
|
+
mode: EventMode;
|
|
1339
|
+
format: EventFormat;
|
|
1340
|
+
/** When `venueVisibility` is `confirmed_only`, the exact-venue fields (venueName/address/lat/lng/
|
|
1341
|
+
* onlineUrl) arrive null here — only the coarse area remains. */
|
|
1342
|
+
location: EventLocation;
|
|
1343
|
+
/** `confirmed_only` = the page should say the exact venue is shared once the spot is confirmed. */
|
|
1344
|
+
venueVisibility: EventVenueVisibility;
|
|
1345
|
+
timezone: string | null;
|
|
1346
|
+
startAt: string | null;
|
|
1347
|
+
endAt: string | null;
|
|
1348
|
+
doorsAt: string | null;
|
|
1349
|
+
capacity: number | null;
|
|
1350
|
+
coverUrl: string | null;
|
|
1351
|
+
media: EventMediaItem[];
|
|
1352
|
+
category: string | null;
|
|
1353
|
+
/** Event-level minimum age (0 = none); per-ticket effective age is on each ticket type. */
|
|
1354
|
+
minAge: number;
|
|
1355
|
+
/** Who supplies the DOB when an age check applies. */
|
|
1356
|
+
dobMode: EventDobMode;
|
|
1357
|
+
/** Require an email from every ticket holder (vs just the buyer). */
|
|
1358
|
+
attendeeEmailRequired: boolean;
|
|
1359
|
+
/** Current FrontDesk ticket-charge rate so checkout can preview passed-through charges. */
|
|
1360
|
+
frontdeskFee: EventCommissionConfig;
|
|
1361
|
+
/** RESOLVED event-level customer checkout charge (organizer override or the FrontDesk rate). Applies
|
|
1362
|
+
* once to the non-exempt ticket amount of a checkout. */
|
|
1363
|
+
customerFee: EventCommissionConfig;
|
|
1364
|
+
/** The customer-charge FLAT component FX-converted (rounded up clean) into each accepted SECONDARY
|
|
1365
|
+
* currency, so the client fee preview matches the server's converted charge. Absent for
|
|
1366
|
+
* single-currency stores or currencies with no configured rate (treat as unavailable). */
|
|
1367
|
+
customerFeeFlatByCurrency?: Record<string, number>;
|
|
1368
|
+
/** Named days for a multi-day event (empty = single-day). Drives the day-pass labels. */
|
|
1369
|
+
days: PublicEventDay[];
|
|
1370
|
+
/** PV-106 Save For It — the event master switch. A ticket is only saveable when this is on AND that
|
|
1371
|
+
* ticket's own `saveForItSaveable` is true; use for the section-level "Save for it available" copy. */
|
|
1372
|
+
saveForItEnabled: boolean;
|
|
1373
|
+
/** Fuzzed social proof for the savings panel — "About 100 people". Counts savers across ALL of this
|
|
1374
|
+
* event's ticket types, arrives ALREADY vague (the real count never reaches the browser), and is null
|
|
1375
|
+
* whenever the crowd is too small, the organizer opted out, or Ops switched it off. */
|
|
1376
|
+
saveForItCrowdLabel?: string | null;
|
|
1377
|
+
ticketTypes: PublicEventTicketType[];
|
|
1378
|
+
/** Packages the organizer attached to sell alongside tickets (PV-082) — active + public only,
|
|
1379
|
+
* bought via the package's own page + checkout. Empty when none. */
|
|
1380
|
+
attachedPackages: PublicEventPackage[];
|
|
1381
|
+
/** Store products the organizer attached (Store epic) — active + public only, shown under tickets.
|
|
1382
|
+
* Full products (with variants) so they can be added to the combined event cart (Phase 5b: tickets +
|
|
1383
|
+
* products in one order, one payment). Empty when none. */
|
|
1384
|
+
attachedProducts: PublicProduct[];
|
|
1385
|
+
brand: {
|
|
1386
|
+
ref: string;
|
|
1387
|
+
displayName: string;
|
|
1388
|
+
avatarUrl: string | null;
|
|
1389
|
+
handle: string | null;
|
|
1390
|
+
};
|
|
1391
|
+
/** Whether the workspace storefront is published (so the page can render in-chrome). */
|
|
1392
|
+
storefrontPublished: boolean;
|
|
1393
|
+
/** FD-SPONS — the active Official Ticket Sponsor of this event, injected server-side ONLY while the
|
|
1394
|
+
* sponsorship is active. Null the moment it pauses/ends, so branding de-injects automatically. */
|
|
1395
|
+
sponsorship?: PublicEventSponsorship | null;
|
|
1396
|
+
}
|
|
1397
|
+
/** A package attached to an event (PV-082) — rendered as its own container on the event page with its
|
|
1398
|
+
* options/tiers inline. The full public package (active + public only), reused from the package engine. */
|
|
1399
|
+
interface PublicEventPackage {
|
|
1400
|
+
ref: string;
|
|
1401
|
+
type: string;
|
|
1402
|
+
name: string;
|
|
1403
|
+
slug: string;
|
|
1404
|
+
description: string | null;
|
|
1405
|
+
coverUrl: string | null;
|
|
1406
|
+
currency: string;
|
|
1407
|
+
/** The SAME shape a standalone public package serves: `publicForEvent` delegates to
|
|
1408
|
+
* `buildPublicPackage`/`serializePublicOption`, so scheduling + availability really are present.
|
|
1409
|
+
* Was `PublicEventPackageOption[]`, which under-declared it. */
|
|
1410
|
+
options: PublicPackageOption[];
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/** A choice in a select / multi_select column. */
|
|
1414
|
+
interface SelectOption {
|
|
1415
|
+
id: string;
|
|
1416
|
+
label: string;
|
|
1417
|
+
color?: string;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/** Question input types. A strict SUBSET of the Tables column types (no relations) so every field maps
|
|
1421
|
+
* 1:1 to a column. `formFieldToColumnType` is the (identity) bridge. */
|
|
1422
|
+
type FormFieldType = "text" | "long_text" | "number" | "currency" | "date" | "datetime" | "checkbox" | "select" | "multi_select" | "email" | "phone" | "url" | "file" | "multi_file";
|
|
1423
|
+
/** Which kinds of file a `multi_file` question accepts. */
|
|
1424
|
+
type FormFileKind = "image" | "document" | "any";
|
|
1425
|
+
/** Per-question file constraints for a `multi_file` question (stored in `FormField.options.file`). */
|
|
1426
|
+
interface FormFileConstraints {
|
|
1427
|
+
/** Minimum number of files (also implied by `required` >= 1). null = no explicit minimum. */
|
|
1428
|
+
minFiles?: number | null;
|
|
1429
|
+
/** Maximum number of files allowed. null = no cap (bounded by MULTI_FILE_HARD_CAP). */
|
|
1430
|
+
maxFiles?: number | null;
|
|
1431
|
+
/** Accepted file kinds. Defaults to 'image'. */
|
|
1432
|
+
kinds?: FormFileKind;
|
|
1433
|
+
}
|
|
1434
|
+
/** Fields flagged as identity drive the community-contact upsert (matched by email). */
|
|
1435
|
+
type FormFieldIdentity = "name" | "email" | "phone";
|
|
1436
|
+
/** A field as exposed on the PUBLIC page (no internal columnId leaked). */
|
|
1437
|
+
interface PublicFormField {
|
|
1438
|
+
id: string;
|
|
1439
|
+
label: string;
|
|
1440
|
+
type: FormFieldType;
|
|
1441
|
+
required: boolean;
|
|
1442
|
+
helpText?: string | null;
|
|
1443
|
+
placeholder?: string | null;
|
|
1444
|
+
options?: {
|
|
1445
|
+
choices?: SelectOption[];
|
|
1446
|
+
file?: FormFileConstraints;
|
|
1447
|
+
};
|
|
1448
|
+
identity?: FormFieldIdentity | null;
|
|
1449
|
+
}
|
|
1450
|
+
/** FD.forms item baked into a custom_html frame's __FD_INIT__: the CTA-card basics plus enough of
|
|
1451
|
+
* the definition (fields + submit/thank-you copy) for the frame to render and client-validate a
|
|
1452
|
+
* bound form. `redirectUrl` is deliberately NOT baked — redirects are performed by the HOST page,
|
|
1453
|
+
* and frame script cannot top-navigate without user activation anyway. */
|
|
1454
|
+
interface CustomHtmlFormDef {
|
|
1455
|
+
slug: string;
|
|
1456
|
+
name: string;
|
|
1457
|
+
description: string | null;
|
|
1458
|
+
submitLabel: string | null;
|
|
1459
|
+
thankYouMessage: string;
|
|
1460
|
+
fields: PublicFormField[];
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* FrontDesk public storefront contract (Storefront API §3.2). These shapes are what a
|
|
1465
|
+
* merchant-built frontend receives, so they are additive-only: never remove a field, never retype
|
|
1466
|
+
* one, never narrow an enum. Guarded by `pnpm --filter pro-api store-contract:check`.
|
|
1467
|
+
*/
|
|
1468
|
+
interface PublicCatalogItem {
|
|
1469
|
+
ref: string;
|
|
1470
|
+
type: 'image' | 'video';
|
|
1471
|
+
url: string;
|
|
1472
|
+
thumbnailUrl: string | null;
|
|
1473
|
+
title: string;
|
|
1474
|
+
description: string;
|
|
1475
|
+
linkUrl: string | null;
|
|
1476
|
+
tags: string[];
|
|
1477
|
+
width: number | null;
|
|
1478
|
+
height: number | null;
|
|
1479
|
+
}
|
|
1480
|
+
interface PublicCatalog {
|
|
1481
|
+
catalog: {
|
|
1482
|
+
name: string;
|
|
1483
|
+
slug: string;
|
|
1484
|
+
description: string;
|
|
1485
|
+
allowDownloads: boolean;
|
|
1486
|
+
fullScreenMasonry: boolean;
|
|
1487
|
+
showGalleryName: boolean;
|
|
1488
|
+
showGalleryDescription: boolean;
|
|
1489
|
+
seoIndexable: boolean;
|
|
1490
|
+
/** 'media_catalog' gallery vs 'mood_board' (renders the brief strip). */
|
|
1491
|
+
type?: 'media_catalog' | 'mood_board' | 'standard';
|
|
1492
|
+
/** Mood boards only: the AI-captured creative brief. */
|
|
1493
|
+
brief?: {
|
|
1494
|
+
theme?: string;
|
|
1495
|
+
palette?: string[];
|
|
1496
|
+
occasion?: string;
|
|
1497
|
+
notes?: string;
|
|
1498
|
+
} | null;
|
|
1499
|
+
};
|
|
1500
|
+
items: PublicCatalogItem[];
|
|
1501
|
+
brand: {
|
|
1502
|
+
ref: string;
|
|
1503
|
+
displayName: string;
|
|
1504
|
+
avatarUrl: string | null;
|
|
1505
|
+
};
|
|
1506
|
+
storefrontPublished: boolean;
|
|
1507
|
+
}
|
|
1508
|
+
/** A catalog hydrated into the storefront payload for catalog-bound sections (e.g. portfolio). */
|
|
1509
|
+
interface HydratedCatalog {
|
|
1510
|
+
ref: string;
|
|
1511
|
+
name: string;
|
|
1512
|
+
slug: string;
|
|
1513
|
+
description: string;
|
|
1514
|
+
allowDownloads: boolean;
|
|
1515
|
+
fullScreenMasonry: boolean;
|
|
1516
|
+
showGalleryName: boolean;
|
|
1517
|
+
showGalleryDescription: boolean;
|
|
1518
|
+
visibility: 'public' | 'private';
|
|
1519
|
+
publicPath: string;
|
|
1520
|
+
itemCount: number;
|
|
1521
|
+
coverUrl: string | null;
|
|
1522
|
+
items: PublicCatalogItem[];
|
|
1523
|
+
}
|
|
1524
|
+
type PublicFileKind = 'image' | 'video' | 'pdf' | 'doc' | 'sheet' | 'slides' | 'audio' | 'file';
|
|
1525
|
+
interface PublicFolderFile {
|
|
1526
|
+
ref: string;
|
|
1527
|
+
fileKind: PublicFileKind;
|
|
1528
|
+
url: string;
|
|
1529
|
+
thumbnailUrl: string | null;
|
|
1530
|
+
filename: string;
|
|
1531
|
+
mimeType: string | null;
|
|
1532
|
+
sizeBytes: number | null;
|
|
1533
|
+
width: number | null;
|
|
1534
|
+
height: number | null;
|
|
1535
|
+
createdAt: string;
|
|
1536
|
+
}
|
|
1537
|
+
interface PublicFolder {
|
|
1538
|
+
folder: {
|
|
1539
|
+
name: string;
|
|
1540
|
+
slug: string;
|
|
1541
|
+
description: string;
|
|
1542
|
+
allowDownloads: boolean;
|
|
1543
|
+
seoIndexable: boolean;
|
|
1544
|
+
};
|
|
1545
|
+
files: PublicFolderFile[];
|
|
1546
|
+
brand: {
|
|
1547
|
+
ref: string;
|
|
1548
|
+
displayName: string;
|
|
1549
|
+
avatarUrl: string | null;
|
|
1550
|
+
};
|
|
1551
|
+
storefrontPublished: boolean;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* FrontDesk public storefront contract (Storefront API §3.2). These shapes are what a
|
|
1556
|
+
* merchant-built frontend receives, so they are additive-only: never remove a field, never retype
|
|
1557
|
+
* one, never narrow an enum. Guarded by `pnpm --filter pro-api store-contract:check`.
|
|
1558
|
+
*/
|
|
1559
|
+
|
|
1560
|
+
/** A public trust signal on a storefront. */
|
|
1561
|
+
interface StorefrontBadge {
|
|
1562
|
+
type: string;
|
|
1563
|
+
grantedAt: string;
|
|
1564
|
+
}
|
|
1565
|
+
interface StorefrontSectionConfig {
|
|
1566
|
+
id: string;
|
|
1567
|
+
type: 'hero' | 'packages' | 'store' | 'events' | 'portfolio' | 'booking_cta' | 'reviews' | 'product_reviews' | 'about' | 'faq' | 'contact' | 'logo_strip' | 'image_text' | 'rich_text' | 'media_gallery' | 'content_showcase' | 'social' | 'form' | 'profiles' | 'collections' | 'custom_html' | 'newsletter';
|
|
1568
|
+
visible: boolean;
|
|
1569
|
+
hasBeenEdited?: boolean;
|
|
1570
|
+
options?: Record<string, string | number | boolean | null>;
|
|
1571
|
+
}
|
|
1572
|
+
interface StorefrontPageConfig {
|
|
1573
|
+
id: string;
|
|
1574
|
+
type: 'home' | 'about' | 'contact' | 'gallery' | 'portfolio' | 'services' | 'menu' | 'vendors' | 'custom';
|
|
1575
|
+
title: string;
|
|
1576
|
+
slug: string;
|
|
1577
|
+
visible: boolean;
|
|
1578
|
+
metaTitle?: string;
|
|
1579
|
+
sections: StorefrontSectionConfig[];
|
|
1580
|
+
options?: Record<string, string | number | boolean | null>;
|
|
1581
|
+
}
|
|
1582
|
+
interface StorefrontButton {
|
|
1583
|
+
id: string;
|
|
1584
|
+
label: string;
|
|
1585
|
+
visible: boolean;
|
|
1586
|
+
fill?: 'solid' | 'outline';
|
|
1587
|
+
bgColor?: string | null;
|
|
1588
|
+
textColor?: string | null;
|
|
1589
|
+
radius?: number | null;
|
|
1590
|
+
fontScale?: number;
|
|
1591
|
+
fontFamily?: string;
|
|
1592
|
+
fontWeight?: number | null;
|
|
1593
|
+
italic?: boolean;
|
|
1594
|
+
underline?: boolean;
|
|
1595
|
+
uppercase?: boolean;
|
|
1596
|
+
/** Where the button points (canonical link engine). */
|
|
1597
|
+
link: StorefrontLink;
|
|
1598
|
+
}
|
|
1599
|
+
/** A single composable content block (Composed hero, reusable). text=rich HTML, image/video=url,
|
|
1600
|
+
* media=an image/video playlist, buttons=a button row. width is a % of the row
|
|
1601
|
+
* (two <100 blocks sit side-by-side = a "split"). */
|
|
1602
|
+
interface StorefrontBlock {
|
|
1603
|
+
id: string;
|
|
1604
|
+
type: 'text' | 'image' | 'buttons' | 'video' | 'logos' | 'media' | 'testimonial' | 'divider' | 'chat' | 'social' | 'countdown' | 'profiles';
|
|
1605
|
+
width?: number;
|
|
1606
|
+
align?: 'left' | 'center' | 'right';
|
|
1607
|
+
/** Per-block vertical spacing in px (on top of the section's block gap). */
|
|
1608
|
+
spaceTop?: number;
|
|
1609
|
+
spaceBottom?: number;
|
|
1610
|
+
/** false = hidden in the preview/live site (kept, not deleted). */
|
|
1611
|
+
visible?: boolean;
|
|
1612
|
+
/** Image/video/media blocks: rendered pixel width, opacity, monochrome tint, animation, and corner treatment. */
|
|
1613
|
+
mediaWidth?: number;
|
|
1614
|
+
mediaOpacity?: number;
|
|
1615
|
+
mediaMono?: boolean;
|
|
1616
|
+
mediaAnimate?: boolean;
|
|
1617
|
+
mediaRadius?: 'sharp' | 'curved' | 'rounded';
|
|
1618
|
+
/** Legacy numeric radius kept for older saved drafts. */
|
|
1619
|
+
radius?: number;
|
|
1620
|
+
/** Video blocks: false = normal player with sound + controls; absent/true = autoplay muted loop, no controls. */
|
|
1621
|
+
autoplay?: boolean;
|
|
1622
|
+
html?: string;
|
|
1623
|
+
url?: string | null;
|
|
1624
|
+
media?: Array<{
|
|
1625
|
+
id: string;
|
|
1626
|
+
type: 'image' | 'video';
|
|
1627
|
+
url: string;
|
|
1628
|
+
}>;
|
|
1629
|
+
buttons?: StorefrontButton[];
|
|
1630
|
+
/** Logos block: logo images + optional lead label, layout (row / marquee), monochrome tint, height (px). */
|
|
1631
|
+
logos?: Array<{
|
|
1632
|
+
id: string;
|
|
1633
|
+
name: string;
|
|
1634
|
+
imageUrl: string;
|
|
1635
|
+
url?: string | null;
|
|
1636
|
+
visible: boolean;
|
|
1637
|
+
}>;
|
|
1638
|
+
label?: string;
|
|
1639
|
+
logoLayout?: 'row' | 'marquee';
|
|
1640
|
+
logoMono?: boolean;
|
|
1641
|
+
logoHeight?: number;
|
|
1642
|
+
/** Logo corner radius preset: sharp (square) / curved (~8px) / rounded (pill). */
|
|
1643
|
+
logoRadius?: 'sharp' | 'curved' | 'rounded';
|
|
1644
|
+
/** Testimonial block: one or more quote cards with star rating + attribution. */
|
|
1645
|
+
testimonials?: Array<{
|
|
1646
|
+
id: string;
|
|
1647
|
+
quote: string;
|
|
1648
|
+
authorName: string;
|
|
1649
|
+
authorRole: string;
|
|
1650
|
+
rating?: number;
|
|
1651
|
+
visible?: boolean;
|
|
1652
|
+
}>;
|
|
1653
|
+
/** Testimonial block styling. Unset = current default theme. */
|
|
1654
|
+
testimonialPrimaryColor?: string;
|
|
1655
|
+
testimonialBaseColor?: string;
|
|
1656
|
+
testimonialBubbleColor?: string;
|
|
1657
|
+
testimonialBubbleOpacity?: number;
|
|
1658
|
+
testimonialBubbleWidth?: number;
|
|
1659
|
+
testimonialBubbleHeight?: number;
|
|
1660
|
+
testimonialBubblePadding?: number;
|
|
1661
|
+
/** Divider block: horizontal rule with editable thickness, colour and stroke style. */
|
|
1662
|
+
dividerHeight?: number;
|
|
1663
|
+
dividerColor?: string;
|
|
1664
|
+
dividerStyle?: 'solid' | 'dashed' | 'dotted';
|
|
1665
|
+
/** FrontDesk Chat block: the AI greeting bubble + composer placeholder (behaviour lives in the
|
|
1666
|
+
* workspace's AI Front Desk Website policy, not here). */
|
|
1667
|
+
chatGreeting?: string;
|
|
1668
|
+
chatPlaceholder?: string;
|
|
1669
|
+
/** Optional composer accent override: input border + send button. Empty = use storefront brand. */
|
|
1670
|
+
chatAccentColor?: string;
|
|
1671
|
+
/** Chat block: tap-to-ask starter prompts shown before a conversation starts. label = the short
|
|
1672
|
+
* pill text, message = the full question sent when tapped. The server grafts workspace defaults
|
|
1673
|
+
* onto chat blocks that have none, so the client just renders what it gets. */
|
|
1674
|
+
chips?: Array<{
|
|
1675
|
+
label: string;
|
|
1676
|
+
message: string;
|
|
1677
|
+
}>;
|
|
1678
|
+
/** Social block: curated posts (inline, like chat's chips) + how they arrange. */
|
|
1679
|
+
socialLayout?: SocialLayout;
|
|
1680
|
+
socialItems?: SocialEmbedItem[];
|
|
1681
|
+
/** Profiles block: people cards inline on the block (same item shape as profilesSections items). */
|
|
1682
|
+
profiles?: Array<{
|
|
1683
|
+
id: string;
|
|
1684
|
+
name: string;
|
|
1685
|
+
title: string;
|
|
1686
|
+
tag?: string | null;
|
|
1687
|
+
bio?: string | null;
|
|
1688
|
+
imageUrl?: string | null;
|
|
1689
|
+
socials?: Array<{
|
|
1690
|
+
id: string;
|
|
1691
|
+
platform: string;
|
|
1692
|
+
url: string;
|
|
1693
|
+
visible: boolean;
|
|
1694
|
+
}>;
|
|
1695
|
+
link?: StorefrontLink | null;
|
|
1696
|
+
visible: boolean;
|
|
1697
|
+
}>;
|
|
1698
|
+
profilesLayout?: 'carousel' | 'grid' | 'marquee' | 'spotlight';
|
|
1699
|
+
profilesShape?: 'square' | 'circle' | 'portrait' | 'arch';
|
|
1700
|
+
profilesCardSize?: 'small' | 'medium' | 'large';
|
|
1701
|
+
profilesColumns?: number;
|
|
1702
|
+
profilesScrollSpeed?: 'slow' | 'medium' | 'fast';
|
|
1703
|
+
profilesCardTextAlign?: 'auto' | 'left' | 'center';
|
|
1704
|
+
profilesShowTitles?: boolean;
|
|
1705
|
+
profilesShowTags?: boolean;
|
|
1706
|
+
profilesShowBios?: boolean;
|
|
1707
|
+
profilesShowSocials?: boolean;
|
|
1708
|
+
countdownTargetAt?: string;
|
|
1709
|
+
countdownUpDurationSeconds?: number;
|
|
1710
|
+
countdownShowDays?: boolean;
|
|
1711
|
+
countdownShowHours?: boolean;
|
|
1712
|
+
countdownShowMinutes?: boolean;
|
|
1713
|
+
countdownShowSeconds?: boolean;
|
|
1714
|
+
countdownHeader?: string;
|
|
1715
|
+
countdownHeaderPosition?: 'above' | 'below' | 'hidden';
|
|
1716
|
+
countdownSubtext?: string;
|
|
1717
|
+
countdownSubtextPosition?: 'above' | 'below' | 'hidden';
|
|
1718
|
+
countdownCompletionText?: string;
|
|
1719
|
+
countdownLayout?: 'plain' | 'cards';
|
|
1720
|
+
countdownSeparator?: 'colon' | 'dot' | 'none';
|
|
1721
|
+
countdownDayLabel?: string;
|
|
1722
|
+
countdownHourLabel?: string;
|
|
1723
|
+
countdownMinuteLabel?: string;
|
|
1724
|
+
countdownSecondLabel?: string;
|
|
1725
|
+
countdownBackgroundColor?: string;
|
|
1726
|
+
countdownBackgroundOpacity?: number;
|
|
1727
|
+
countdownBorderColor?: string;
|
|
1728
|
+
countdownBorderWidth?: number;
|
|
1729
|
+
countdownRadius?: number;
|
|
1730
|
+
countdownPadding?: number;
|
|
1731
|
+
countdownGap?: number;
|
|
1732
|
+
countdownRevealBlockId?: string;
|
|
1733
|
+
countdownHideAtZero?: boolean;
|
|
1734
|
+
countdownTransition?: 'fade' | 'slide_up';
|
|
1735
|
+
countdownCtaEnabled?: boolean;
|
|
1736
|
+
countdownCta?: StorefrontButton;
|
|
1737
|
+
countdownTrackAnalytics?: boolean;
|
|
1738
|
+
countdownPreviewState?: 'live' | 'before' | 'countup' | 'completed';
|
|
1739
|
+
/** Legacy single-card fields, kept so older drafts still render and can be migrated lazily. */
|
|
1740
|
+
quote?: string;
|
|
1741
|
+
authorName?: string;
|
|
1742
|
+
authorRole?: string;
|
|
1743
|
+
rating?: number;
|
|
1744
|
+
}
|
|
1745
|
+
interface StorefrontBlockGroup {
|
|
1746
|
+
id: string;
|
|
1747
|
+
label?: string;
|
|
1748
|
+
width?: number;
|
|
1749
|
+
/** column = shares a row with other groups; hero = spans the full Hero row. */
|
|
1750
|
+
span?: 'column' | 'hero';
|
|
1751
|
+
align?: 'left' | 'center' | 'right';
|
|
1752
|
+
valign?: 'top' | 'center' | 'bottom';
|
|
1753
|
+
/** Per-group margin in px, useful when rows/columns need breathing room independent of HGap/VGap. */
|
|
1754
|
+
spaceTop?: number;
|
|
1755
|
+
spaceRight?: number;
|
|
1756
|
+
spaceBottom?: number;
|
|
1757
|
+
spaceLeft?: number;
|
|
1758
|
+
visible?: boolean;
|
|
1759
|
+
blockIds: string[];
|
|
1760
|
+
}
|
|
1761
|
+
interface StorefrontHeaderLink {
|
|
1762
|
+
id: string;
|
|
1763
|
+
label: string;
|
|
1764
|
+
/** Where the nav link points (canonical link engine — same as buttons). */
|
|
1765
|
+
link: StorefrontLink;
|
|
1766
|
+
visible: boolean;
|
|
1767
|
+
}
|
|
1768
|
+
interface StorefrontHeaderLinkStyle {
|
|
1769
|
+
color: string | null;
|
|
1770
|
+
fontScale: number;
|
|
1771
|
+
weight: number | null;
|
|
1772
|
+
uppercase: boolean;
|
|
1773
|
+
/** Letter case for nav links. 'auto' = each layout's native look (sentence in the floating bars,
|
|
1774
|
+
* uppercase in the solid ones); anything else applies everywhere. */
|
|
1775
|
+
textCase: 'auto' | 'sentence' | 'upper' | 'capitalize' | 'lower';
|
|
1776
|
+
fontFamily: string;
|
|
1777
|
+
italic: boolean;
|
|
1778
|
+
underline: boolean;
|
|
1779
|
+
}
|
|
1780
|
+
interface StorefrontFieldStyle {
|
|
1781
|
+
fontScale?: number;
|
|
1782
|
+
hidden?: boolean;
|
|
1783
|
+
color?: string | null;
|
|
1784
|
+
opacity?: number;
|
|
1785
|
+
fontFamily?: string;
|
|
1786
|
+
fontWeight?: number | null;
|
|
1787
|
+
italic?: boolean;
|
|
1788
|
+
underline?: boolean;
|
|
1789
|
+
uppercase?: boolean;
|
|
1790
|
+
/** Unitless CSS line-height; null/absent = inherit the layout default. */
|
|
1791
|
+
lineHeight?: number | null;
|
|
1792
|
+
/** Max width of the text block in px; null/absent = auto. Controls how the field wraps. */
|
|
1793
|
+
maxWidth?: number | null;
|
|
1794
|
+
/** Per-field text alignment; null/absent = inherit the section's own alignment. Setting it makes
|
|
1795
|
+
* the field a full-width block so left/center/right actually take effect on an inline span. */
|
|
1796
|
+
align?: 'left' | 'center' | 'right' | null;
|
|
1797
|
+
}
|
|
1798
|
+
interface StorefrontRenderConfig {
|
|
1799
|
+
templateId: string;
|
|
1800
|
+
theme: {
|
|
1801
|
+
fontPairing: 'modern-sans' | 'editorial-serif' | 'compact-sans' | string;
|
|
1802
|
+
palette: 'frontdesk' | 'midnight' | 'fresh' | 'mono' | string;
|
|
1803
|
+
brandColor?: string | null;
|
|
1804
|
+
/** Where the accent comes from: the palette (default) or the brand colour. */
|
|
1805
|
+
accentSource?: 'brand' | 'palette';
|
|
1806
|
+
spacing?: 'compact' | 'comfortable' | 'editorial';
|
|
1807
|
+
contentWidth?: 'full' | 'standard' | 'compact' | 'thin';
|
|
1808
|
+
/** User-created palettes (keys `custom-<slug>`); `palette` and band overrides may reference them. */
|
|
1809
|
+
customPalettes?: Record<string, {
|
|
1810
|
+
label?: string;
|
|
1811
|
+
bg: string;
|
|
1812
|
+
panel: string;
|
|
1813
|
+
text: string;
|
|
1814
|
+
muted: string;
|
|
1815
|
+
line: string;
|
|
1816
|
+
accent?: string;
|
|
1817
|
+
}>;
|
|
1818
|
+
/** User-created font pairings (keys `customfont-<slug>`); `fontPairing` and band overrides too. */
|
|
1819
|
+
customFontPacks?: Record<string, {
|
|
1820
|
+
label: string;
|
|
1821
|
+
heading: {
|
|
1822
|
+
stack: string;
|
|
1823
|
+
weight: number;
|
|
1824
|
+
letterSpacing: string;
|
|
1825
|
+
};
|
|
1826
|
+
body: {
|
|
1827
|
+
stack: string;
|
|
1828
|
+
weight: number;
|
|
1829
|
+
letterSpacing: string;
|
|
1830
|
+
};
|
|
1831
|
+
}>;
|
|
1832
|
+
};
|
|
1833
|
+
sections: StorefrontSectionConfig[];
|
|
1834
|
+
pages?: StorefrontPageConfig[];
|
|
1835
|
+
}
|
|
1836
|
+
interface StorefrontContent {
|
|
1837
|
+
header: {
|
|
1838
|
+
websiteTitle?: string;
|
|
1839
|
+
logoText: string;
|
|
1840
|
+
logoImageUrl?: string | null;
|
|
1841
|
+
logoMode?: 'logo_text' | 'text' | 'logo';
|
|
1842
|
+
logoHeight?: number;
|
|
1843
|
+
logoSvg?: string | null;
|
|
1844
|
+
logoSvgFill?: string | null;
|
|
1845
|
+
logoSvgStroke?: string | null;
|
|
1846
|
+
logoSvgStrokeWidth?: number;
|
|
1847
|
+
faviconUrl?: string | null;
|
|
1848
|
+
/** How a shared storefront link previews on WhatsApp/social. 'card' = a designed 1200x630 share
|
|
1849
|
+
* card (laid out by socialCardTemplate); 'photo' = the best real photo used as-is; 'custom' =
|
|
1850
|
+
* the owner's own artwork. The logo stays the small favicon/icon. */
|
|
1851
|
+
socialPreviewStyle?: SocialPreviewStyle;
|
|
1852
|
+
socialCardTemplate?: SocialCardTemplate;
|
|
1853
|
+
/** Share-only copy. Blank = written from websiteTitle + the profile description. */
|
|
1854
|
+
socialCardHeadline?: string | null;
|
|
1855
|
+
socialCardSubline?: string | null;
|
|
1856
|
+
/** The owner's uploaded 1200x630 artwork, used by the 'custom' style. */
|
|
1857
|
+
socialPreviewImageUrl?: string | null;
|
|
1858
|
+
layout: 'brand_left' | 'brand_center' | 'floating_pill' | 'floating_split';
|
|
1859
|
+
/** Styling for the floating pill layout; ignored by the other two. */
|
|
1860
|
+
pillStyle?: StorefrontHeaderPillStyle;
|
|
1861
|
+
navPosition?: 'left' | 'center' | 'right';
|
|
1862
|
+
ctas?: StorefrontButton[];
|
|
1863
|
+
linkStyle?: StorefrontHeaderLinkStyle;
|
|
1864
|
+
links?: StorefrontHeaderLink[];
|
|
1865
|
+
maxWidth?: 'full' | 'standard' | 'compact' | 'thin';
|
|
1866
|
+
/** Transparent header that sits flush over the hero on load and solidifies on scroll. */
|
|
1867
|
+
overlayHeader?: boolean;
|
|
1868
|
+
/** Nav/logo/icon colour while transparent over the hero. 'auto' reads the hero bg brightness. */
|
|
1869
|
+
overlayColor?: 'light' | 'dark' | 'auto';
|
|
1870
|
+
/** Optional raster logo variant shown only while transparent (light/overlay logo). */
|
|
1871
|
+
overlayLogoImageUrl?: string | null;
|
|
1872
|
+
/** Phone-menu extras: show the business phone / email / socials under the links. Default false. */
|
|
1873
|
+
menuShowPhone?: boolean;
|
|
1874
|
+
menuShowEmail?: boolean;
|
|
1875
|
+
menuShowSocials?: boolean;
|
|
1876
|
+
/** Hide the automatic "Collections" nav link (shown once the store has a visible collection). */
|
|
1877
|
+
hideCollectionsNav?: boolean;
|
|
1878
|
+
/** Theme overrides for the header band. Blank/absent = inherit the page theme (see section-theme). */
|
|
1879
|
+
palette?: string;
|
|
1880
|
+
fontPairing?: string;
|
|
1881
|
+
};
|
|
1882
|
+
hero: {
|
|
1883
|
+
eyebrow: string;
|
|
1884
|
+
title: string;
|
|
1885
|
+
subtitle: string;
|
|
1886
|
+
buttons: StorefrontButton[];
|
|
1887
|
+
backgroundMedia: Array<{
|
|
1888
|
+
id: string;
|
|
1889
|
+
type: 'image' | 'video';
|
|
1890
|
+
url: string;
|
|
1891
|
+
}>;
|
|
1892
|
+
backgroundMode?: 'image' | 'slideshow' | 'video';
|
|
1893
|
+
backgroundUrl?: string | null;
|
|
1894
|
+
backgroundType?: 'image' | 'video' | null;
|
|
1895
|
+
overlay: number;
|
|
1896
|
+
/** Seconds an image slide holds in a rotation; videos always play to their end. */
|
|
1897
|
+
slideDuration?: number;
|
|
1898
|
+
};
|
|
1899
|
+
/** Additional hero sections, keyed by section id. The primary legacy hero still lives at `hero`. */
|
|
1900
|
+
heroSections?: Record<string, StorefrontContent['hero']>;
|
|
1901
|
+
fieldStyles?: Record<string, StorefrontFieldStyle>;
|
|
1902
|
+
footer: {
|
|
1903
|
+
/** Selection state only — a footer palette pick is written through into the colour fields below,
|
|
1904
|
+
* so the renderer reads those, never this. */
|
|
1905
|
+
palette?: string;
|
|
1906
|
+
/** Font-pack override for the footer band. Blank/absent = inherit the page theme. */
|
|
1907
|
+
fontPairing?: string;
|
|
1908
|
+
template: FooterTemplate;
|
|
1909
|
+
brandText: string;
|
|
1910
|
+
tagline: string;
|
|
1911
|
+
copyright: string;
|
|
1912
|
+
statusText?: string | null;
|
|
1913
|
+
showBackToTop?: boolean;
|
|
1914
|
+
backgroundColor: string;
|
|
1915
|
+
/** Optional photo behind the footer (cover, centred); falls back to backgroundColor when null. */
|
|
1916
|
+
backgroundImageUrl?: string | null;
|
|
1917
|
+
/** Dark scrim over the background image, 0-90, so footer text stays legible. */
|
|
1918
|
+
backgroundOverlay?: number;
|
|
1919
|
+
/** Footer brand logo (mirrors the header logo). 'text' = brand text only (default). */
|
|
1920
|
+
logoMode?: 'logo_text' | 'text' | 'logo';
|
|
1921
|
+
logoImageUrl?: string | null;
|
|
1922
|
+
logoSvg?: string | null;
|
|
1923
|
+
logoSvgFill?: string | null;
|
|
1924
|
+
logoSvgStroke?: string | null;
|
|
1925
|
+
logoSvgStrokeWidth?: number;
|
|
1926
|
+
logoHeight?: number;
|
|
1927
|
+
/** False = follow the theme palette; true (or absent, for footers predating the flag) = paint the
|
|
1928
|
+
* six colours below. */
|
|
1929
|
+
customColors?: boolean;
|
|
1930
|
+
textColor: string;
|
|
1931
|
+
mutedTextColor: string;
|
|
1932
|
+
accentColor: string;
|
|
1933
|
+
copyrightBackgroundColor: string;
|
|
1934
|
+
copyrightTextColor: string;
|
|
1935
|
+
socialLinks: Array<{
|
|
1936
|
+
id: string;
|
|
1937
|
+
platform: string;
|
|
1938
|
+
label: string;
|
|
1939
|
+
url: string;
|
|
1940
|
+
visible: boolean;
|
|
1941
|
+
}>;
|
|
1942
|
+
linkGroups: Array<{
|
|
1943
|
+
id: string;
|
|
1944
|
+
title: string;
|
|
1945
|
+
links: Array<{
|
|
1946
|
+
id: string;
|
|
1947
|
+
label: string;
|
|
1948
|
+
link: StorefrontLink;
|
|
1949
|
+
visible: boolean;
|
|
1950
|
+
}>;
|
|
1951
|
+
}>;
|
|
1952
|
+
maxWidth?: 'full' | 'standard' | 'compact' | 'thin';
|
|
1953
|
+
/** Optional blocks. Absent = off; the TEMPLATE decides where each renders. */
|
|
1954
|
+
newsletter?: StorefrontFooterNewsletter;
|
|
1955
|
+
payments?: StorefrontFooterPayments;
|
|
1956
|
+
address?: StorefrontFooterAddress;
|
|
1957
|
+
hours?: StorefrontFooterHours;
|
|
1958
|
+
trust?: StorefrontFooterTrust;
|
|
1959
|
+
};
|
|
1960
|
+
contact: {
|
|
1961
|
+
addressLine1: string;
|
|
1962
|
+
addressLine2: string;
|
|
1963
|
+
city: string;
|
|
1964
|
+
state: string;
|
|
1965
|
+
country: string;
|
|
1966
|
+
googleMapsUrl?: string | null;
|
|
1967
|
+
email?: string | null;
|
|
1968
|
+
phoneNumbers: string[];
|
|
1969
|
+
socials: Array<{
|
|
1970
|
+
id: string;
|
|
1971
|
+
platform: string;
|
|
1972
|
+
label: string;
|
|
1973
|
+
url: string;
|
|
1974
|
+
visible: boolean;
|
|
1975
|
+
}>;
|
|
1976
|
+
showMap: boolean;
|
|
1977
|
+
};
|
|
1978
|
+
logoStrips: Record<string, {
|
|
1979
|
+
logos: Array<{
|
|
1980
|
+
id: string;
|
|
1981
|
+
name: string;
|
|
1982
|
+
imageUrl: string;
|
|
1983
|
+
url?: string | null;
|
|
1984
|
+
visible: boolean;
|
|
1985
|
+
radius?: number | null;
|
|
1986
|
+
size?: 'small' | 'medium' | 'large';
|
|
1987
|
+
}>;
|
|
1988
|
+
}>;
|
|
1989
|
+
faqSections: Record<string, {
|
|
1990
|
+
items: Array<{
|
|
1991
|
+
id: string;
|
|
1992
|
+
question: string;
|
|
1993
|
+
answer: string;
|
|
1994
|
+
category?: string;
|
|
1995
|
+
visible: boolean;
|
|
1996
|
+
}>;
|
|
1997
|
+
}>;
|
|
1998
|
+
imageTextSections: Record<string, {
|
|
1999
|
+
media: Array<{
|
|
2000
|
+
id: string;
|
|
2001
|
+
type: 'image' | 'video';
|
|
2002
|
+
url: string;
|
|
2003
|
+
}>;
|
|
2004
|
+
body: string;
|
|
2005
|
+
}>;
|
|
2006
|
+
richTextSections?: Record<string, {
|
|
2007
|
+
body: string;
|
|
2008
|
+
}>;
|
|
2009
|
+
/** Composable content blocks, keyed by section id (the "Composed" hero is the first consumer). */
|
|
2010
|
+
blockSections?: Record<string, {
|
|
2011
|
+
blocks: StorefrontBlock[];
|
|
2012
|
+
groups?: StorefrontBlockGroup[];
|
|
2013
|
+
}>;
|
|
2014
|
+
mediaGallerySections: Record<string, {
|
|
2015
|
+
media: Array<{
|
|
2016
|
+
id: string;
|
|
2017
|
+
type: 'image' | 'video';
|
|
2018
|
+
url: string;
|
|
2019
|
+
caption?: string;
|
|
2020
|
+
}>;
|
|
2021
|
+
ctas: StorefrontButton[];
|
|
2022
|
+
}>;
|
|
2023
|
+
/** Curated social embeds, keyed by section id (used by the social section). */
|
|
2024
|
+
socialSections?: Record<string, {
|
|
2025
|
+
layout: 'grid' | 'carousel' | 'featured';
|
|
2026
|
+
items: SocialEmbedItem[];
|
|
2027
|
+
}>;
|
|
2028
|
+
reviewsSections?: Record<string, {
|
|
2029
|
+
items: Array<{
|
|
2030
|
+
id: string;
|
|
2031
|
+
name: string;
|
|
2032
|
+
role: string;
|
|
2033
|
+
text: string;
|
|
2034
|
+
avatarUrl?: string | null;
|
|
2035
|
+
rating?: number;
|
|
2036
|
+
visible: boolean;
|
|
2037
|
+
}>;
|
|
2038
|
+
}>;
|
|
2039
|
+
/** People cards (team/lineup/speakers), keyed by section id (used by the profiles section). */
|
|
2040
|
+
profilesSections?: Record<string, {
|
|
2041
|
+
source?: string;
|
|
2042
|
+
items: Array<{
|
|
2043
|
+
id: string;
|
|
2044
|
+
name: string;
|
|
2045
|
+
title: string;
|
|
2046
|
+
tag?: string | null;
|
|
2047
|
+
bio?: string | null;
|
|
2048
|
+
imageUrl?: string | null;
|
|
2049
|
+
socials?: Array<{
|
|
2050
|
+
id: string;
|
|
2051
|
+
platform: string;
|
|
2052
|
+
url: string;
|
|
2053
|
+
visible: boolean;
|
|
2054
|
+
}>;
|
|
2055
|
+
link?: StorefrontLink | null;
|
|
2056
|
+
visible: boolean;
|
|
2057
|
+
}>;
|
|
2058
|
+
}>;
|
|
2059
|
+
/** Content Showcase ("Highlights") cards, keyed by section id. Purely storefront-authored, no portal data. */
|
|
2060
|
+
contentShowcaseSections?: Record<string, {
|
|
2061
|
+
items: Array<{
|
|
2062
|
+
id: string;
|
|
2063
|
+
eyebrow?: string | null;
|
|
2064
|
+
title: string;
|
|
2065
|
+
description?: string | null;
|
|
2066
|
+
mediaType: 'image' | 'video';
|
|
2067
|
+
imageUrl?: string | null;
|
|
2068
|
+
videoUrl?: string | null;
|
|
2069
|
+
buttons: StorefrontButton[];
|
|
2070
|
+
link?: StorefrontLink | null;
|
|
2071
|
+
visible: boolean;
|
|
2072
|
+
}>;
|
|
2073
|
+
}>;
|
|
2074
|
+
/** Custom HTML section code bodies (raw html/css/js the author wrote) + picked photo URLs, keyed by section id. */
|
|
2075
|
+
/** Newsletter section copy, keyed by section id — the SAME shape as the footer's newsletter block,
|
|
2076
|
+
* so one form component serves both. */
|
|
2077
|
+
newsletterSections?: Record<string, StorefrontFooterNewsletter>;
|
|
2078
|
+
customHtmlSections?: Record<string, {
|
|
2079
|
+
html?: string;
|
|
2080
|
+
css?: string;
|
|
2081
|
+
js?: string;
|
|
2082
|
+
images?: string[];
|
|
2083
|
+
videos?: string[];
|
|
2084
|
+
links?: Array<{
|
|
2085
|
+
id?: string;
|
|
2086
|
+
label?: string;
|
|
2087
|
+
link?: StorefrontLink;
|
|
2088
|
+
}>;
|
|
2089
|
+
pins?: StorefrontCustomHtmlPins;
|
|
2090
|
+
fields?: Record<string, string>;
|
|
2091
|
+
}>;
|
|
2092
|
+
sectionButtons?: Record<string, StorefrontButton>;
|
|
2093
|
+
sectionButtonGroups?: Record<string, StorefrontButton[]>;
|
|
2094
|
+
tracking?: {
|
|
2095
|
+
facebookPixelId?: string;
|
|
2096
|
+
ga4Id?: string;
|
|
2097
|
+
gtmId?: string;
|
|
2098
|
+
tiktokPixelId?: string;
|
|
2099
|
+
snapPixelId?: string;
|
|
2100
|
+
xPixelId?: string;
|
|
2101
|
+
xPurchaseEventId?: string;
|
|
2102
|
+
xAddToCartEventId?: string;
|
|
2103
|
+
xLeadEventId?: string;
|
|
2104
|
+
shareHashedEmail?: boolean;
|
|
2105
|
+
customHead?: string;
|
|
2106
|
+
customBodyEnd?: string;
|
|
2107
|
+
};
|
|
2108
|
+
/** On-load + exit-intent promo campaigns. Site-wide, not a section (see @frontdesk/shared promo). */
|
|
2109
|
+
promo?: StorefrontPromoContent;
|
|
2110
|
+
}
|
|
2111
|
+
/** The public storefront payload returned by GET /v1/storefront/:ref. */
|
|
2112
|
+
interface Storefront {
|
|
2113
|
+
ref: string;
|
|
2114
|
+
type: string;
|
|
2115
|
+
displayName: string;
|
|
2116
|
+
/** Workspace business-hours timezone used by date-sensitive storefront components. */
|
|
2117
|
+
timezone?: string;
|
|
2118
|
+
description: string | null;
|
|
2119
|
+
/** Kept for back-compat — prefer categorySlugs. */
|
|
2120
|
+
categorySlug: string | null;
|
|
2121
|
+
categorySlugs?: string[];
|
|
2122
|
+
website?: string | null;
|
|
2123
|
+
socials?: Record<string, string>;
|
|
2124
|
+
city?: string | null;
|
|
2125
|
+
state?: string | null;
|
|
2126
|
+
country?: string | null;
|
|
2127
|
+
avatarUrl: string | null;
|
|
2128
|
+
coverUrl: string | null;
|
|
2129
|
+
badges: StorefrontBadge[];
|
|
2130
|
+
memberCount?: number;
|
|
2131
|
+
renderConfig?: StorefrontRenderConfig;
|
|
2132
|
+
content?: StorefrontContent;
|
|
2133
|
+
/** Media catalogs referenced by sections (options.catalogRef), hydrated server-side. */
|
|
2134
|
+
catalogs?: Record<string, HydratedCatalog>;
|
|
2135
|
+
/** Hand-picked media assets (portfolio custom mode, options.assetRefs CSV), keyed by ref. */
|
|
2136
|
+
assets?: Record<string, PublicCatalogItem>;
|
|
2137
|
+
/** Active public packages for the `packages` section (lightweight summaries). */
|
|
2138
|
+
packages?: PublicPackageSummary[];
|
|
2139
|
+
/** Full packages (with options) for packages sections in 'options'/'custom' mode, keyed by package ref. */
|
|
2140
|
+
packageDetails?: Record<string, PublicPackage>;
|
|
2141
|
+
/** Active public products for the `store` section (lightweight summaries). */
|
|
2142
|
+
products?: PublicProductSummary[];
|
|
2143
|
+
/** Active collections (membership resolved into refs against `products`) — the `collections`
|
|
2144
|
+
* section, the /collections pages and the auto nav link. */
|
|
2145
|
+
collections?: PublicCollectionSummary[];
|
|
2146
|
+
/** Published public events for the `events` section + /events listing (lightweight summaries). */
|
|
2147
|
+
events?: PublicEventSummary[];
|
|
2148
|
+
/** The workspace's primary verified custom domain — the canonical host, when set. */
|
|
2149
|
+
primaryDomain?: string | null;
|
|
2150
|
+
/** Every verified custom domain the site is live on, primary first — the tracking allowlist's
|
|
2151
|
+
* source of truth for "is this host really ours?" (see `trackingAllowedHosts`). */
|
|
2152
|
+
verifiedDomains?: string[];
|
|
2153
|
+
/** Effective image-upload policy (admin default + this FrontDesk's override) for client-side resize. */
|
|
2154
|
+
mediaPolicy?: MediaUploadPolicy;
|
|
2155
|
+
/** The store's effective payment methods — the footer's payment marks are DERIVED from these
|
|
2156
|
+
* server-side, so the footer can never advertise a rail checkout won't offer. Absent when the
|
|
2157
|
+
* footer's payment block is off. */
|
|
2158
|
+
paymentMethods?: string[];
|
|
2159
|
+
/** Weekly opening hours from the workspace's front-desk policy, for the footer's hours block.
|
|
2160
|
+
* Absent when hours were never configured, which is how that block self-hides. */
|
|
2161
|
+
businessHours?: Array<{
|
|
2162
|
+
day: string;
|
|
2163
|
+
start: string;
|
|
2164
|
+
end: string;
|
|
2165
|
+
}>;
|
|
2166
|
+
/** Own-site floating chat bubble (frontdesk-embed-chat-widget): the renderer injects /widget.js with
|
|
2167
|
+
* this embed key when set (suppressed on /chat, chat-hero pages, and the builder preview). */
|
|
2168
|
+
widgetBubble?: {
|
|
2169
|
+
key: string;
|
|
2170
|
+
} | null;
|
|
2171
|
+
/** Store-wide review aggregate + recent published reviews — feeds the custom_html sandbox's
|
|
2172
|
+
* FD.reviews. Only present when a visible custom_html section has code AND reviews are enabled. */
|
|
2173
|
+
reviews?: StorefrontReviewsData;
|
|
2174
|
+
/** Published public forms (FD.forms) — same hydration gate as `reviews`. */
|
|
2175
|
+
forms?: StorefrontFormSummary[];
|
|
2176
|
+
}
|
|
2177
|
+
/** Per-entity pinned refs for a Custom HTML section's FD.* data lists (forms pin by slug). */
|
|
2178
|
+
interface StorefrontCustomHtmlPins {
|
|
2179
|
+
products?: string[];
|
|
2180
|
+
packages?: string[];
|
|
2181
|
+
collections?: string[];
|
|
2182
|
+
catalogs?: string[];
|
|
2183
|
+
events?: string[];
|
|
2184
|
+
forms?: string[];
|
|
2185
|
+
}
|
|
2186
|
+
/** FD.reviews: store-wide aggregate + recent published reviews, product-attributed. */
|
|
2187
|
+
interface StorefrontReviewsData {
|
|
2188
|
+
average: number;
|
|
2189
|
+
count: number;
|
|
2190
|
+
items: Array<{
|
|
2191
|
+
author: string;
|
|
2192
|
+
rating: number;
|
|
2193
|
+
text: string;
|
|
2194
|
+
date: string;
|
|
2195
|
+
productName: string;
|
|
2196
|
+
productSlug: string | null;
|
|
2197
|
+
}>;
|
|
2198
|
+
}
|
|
2199
|
+
/** FD.forms item — the CTA-card basics plus the public field definitions + submit/thank-you copy,
|
|
2200
|
+
* so a custom_html frame can render and client-validate a BOUND form (data-fd-form). */
|
|
2201
|
+
type StorefrontFormSummary = CustomHtmlFormDef;
|
|
2202
|
+
|
|
2203
|
+
/**
|
|
2204
|
+
* FrontDesk public storefront contract (Storefront API §3.2). These shapes are what a
|
|
2205
|
+
* merchant-built frontend receives, so they are additive-only: never remove a field, never retype
|
|
2206
|
+
* one, never narrow an enum. Guarded by `pnpm --filter pro-api store-contract:check`.
|
|
2207
|
+
*/
|
|
2208
|
+
interface PublicNoteApproval {
|
|
2209
|
+
approverName: string;
|
|
2210
|
+
approverEmail: string | null;
|
|
2211
|
+
note: string | null;
|
|
2212
|
+
approvedAt: string;
|
|
2213
|
+
}
|
|
2214
|
+
interface PublicNoteComment {
|
|
2215
|
+
ref: string;
|
|
2216
|
+
authorKind: 'client' | 'team';
|
|
2217
|
+
authorName: string;
|
|
2218
|
+
body: string;
|
|
2219
|
+
createdAt: string;
|
|
2220
|
+
}
|
|
2221
|
+
interface PublicNote {
|
|
2222
|
+
ref: string;
|
|
2223
|
+
title: string;
|
|
2224
|
+
slug: string;
|
|
2225
|
+
contentHtml: string;
|
|
2226
|
+
publicPath: string;
|
|
2227
|
+
updatedAt: string;
|
|
2228
|
+
lastEditedAt: string | null;
|
|
2229
|
+
approvals: PublicNoteApproval[];
|
|
2230
|
+
approvalCount: number;
|
|
2231
|
+
comments: PublicNoteComment[];
|
|
2232
|
+
brand: {
|
|
2233
|
+
ref: string;
|
|
2234
|
+
displayName: string;
|
|
2235
|
+
avatarUrl: string | null;
|
|
2236
|
+
handle: string | null;
|
|
2237
|
+
};
|
|
2238
|
+
storefrontPublished: boolean;
|
|
2239
|
+
}
|
|
2240
|
+
/** A shared table (PRO Tables). Read-only, already stripped to the columns the merchant left visible. */
|
|
2241
|
+
interface PublicTableColumn {
|
|
2242
|
+
id: string;
|
|
2243
|
+
name: string;
|
|
2244
|
+
type: string;
|
|
2245
|
+
options?: {
|
|
2246
|
+
choices?: {
|
|
2247
|
+
id: string;
|
|
2248
|
+
label: string;
|
|
2249
|
+
color?: string;
|
|
2250
|
+
}[];
|
|
2251
|
+
currency?: string;
|
|
2252
|
+
agg?: string;
|
|
2253
|
+
};
|
|
2254
|
+
width?: number;
|
|
2255
|
+
}
|
|
2256
|
+
interface PublicTable {
|
|
2257
|
+
name: string;
|
|
2258
|
+
description: string | null;
|
|
2259
|
+
icon: string | null;
|
|
2260
|
+
slug: string;
|
|
2261
|
+
publicPath: string;
|
|
2262
|
+
columns: PublicTableColumn[];
|
|
2263
|
+
rows: Record<string, unknown>[];
|
|
2264
|
+
total: number;
|
|
2265
|
+
truncated: boolean;
|
|
2266
|
+
aggregates: Record<string, number | null>;
|
|
2267
|
+
updatedAt: string;
|
|
2268
|
+
lastEditedAt: string | null;
|
|
2269
|
+
brand: {
|
|
2270
|
+
ref: string;
|
|
2271
|
+
displayName: string;
|
|
2272
|
+
avatarUrl: string | null;
|
|
2273
|
+
handle: string | null;
|
|
2274
|
+
};
|
|
2275
|
+
storefrontPublished: boolean;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
/**
|
|
2279
|
+
* FrontDesk public storefront contract (Storefront API §3.2). These shapes are what a
|
|
2280
|
+
* merchant-built frontend receives, so they are additive-only: never remove a field, never retype
|
|
2281
|
+
* one, never narrow an enum. Guarded by `pnpm --filter pro-api store-contract:check`.
|
|
2282
|
+
*/
|
|
2283
|
+
|
|
2284
|
+
interface PublicForm {
|
|
2285
|
+
ref: string;
|
|
2286
|
+
slug: string;
|
|
2287
|
+
name: string;
|
|
2288
|
+
description: string | null;
|
|
2289
|
+
status: 'draft' | 'published' | 'closed';
|
|
2290
|
+
fields: PublicFormField[];
|
|
2291
|
+
settings: {
|
|
2292
|
+
displayTitle?: string | null;
|
|
2293
|
+
displayDescription?: string | null;
|
|
2294
|
+
sideTitle?: string | null;
|
|
2295
|
+
sideDescription?: string | null;
|
|
2296
|
+
thankYouMessage: string;
|
|
2297
|
+
redirectUrl?: string | null;
|
|
2298
|
+
submitButtonLabel?: string | null;
|
|
2299
|
+
};
|
|
2300
|
+
coverUrl: string | null;
|
|
2301
|
+
workspace: {
|
|
2302
|
+
name: string;
|
|
2303
|
+
handle: string | null;
|
|
2304
|
+
logoUrl: string | null;
|
|
2305
|
+
};
|
|
2306
|
+
/** Short-lived permit for the public upload endpoint (sent as x-fd-upload-token). */
|
|
2307
|
+
uploadToken?: string;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/**
|
|
2311
|
+
* Typed client for the FrontDesk Storefront API.
|
|
2312
|
+
*
|
|
2313
|
+
* Deliberately thin. It is a fetch wrapper with the response types attached and the error envelope
|
|
2314
|
+
* turned into a real Error — nothing more. A client that caches, retries or reshapes responses
|
|
2315
|
+
* becomes a second implementation of the API's semantics, and then the docs describe one thing while
|
|
2316
|
+
* the client does another.
|
|
2317
|
+
*
|
|
2318
|
+
* The types come from `@frontdesk/shared`, which is the SAME source the server serialises from, so
|
|
2319
|
+
* this client cannot describe a shape the API does not return.
|
|
2320
|
+
*/
|
|
2321
|
+
interface StoreClientOptions {
|
|
2322
|
+
/** e.g. `https://api.frontdesk.africa/v1`. No trailing slash needed. */
|
|
2323
|
+
baseUrl: string;
|
|
2324
|
+
/** `fd_pk_…` in a browser, `fd_sk_…` on a server. */
|
|
2325
|
+
key: string;
|
|
2326
|
+
/** Override for tests, or a custom-instrumented fetch. */
|
|
2327
|
+
fetch?: typeof fetch;
|
|
2328
|
+
}
|
|
2329
|
+
/** The contract's error envelope, thrown so a caller can branch on `code` without parsing. */
|
|
2330
|
+
declare class StoreApiError extends Error {
|
|
2331
|
+
readonly code: string;
|
|
2332
|
+
readonly status: number;
|
|
2333
|
+
readonly retryable: boolean;
|
|
2334
|
+
readonly requestId: string | null;
|
|
2335
|
+
readonly details?: Record<string, unknown>;
|
|
2336
|
+
constructor(status: number, body: unknown);
|
|
2337
|
+
}
|
|
2338
|
+
declare function createStoreClient(opts: StoreClientOptions): {
|
|
2339
|
+
/** Escape hatch for anything not yet wrapped. Same auth, same error handling. */
|
|
2340
|
+
request: <T>(path: string, init?: RequestInit & {
|
|
2341
|
+
idempotencyKey?: string;
|
|
2342
|
+
}) => Promise<T>;
|
|
2343
|
+
storefront: () => Promise<Storefront>;
|
|
2344
|
+
products: () => Promise<PublicProductSummary[]>;
|
|
2345
|
+
product: (slug: string) => Promise<PublicProduct>;
|
|
2346
|
+
productSlots: (slug: string, opts: {
|
|
2347
|
+
variantRef: string;
|
|
2348
|
+
date: string;
|
|
2349
|
+
}) => Promise<PublicProductSlots>;
|
|
2350
|
+
collections: () => Promise<PublicCollectionSummary[]>;
|
|
2351
|
+
collection: (slug: string) => Promise<PublicCollectionDetail>;
|
|
2352
|
+
events: () => Promise<PublicEventSummary[]>;
|
|
2353
|
+
event: (slug: string) => Promise<PublicEvent>;
|
|
2354
|
+
form: (slug: string) => Promise<PublicForm>;
|
|
2355
|
+
table: (slug: string) => Promise<PublicTable>;
|
|
2356
|
+
catalog: (slug: string) => Promise<PublicCatalog>;
|
|
2357
|
+
files: (slug: string) => Promise<PublicFolder>;
|
|
2358
|
+
note: (slug: string) => Promise<PublicNote>;
|
|
2359
|
+
deliveryZones: () => Promise<DeliveryZone[]>;
|
|
2360
|
+
/**
|
|
2361
|
+
* Live stock for the variants a cart already holds — a cart is localStorage, so its lines can be
|
|
2362
|
+
* days old. A ref missing from the response map is no longer purchasable: drop that line. Refs in
|
|
2363
|
+
* `preorderable` are still buyable at 0 stock; cap those on `preorderRemaining` instead.
|
|
2364
|
+
*/
|
|
2365
|
+
availability: (variantRefs: string[]) => Promise<PublicStoreAvailability>;
|
|
2366
|
+
/** The payment rails this workspace can charge, for rendering your own picker (headless only). */
|
|
2367
|
+
paymentMethods: (currency?: string) => Promise<string[]>;
|
|
2368
|
+
/**
|
|
2369
|
+
* Open a hosted checkout. SERVER ONLY — needs a secret key.
|
|
2370
|
+
*
|
|
2371
|
+
* `idempotencyKey` is required by the API, not optional politeness: reuse the same value on a
|
|
2372
|
+
* retry and you get the SAME checkout back instead of charging a buyer twice. Derive it from
|
|
2373
|
+
* something stable in your own system (your cart id), never a random value per attempt.
|
|
2374
|
+
*/
|
|
2375
|
+
createCheckout: (input: StoreCheckoutSessionInput, idempotencyKey: string) => Promise<StoreCheckoutSessionView>;
|
|
2376
|
+
/**
|
|
2377
|
+
* Open a hosted checkout for event tickets. SERVER ONLY — needs a secret key.
|
|
2378
|
+
*
|
|
2379
|
+
* `GET /store/events/:slug` tells you what to collect first: each tier carries formFields,
|
|
2380
|
+
* requiresAttendeeDetails, groupSize and min/max per order; the event carries minAge and dobMode.
|
|
2381
|
+
* Everything is re-validated server-side, so prices and availability are never yours to decide.
|
|
2382
|
+
*/
|
|
2383
|
+
createEventCheckout: (slug: string, input: StoreEventCheckoutInput, idempotencyKey: string) => Promise<StoreCheckoutSessionView>;
|
|
2384
|
+
/**
|
|
2385
|
+
* Open a ticket checkout you render yourself, including the payment step. SERVER ONLY, and the
|
|
2386
|
+
* workspace must have headless switched on. Name ONE provider from `paymentMethods()`; branch on
|
|
2387
|
+
* the provider you asked for, never on which payment field came back filled in.
|
|
2388
|
+
*/
|
|
2389
|
+
createHeadlessCheckout: (slug: string, input: StoreHeadlessCheckoutInput, idempotencyKey: string) => Promise<StoreHeadlessCheckoutView>;
|
|
2390
|
+
/**
|
|
2391
|
+
* The state of a checkout. THIS is how you confirm a purchase — the return redirect is a browser
|
|
2392
|
+
* navigation and can be lost, replayed or forged, so never fulfil on it alone. Safe to poll while
|
|
2393
|
+
* a buyer is paying: an open session is verified against the provider before we answer.
|
|
2394
|
+
*/
|
|
2395
|
+
getCheckout: (ref: string) => Promise<StoreCheckoutSessionView>;
|
|
2396
|
+
/**
|
|
2397
|
+
* Cancel an open checkout and release the seats it was holding, instead of leaving them out of
|
|
2398
|
+
* stock until the session lapses. Cancelling an already-paid checkout returns 409.
|
|
2399
|
+
*/
|
|
2400
|
+
cancelCheckout: (ref: string) => Promise<StoreCheckoutSessionView>;
|
|
2401
|
+
};
|
|
2402
|
+
type StoreClient = ReturnType<typeof createStoreClient>;
|
|
2403
|
+
/**
|
|
2404
|
+
* Verify a webhook signature.
|
|
2405
|
+
*
|
|
2406
|
+
* Node only (uses `node:crypto`). Pass the RAW request body — parse it after verifying, never
|
|
2407
|
+
* before: `JSON.parse` then `JSON.stringify` will not reproduce the bytes we signed, and the check
|
|
2408
|
+
* will fail for reasons that look like a key mismatch.
|
|
2409
|
+
*
|
|
2410
|
+
* `toleranceSec` rejects a delivery whose timestamp is far from now, which is what stops a captured
|
|
2411
|
+
* request being replayed at you later.
|
|
2412
|
+
*/
|
|
2413
|
+
declare function verifyWebhook(opts: {
|
|
2414
|
+
rawBody: string;
|
|
2415
|
+
signatureHeader: string | null | undefined;
|
|
2416
|
+
timestampHeader: string | null | undefined;
|
|
2417
|
+
secret: string;
|
|
2418
|
+
toleranceSec?: number;
|
|
2419
|
+
}): Promise<boolean>;
|
|
2420
|
+
|
|
2421
|
+
export { StoreApiError, type StoreClient, type StoreClientOptions, createStoreClient, verifyWebhook };
|