@tribe-nest/forge 1.20.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/AppAuthContext.tsx +26 -0
  3. package/src/contexts/CartContext.tsx +132 -8
  4. package/src/data/queries/useCheckouts.ts +101 -0
  5. package/src/data/queries/useCollections.ts +24 -4
  6. package/src/data/queries/useFinalize.ts +41 -0
  7. package/src/data/queries/usePageActions.ts +2 -0
  8. package/src/index.ts +6 -1
  9. package/src/server/_tests/appUserPermissions.spec.ts +197 -0
  10. package/src/server/appAuth.ts +37 -2
  11. package/src/server/appUsers.ts +133 -0
  12. package/src/server/index.ts +17 -0
  13. package/src/server/jobs.ts +141 -6
  14. package/src/server/platform.ts +208 -0
  15. package/src/types/models.ts +26 -2
  16. package/src/ui/headless/checkout/useCheckout.ts +56 -9
  17. package/src/ui/headless/event/useEventCheckout.ts +36 -0
  18. package/src/ui/headless/funnel/Funnel.tsx +159 -0
  19. package/src/ui/headless/funnel/funnelSession.spec.ts +108 -0
  20. package/src/ui/headless/funnel/funnelSession.ts +88 -0
  21. package/src/ui/headless/funnel/index.ts +3 -0
  22. package/src/ui/headless/funnel/useFunnelStep.ts +70 -0
  23. package/src/ui/headless/index.ts +3 -0
  24. package/src/ui/index.ts +2 -0
  25. package/src/ui/styled/Addons.tsx +77 -0
  26. package/src/ui/styled/BundleConfirmation.tsx +161 -0
  27. package/src/ui/styled/Cart.tsx +66 -9
  28. package/src/ui/styled/CheckoutConfirmation.tsx +25 -1
  29. package/src/ui/styled/EventTickets.tsx +52 -7
  30. package/src/ui/styled/PageActions.tsx +34 -3
  31. package/src/ui/styled/ProductGrid.tsx +45 -9
  32. package/src/utils/formatDateTime.ts +25 -0
  33. package/src/utils/headMeta.ts +50 -0
@@ -16,12 +16,19 @@ import { readableTextOn } from "../theme/contrast";
16
16
  import { useAmountFormatter } from "../format/useFormatCurrency";
17
17
  import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, Perforation, ConfirmationRow, alpha } from "./Confirmation";
18
18
  import { useOrderFinalize } from "../../data/queries/useFinalize";
19
+ import { BundleConfirmation } from "./BundleConfirmation";
19
20
  import { useCart } from "../../contexts/CartContext";
20
21
  import { usePublicAuth } from "../../contexts/PublicAuthContext";
21
22
  import { OrderStatus, ProductDeliveryType, type IPublicOrder } from "../../types/models";
22
23
 
23
24
  export interface CheckoutConfirmationProps {
24
25
  orderId?: string;
26
+ /**
27
+ * Set instead of `orderId` when the payment was a BUNDLE — one intent across
28
+ * tickets and products. Delegates to `<BundleConfirmation>`, which finalizes
29
+ * every surface in one call.
30
+ */
31
+ checkoutId?: string;
25
32
  /**
26
33
  * The Stripe `redirect_status` appended to the return URL (`succeeded` /
27
34
  * `failed` / `processing`). When `failed`, the page shows the failed state
@@ -97,7 +104,24 @@ function statusIcon(status: OrderStatus, color: string) {
97
104
  * status), still-processing (polls the gateway webhook race), or failed/not-found
98
105
  * — with the full order breakdown (delivery groups, gifts, variants, totals).
99
106
  */
100
- export function CheckoutConfirmation({
107
+ export function CheckoutConfirmation(props: CheckoutConfirmationProps) {
108
+ // A bundle settles every surface through one endpoint, so it gets its own
109
+ // confirmation rather than threading a second shape through the order one.
110
+ if (props.checkoutId) {
111
+ return (
112
+ <BundleConfirmation
113
+ checkoutId={props.checkoutId}
114
+ redirectStatus={props.redirectStatus}
115
+ explorePath={props.explorePath}
116
+ accountPath={props.accountPath}
117
+ checkoutPath={props.checkoutPath}
118
+ />
119
+ );
120
+ }
121
+ return <OrderConfirmation {...props} />;
122
+ }
123
+
124
+ function OrderConfirmation({
101
125
  orderId,
102
126
  redirectStatus,
103
127
  formatAmount,
@@ -101,7 +101,17 @@ export function EventTickets({
101
101
 
102
102
  {c.isLoading && <Loading />}
103
103
  {!c.isLoading && !c.event && <p style={{ marginTop: 8 }}>Event not found.</p>}
104
- {c.event && <EventTicketsBody c={c} event={c.event} fmt={fmt} renderPayment={renderPay} />}
104
+ {c.event && (
105
+ <EventTicketsBody
106
+ c={c}
107
+ event={c.event}
108
+ fmt={fmt}
109
+ renderPayment={renderPay}
110
+ // Selection is safe in the cart now — close so the buyer can go
111
+ // browse the add-ons.
112
+ onAddedToCart={() => setOpen(false)}
113
+ />
114
+ )}
105
115
  </DialogContent>
106
116
  </DialogPortal>
107
117
  </DialogRoot>
@@ -115,17 +125,19 @@ function EventTicketsBody({
115
125
  event,
116
126
  fmt,
117
127
  renderPayment,
128
+ onAddedToCart,
118
129
  }: {
119
130
  c: Checkout;
120
131
  event: IEvent;
121
132
  fmt: (n: number) => string;
122
133
  renderPayment?: (props: PaymentRenderProps) => ReactNode;
134
+ onAddedToCart?: () => void;
123
135
  }) {
124
136
  const stepIndex = c.step === "tickets" ? 0 : c.step === "details" ? 1 : 2;
125
137
  return (
126
138
  <div style={{ width: "100%" }}>
127
139
  <Progress current={stepIndex} />
128
- {c.step === "tickets" && <TicketStep c={c} event={event} fmt={fmt} />}
140
+ {c.step === "tickets" && <TicketStep c={c} event={event} fmt={fmt} onAddedToCart={onAddedToCart} />}
129
141
  {c.step === "details" && <DetailsStep c={c} event={event} fmt={fmt} />}
130
142
  {c.step === "payment" && <PaymentStep c={c} fmt={fmt} renderPayment={renderPayment} />}
131
143
  </div>
@@ -174,7 +186,17 @@ function Progress({ current }: { current: number }) {
174
186
  }
175
187
 
176
188
  // ── Step 1: ticket selection (with live expiry/urgency + sold-out/max) ────────
177
- function TicketStep({ c, event, fmt }: { c: Checkout; event: IEvent; fmt: (n: number) => string }) {
189
+ function TicketStep({
190
+ c,
191
+ event,
192
+ fmt,
193
+ onAddedToCart,
194
+ }: {
195
+ c: Checkout;
196
+ event: IEvent;
197
+ fmt: (n: number) => string;
198
+ onAddedToCart?: () => void;
199
+ }) {
178
200
  const theme = useForgeTheme();
179
201
  // Inclusive stores caption ticket prices as tax-inclusive (display only).
180
202
  const pricesIncludeTax = usePricesIncludeTax();
@@ -311,7 +333,21 @@ function TicketStep({ c, event, fmt }: { c: Checkout; event: IEvent; fmt: (n: nu
311
333
  </div>
312
334
 
313
335
  {c.error && <p style={{ color: "#ef4444", fontSize: 14, marginTop: 12 }}>{c.error}</p>}
314
- <ActionBar c={c} fmt={fmt} onNext={c.goToDetails} nextLabel="Continue" />
336
+ <ActionBar
337
+ c={c}
338
+ fmt={fmt}
339
+ onNext={c.goToDetails}
340
+ nextLabel="Continue"
341
+ // Buying tickets alone still goes straight down the untouched
342
+ // per-event flow; this exit exists so the buyer can go add merch and
343
+ // come back with the selection intact.
344
+ secondary={{
345
+ label: c.ticketsInCart ? "Update cart" : "Add to cart",
346
+ onClick: () => {
347
+ if (c.addTicketsToCart()) onAddedToCart?.();
348
+ },
349
+ }}
350
+ />
315
351
  </div>
316
352
  );
317
353
  }
@@ -469,6 +505,7 @@ function ActionBar({
469
505
  onNext,
470
506
  nextLabel,
471
507
  nextDisabled,
508
+ secondary,
472
509
  }: {
473
510
  c: Checkout;
474
511
  fmt: (n: number) => string;
@@ -476,6 +513,8 @@ function ActionBar({
476
513
  onNext: () => void;
477
514
  nextLabel: string;
478
515
  nextDisabled?: boolean;
516
+ /** Extra exit shown next to Back — "Add to cart" on the selection step. */
517
+ secondary?: { label: string; onClick: () => void };
479
518
  }) {
480
519
  const theme = useForgeTheme();
481
520
  return (
@@ -495,9 +534,15 @@ function ActionBar({
495
534
  <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(c.totalAmount)}</span>
496
535
  </div>
497
536
  <div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
498
- <button onClick={onBack} disabled={!onBack} style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}>
499
- Back
500
- </button>
537
+ {secondary ? (
538
+ <button data-testid="tickets-add-to-cart" onClick={secondary.onClick} style={secondaryButtonStyle(theme)}>
539
+ {secondary.label}
540
+ </button>
541
+ ) : (
542
+ <button onClick={onBack} disabled={!onBack} style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}>
543
+ Back
544
+ </button>
545
+ )}
501
546
  <button onClick={onNext} disabled={nextDisabled} style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}>
502
547
  {nextLabel}
503
548
  </button>
@@ -5,6 +5,7 @@ import { readableTextOn } from "../theme/contrast";
5
5
  import { EmailListForm } from "./EmailListForm";
6
6
  import { OfferButton } from "./OfferButton";
7
7
  import { ProductGrid } from "./ProductGrid";
8
+ import { Addons } from "./Addons";
8
9
  import { DonationButton } from "./DonationButton";
9
10
  import { MembershipTiers } from "./MembershipTiers";
10
11
 
@@ -12,11 +13,24 @@ export interface PageActionsProps {
12
13
  pageType: string;
13
14
  entityId?: string;
14
15
  placement?: string;
16
+ /**
17
+ * Where product detail lives on this site — `/i/store` on code sites,
18
+ * `/products` on Craft ones. Only the `addons` block uses it.
19
+ */
20
+ productBasePath?: string;
15
21
  className?: string;
16
22
  style?: CSSProperties;
17
23
  }
18
24
 
19
- function ActionRenderer({ action }: { action: PageActionDescriptor }) {
25
+ function ActionRenderer({
26
+ action,
27
+ entityId,
28
+ productBasePath,
29
+ }: {
30
+ action: PageActionDescriptor;
31
+ entityId?: string;
32
+ productBasePath?: string;
33
+ }) {
20
34
  const theme = useForgeTheme();
21
35
  const c = action.config as Record<string, any>;
22
36
 
@@ -55,6 +69,16 @@ function ActionRenderer({ action }: { action: PageActionDescriptor }) {
55
69
  <ProductGrid columns={c.columns} limit={c.limit} />
56
70
  </div>
57
71
  );
72
+ case "addons":
73
+ return (
74
+ <Addons
75
+ entityId={entityId}
76
+ productIds={c.productIds ?? []}
77
+ title={c.title}
78
+ columns={c.columns}
79
+ productBasePath={productBasePath}
80
+ />
81
+ );
58
82
  case "donation":
59
83
  return <DonationButton donationId={c.donationId} text={c.text} />;
60
84
  case "membership":
@@ -106,7 +130,14 @@ const RESPONSIVE_CSS = `
106
130
  * AI-code-built sites) — each descriptor maps to an existing Forge primitive.
107
131
  * Renders nothing when the slot is empty.
108
132
  */
109
- export function PageActions({ pageType, entityId, placement = "after_content", className, style }: PageActionsProps) {
133
+ export function PageActions({
134
+ pageType,
135
+ entityId,
136
+ placement = "after_content",
137
+ productBasePath,
138
+ className,
139
+ style,
140
+ }: PageActionsProps) {
110
141
  const { data } = usePageActions(pageType, entityId, placement);
111
142
  const actions = data?.actions ?? [];
112
143
  if (actions.length === 0) return null;
@@ -116,7 +147,7 @@ export function PageActions({ pageType, entityId, placement = "after_content", c
116
147
  <style dangerouslySetInnerHTML={{ __html: RESPONSIVE_CSS }} />
117
148
  {actions.map((action) => (
118
149
  <div key={action.id} className={COMPACT_TYPES.has(action.type) ? "forge-pa-compact" : undefined}>
119
- <ActionRenderer action={action} />
150
+ <ActionRenderer action={action} entityId={entityId} productBasePath={productBasePath} />
120
151
  </div>
121
152
  ))}
122
153
  </div>
@@ -1,4 +1,4 @@
1
- import { useGetProducts } from "../../data/queries/useProducts";
1
+ import { useGetProducts, useGetProductsByIds } from "../../data/queries/useProducts";
2
2
  import type { IPublicProduct } from "../../types/models";
3
3
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
4
  import { useAmountFormatter } from "../format/useFormatCurrency";
@@ -9,8 +9,22 @@ import { Loading } from "./Loading";
9
9
  export interface ProductGridProps {
10
10
  columns?: number;
11
11
  limit?: number;
12
+ /**
13
+ * Show these specific products, in this order, instead of the shop listing.
14
+ * What the add-ons block passes — an organiser's chosen list rather than
15
+ * "the first N products".
16
+ */
17
+ productIds?: string[];
12
18
  formatAmount?: (amount: number) => string;
13
19
  onSelect?: (product: IPublicProduct) => void;
20
+ /**
21
+ * Render each card as a link to this href instead of a button. Router-
22
+ * agnostic on purpose: Forge has no router, and the host decides the path
23
+ * (`/i/store/:slug` on code sites, `/products/:slug` on Craft ones).
24
+ */
25
+ hrefFor?: (product: IPublicProduct) => string;
26
+ /** Message shown in place of the grid when there is nothing to show. */
27
+ emptyLabel?: string;
14
28
  className?: string;
15
29
  style?: React.CSSProperties;
16
30
  }
@@ -21,15 +35,33 @@ function minPrice(p: IPublicProduct): number {
21
35
  }
22
36
 
23
37
  /** Themed product grid, built on `useGetProducts`. */
24
- export function ProductGrid({ columns = 3, limit, formatAmount, onSelect, className, style }: ProductGridProps) {
38
+ export function ProductGrid({
39
+ columns = 3,
40
+ limit,
41
+ productIds,
42
+ formatAmount,
43
+ onSelect,
44
+ hrefFor,
45
+ emptyLabel,
46
+ className,
47
+ style,
48
+ }: ProductGridProps) {
25
49
  const t = useThemeTokens();
26
50
  const fmt = useAmountFormatter(formatAmount);
27
51
  const pricesIncludeTax = usePricesIncludeTax();
28
- const { data, isLoading } = useGetProducts({ page: 1 });
29
- const products = (data?.data ?? []).slice(0, limit ?? 100);
52
+ const byIds = !!productIds?.length;
53
+ // Both hooks are called unconditionally (rules of hooks); each disables
54
+ // itself when it isn't the one in use.
55
+ const listQuery = useGetProducts({ page: 1 }, !byIds);
56
+ const idsQuery = useGetProductsByIds(byIds ? productIds! : []);
57
+
58
+ const isLoading = byIds ? idsQuery.isLoading : listQuery.isLoading;
59
+ const products = byIds
60
+ ? (idsQuery.data ?? [])
61
+ : (listQuery.data?.data ?? []).slice(0, limit ?? 100);
30
62
 
31
63
  if (isLoading) return <Loading />;
32
- if (!products.length) return <p style={{ color: t.text, opacity: 0.7 }}>No products yet.</p>;
64
+ if (!products.length) return <p style={{ color: t.text, opacity: 0.7 }}>{emptyLabel ?? "No products yet."}</p>;
33
65
 
34
66
  // Responsive: each card is at least ~150px wide, but also at least
35
67
  // (container / columns), so it renders `columns` across on wide screens and
@@ -50,13 +82,17 @@ export function ProductGrid({ columns = 3, limit, formatAmount, onSelect, classN
50
82
  >
51
83
  {products.map((p) => {
52
84
  const cover = p.media?.find((m) => m.type === "image")?.url ?? p.media?.[0]?.url;
85
+ const href = hrefFor?.(p);
86
+ const Card = (href ? "a" : "button") as React.ElementType;
53
87
  return (
54
- <button
88
+ <Card
55
89
  key={p.id}
56
- onClick={() => onSelect?.(p)}
90
+ {...(href ? { href } : { onClick: () => onSelect?.(p) })}
57
91
  style={{
58
92
  textAlign: "left",
59
- cursor: onSelect ? "pointer" : "default",
93
+ display: "block",
94
+ textDecoration: "none",
95
+ cursor: href || onSelect ? "pointer" : "default",
60
96
  background: t.surface,
61
97
  border: `1px solid ${t.primary}20`,
62
98
  borderRadius: t.cornerRadius,
@@ -74,7 +110,7 @@ export function ProductGrid({ columns = 3, limit, formatAmount, onSelect, classN
74
110
  <PriceDisplay amount={minPrice(p)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
75
111
  </div>
76
112
  </div>
77
- </button>
113
+ </Card>
78
114
  );
79
115
  })}
80
116
  </div>
@@ -0,0 +1,25 @@
1
+ // Date/time formatting for Forge sites and apps.
2
+ //
3
+ // Consolidated here because it was already inlined three times inside Forge
4
+ // itself (EventDetail, ReplayList — one of which is literally commented "inlined
5
+ // from the app's formatDateTime") on top of the starter's own copy. Four copies
6
+ // of the same twelve lines is four places for a timezone bug to be fixed in
7
+ // three of them.
8
+
9
+ /** Locale date-time formatting, replacing the client's `useEditorContext().formatDateTime`. */
10
+ export const formatDateTime = (value: string | number | Date, timezone?: string): string =>
11
+ new Date(value).toLocaleString("en-US", {
12
+ weekday: "long",
13
+ year: "numeric",
14
+ month: "long",
15
+ day: "numeric",
16
+ hour: "numeric",
17
+ minute: "2-digit",
18
+ hour12: true,
19
+ timeZone: timezone || "UTC",
20
+ timeZoneName: "short",
21
+ });
22
+
23
+ /** Short date only, e.g. "Jun 30, 2026". */
24
+ export const formatDate = (value: string | number | Date): string =>
25
+ new Date(value).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
@@ -0,0 +1,50 @@
1
+ // SEO head() builder for Forge sites and apps.
2
+ //
3
+ // Lives in Forge, not in a starter, because BOTH starters need it and it has no
4
+ // coupling to either. It used to sit in the site-starter's `src/routes/i/-lib/`,
5
+ // which made it reachable only by copying a directory an app has no use for —
6
+ // the root routes (`/offline`, `/documents/$token`) imported it out of `/i/` and
7
+ // broke the moment those fan-site routes were removed.
8
+ //
9
+ // Being here also means a fix reaches existing sites: Forge is versioned and
10
+ // published, and sites adopt it through the same update flow. One source, both
11
+ // starters, no drift.
12
+ //
13
+ // Pages pass plain values (title/description/image) rather than a CMS WebPage —
14
+ // code-site pages are authored in code, not the page builder.
15
+
16
+ export interface HeadMetaInput {
17
+ title: string;
18
+ description?: string;
19
+ image?: string;
20
+ canonicalUrl?: string;
21
+ ogType?: string;
22
+ noindex?: boolean;
23
+ }
24
+
25
+ export function buildHeadMeta(input: HeadMetaInput) {
26
+ const { title, description, image, canonicalUrl, ogType = "website", noindex } = input;
27
+ const desc = description || title;
28
+ return {
29
+ meta: [
30
+ { title },
31
+ { name: "description", content: desc },
32
+ { property: "og:title", content: title },
33
+ { property: "og:description", content: desc },
34
+ { property: "og:type", content: ogType },
35
+ ...(canonicalUrl ? [{ property: "og:url", content: canonicalUrl }] : []),
36
+ ...(image ? [{ property: "og:image", content: image }] : []),
37
+ { name: "twitter:card", content: "summary_large_image" },
38
+ { name: "twitter:title", content: title },
39
+ { name: "twitter:description", content: desc },
40
+ ...(image ? [{ name: "twitter:image", content: image }] : []),
41
+ ...(noindex ? [{ name: "robots", content: "noindex, nofollow" }] : []),
42
+ ],
43
+ links: canonicalUrl ? [{ rel: "canonical", href: canonicalUrl }] : [],
44
+ };
45
+ }
46
+
47
+ export const DEFAULT_HEAD = {
48
+ meta: [{ title: "My site" }],
49
+ links: [] as { rel: string; href: string }[],
50
+ };