@tribe-nest/forge 3.11.0 → 3.17.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/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.11.0",
3
+ "version": "3.17.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "description": "Forge the headless React SDK for building custom TribeNest creator sites (the Hydrogen of TribeNest). Exposes the backend (memberships, commerce, gated content, ticketing, courses, booking, auth) as data + behavior primitives.",
7
+ "description": "Forge \u2014 the headless React SDK for building custom TribeNest creator sites (the Hydrogen of TribeNest). Exposes the backend (memberships, commerce, gated content, ticketing, courses, booking, auth) as data + behavior primitives.",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
10
  "./ui": "./src/ui/index.ts",
@@ -29,9 +29,36 @@ export type CartItem = {
29
29
  quantity: number;
30
30
  recipientMessage?: string;
31
31
  payWhatYouWant: boolean;
32
+ /**
33
+ * WHICH version this line is — "Format: FLAC", "Size: L".
34
+ *
35
+ * The cart used to show a colour swatch and a size string, which is all a
36
+ * variant could be. Anything else was unnameable: a buyer who chose FLAC over
37
+ * MP3 saw two identical lines at two prices and no way to tell which was
38
+ * which, right at the moment they are deciding whether to pay.
39
+ *
40
+ * Optional because a product sold one way has no versions to distinguish, and
41
+ * because carts persist — a line saved before this existed still has to
42
+ * render.
43
+ */
44
+ options?: { axis: string; value: string; swatchHex?: string | null }[];
45
+ /** @deprecated Superseded by `options`. Still read for carts saved earlier. */
32
46
  color?: string;
47
+ /** @deprecated Superseded by `options`. Still read for carts saved earlier. */
33
48
  size?: string;
34
- deliveryType?: ProductDeliveryType;
49
+ /**
50
+ * REQUIRED, and deliberately so.
51
+ *
52
+ * Checkout decides whether to ask for a shipping address from this field on
53
+ * the cart LINE. While it was optional, a caller that forgot it produced an
54
+ * item that silently read as non-physical — the Craft music page did exactly
55
+ * that, and a vinyl reached checkout with the buyer never asked where to send
56
+ * the record. A missing address is not recoverable after payment.
57
+ *
58
+ * Making it required turns that into a compile error instead of a defect
59
+ * nobody sees until an order arrives with nowhere to ship it.
60
+ */
61
+ deliveryType: ProductDeliveryType;
35
62
  /** Set automatically from `?addonFor=` — see `addToCart`. */
36
63
  attachedTo?: AttachedTo;
37
64
  };
@@ -55,7 +82,16 @@ export type TicketCartItem = {
55
82
  /** ticketId → quantity, matching `useEventCheckout`'s `selectedTickets`. */
56
83
  tickets: Record<string, number>;
57
84
  /** Display data per ticket id, so the cart can render lines without refetching. */
58
- ticketMeta: Record<string, { title: string; price: number }>;
85
+ /**
86
+ * `price` is the per-unit figure the cart DISPLAYS — on a pay-what-you-want
87
+ * tier that is the buyer's chosen amount, not the tier's floor, so returning
88
+ * from an add-on page doesn't silently forget what they picked.
89
+ *
90
+ * `pwywAmount` is the same number carried explicitly, because the bundle
91
+ * endpoint treats a line's `price` as display-only and never charges it; the
92
+ * chosen amount has to arrive in a field the server actually reads.
93
+ */
94
+ ticketMeta: Record<string, { title: string; price: number; pwywAmount?: number }>;
59
95
  };
60
96
 
61
97
  interface CartContextType {
@@ -25,7 +25,14 @@ export type CheckoutLineInput =
25
25
  eventId: string;
26
26
  ticketId: string;
27
27
  quantity: number;
28
+ /** Display only — the bundle never charges this. */
28
29
  price: number;
30
+ /**
31
+ * The buyer's chosen per-unit amount on a pay-what-you-want tier. Unlike
32
+ * `price`, this IS forwarded into the ticket order and charged (after the
33
+ * server clamps it against the tier's floor).
34
+ */
35
+ pwywAmount?: number;
29
36
  title: string;
30
37
  coverImage?: string;
31
38
  };
@@ -45,6 +52,13 @@ export function cartToCheckoutLines(cartItems: CartItem[], ticketItems: TicketCa
45
52
  ticketId,
46
53
  quantity: qty,
47
54
  price: t.ticketMeta[ticketId]?.price ?? 0,
55
+ // Sent SEPARATELY from `price` above, which the bundle treats as
56
+ // display-only and never charges. This is the field the server forwards
57
+ // into the ticket order — dropping it would charge the tier's floor and
58
+ // still return 200, so the buyer would be quietly undercharged.
59
+ ...(t.ticketMeta[ticketId]?.pwywAmount != null
60
+ ? { pwywAmount: t.ticketMeta[ticketId]?.pwywAmount }
61
+ : {}),
48
62
  title: t.ticketMeta[ticketId]?.title ?? t.eventTitle,
49
63
  coverImage: t.coverImage,
50
64
  })),
@@ -43,6 +43,12 @@ export function useEvent(id?: string, options?: { initialData?: IEvent }) {
43
43
 
44
44
  export type CreateEventOrderInput = {
45
45
  items: Record<string, number>;
46
+ /**
47
+ * ticketId → the buyer's chosen amount, PER UNIT, on a pay-what-you-want
48
+ * tier. Ignored server-side for any tier that is not PWYW, and clamped up to
49
+ * the tier's own `price` — it can raise what is charged, never lower it.
50
+ */
51
+ amounts?: Record<string, number>;
46
52
  email: string;
47
53
  firstName?: string;
48
54
  lastName?: string;
@@ -1,23 +1,39 @@
1
- import type { IPublicProduct, PaginatedData, ProductCategory } from "../../types/models";
1
+ import type { IPublicProduct, PaginatedData, ProductType } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
 
5
5
  export interface GetProductsParams {
6
6
  query?: string;
7
- category?: ProductCategory[];
7
+ /** What the products ARE. Renamed from `category` in the taxonomy split. */
8
+ productType?: ProductType[];
9
+ /**
10
+ * @deprecated The old name for `productType`. Still honoured, and honoured
11
+ * deliberately: callers usually build this object in a `useMemo` and pass it
12
+ * as a VARIABLE, which turns off TypeScript's excess-property check — so a
13
+ * caller left on the old name compiles clean and silently loses its filter,
14
+ * listing the whole catalogue on a page meant to show one type. Dropping the
15
+ * key would make that failure mode look like working code.
16
+ */
17
+ category?: ProductType[];
18
+ /**
19
+ * A creator-authored category. Descendant-inclusive: filtering on "Apparel"
20
+ * returns everything filed beneath it, at any depth.
21
+ */
22
+ categoryId?: string;
23
+ collectionId?: string;
8
24
  page?: number;
9
25
  releaseType?: string;
10
26
  }
11
27
 
12
- /** Featured products for the profile, optionally filtered by category. */
13
- export function useFeaturedProducts(category?: ProductCategory) {
28
+ /** Featured products for the profile, optionally filtered by product type. */
29
+ export function useFeaturedProducts(productType?: ProductType) {
14
30
  const { client, profileId } = useForge();
15
31
 
16
32
  return useQuery<IPublicProduct[]>({
17
- queryKey: ["featured-products", profileId, category],
33
+ queryKey: ["featured-products", profileId, productType],
18
34
  queryFn: async () => {
19
35
  const res = await client.get("/public/products/featured", {
20
- params: { profileId, category },
36
+ params: { profileId, productType },
21
37
  });
22
38
  return res.data;
23
39
  },
@@ -34,7 +50,9 @@ export function useGetProducts(params?: GetProductsParams, enabled = true) {
34
50
  const res = await client.get("/public/products", {
35
51
  params: {
36
52
  profileId: profileId,
37
- category: params?.category,
53
+ productType: params?.productType ?? params?.category,
54
+ categoryId: params?.categoryId,
55
+ collectionId: params?.collectionId,
38
56
  page: params?.page || 1,
39
57
  limit: 10,
40
58
  filter: {
@@ -90,11 +108,21 @@ export function useGetProductsByIds(productIds: string[]) {
90
108
  });
91
109
  }
92
110
 
93
- export function useGetProductCategories() {
111
+ /**
112
+ * The product TYPES this creator has products in, plus a synthetic "Coaching"
113
+ * entry when they sell coaching. This is what the storefront's top-level tabs
114
+ * are built from.
115
+ *
116
+ * The endpoint keeps its `/categories` path: every code-site published before
117
+ * the taxonomy split calls it and reads this exact payload, and the hook name
118
+ * is what changed. For the creator's OWN categories use
119
+ * {@link useProductCategories}.
120
+ */
121
+ export function useGetProductTypes() {
94
122
  const { client, profileId } = useForge();
95
123
 
96
- return useQuery<{ title: ProductCategory; description: string }[]>({
97
- queryKey: ["product-categories", profileId],
124
+ return useQuery<{ title: string; description: string }[]>({
125
+ queryKey: ["product-types", profileId],
98
126
  queryFn: async () => {
99
127
  const res = await client.get("/public/products/categories", {
100
128
  params: {
@@ -106,3 +134,69 @@ export function useGetProductCategories() {
106
134
  enabled: !!profileId && !!client,
107
135
  });
108
136
  }
137
+
138
+ /**
139
+ * @deprecated Renamed to {@link useGetProductTypes}, which is what it always
140
+ * returned. Kept so a site built against an earlier Forge keeps compiling; it
141
+ * will go in a future major.
142
+ */
143
+ export const useGetProductCategories = useGetProductTypes;
144
+
145
+ /** A node in the creator's category tree. Children are nested, never repeated at the root. */
146
+ export interface IPublicProductCategory {
147
+ id: string;
148
+ title: string;
149
+ slug: string;
150
+ description: string | null;
151
+ parentId: string | null;
152
+ position: number;
153
+ productCount: number;
154
+ children: IPublicProductCategory[];
155
+ }
156
+
157
+ export interface IPublicProductCollection {
158
+ id: string;
159
+ title: string;
160
+ slug: string;
161
+ description: string | null;
162
+ isFeatured: boolean;
163
+ position: number;
164
+ productCount: number;
165
+ }
166
+
167
+ /**
168
+ * The creator's OWN category tree — what they organise their store by, as
169
+ * opposed to {@link useGetProductTypes} which is what their products ARE.
170
+ *
171
+ * Counts are descendant-inclusive, so "Apparel" reports everything beneath it.
172
+ */
173
+ export function useProductCategories() {
174
+ const { client, profileId } = useForge();
175
+
176
+ return useQuery<IPublicProductCategory[]>({
177
+ queryKey: ["public-product-categories", profileId],
178
+ queryFn: async () => {
179
+ const res = await client.get("/public/products/product-categories", {
180
+ params: { profileId },
181
+ });
182
+ return res.data;
183
+ },
184
+ enabled: !!profileId && !!client,
185
+ });
186
+ }
187
+
188
+ /** The creator's curated collections — drops, seasons, hand-picked edits. */
189
+ export function useProductCollections() {
190
+ const { client, profileId } = useForge();
191
+
192
+ return useQuery<IPublicProductCollection[]>({
193
+ queryKey: ["public-product-collections", profileId],
194
+ queryFn: async () => {
195
+ const res = await client.get("/public/products/product-collections", {
196
+ params: { profileId },
197
+ });
198
+ return res.data;
199
+ },
200
+ enabled: !!profileId && !!client,
201
+ });
202
+ }
package/src/index.ts CHANGED
@@ -26,6 +26,14 @@ export * from "./types";
26
26
  export { AudioPlayerProvider, useAudioPlayer } from "./contexts/AudioPlayerContext";
27
27
  export type { AudioTrack } from "./contexts/AudioPlayerContext";
28
28
  export { CartProvider, useCart } from "./contexts/CartContext";
29
+ // Variant selection, shared with the Craft themes in frontend-shared the same
30
+ // way the cart and audio player are — so a code site and a Craft site cannot
31
+ // disagree about which version a buyer picked.
32
+ export { useVariantSelection } from "./ui/headless/useVariantSelection";
33
+ // Which version a cart line is. Exported from the package root because the
34
+ // Craft themes render their own cart and must resolve it the same way.
35
+ export { resolveCartLineOptions } from "./ui/styled/CartLineOptions";
36
+ export type { VariantAxis, VariantAxisValue } from "./ui/headless/useVariantSelection";
29
37
  export type { CartItem, TicketCartItem, AttachedTo } from "./contexts/CartContext";
30
38
  export {
31
39
  ACCESS_TOKEN_KEY,
@@ -582,17 +582,46 @@ export type IMedia = {
582
582
  type: MediaType;
583
583
  previewUrl?: string | null;
584
584
  previewStatus?: string | null;
585
+ /**
586
+ * Which version this file is for, when it is a song's file.
587
+ *
588
+ * Null — and absent everywhere else — means every version gets it. Present so
589
+ * an EDITOR can round-trip a per-version file; a storefront never needs it,
590
+ * because the server has already filtered the list to the version being
591
+ * viewed.
592
+ */
593
+ productOptionValueId?: string | null;
585
594
  };
586
595
 
587
- export enum ProductCategory {
596
+ /**
597
+ * What a product IS, which decides how it renders: Music shows a track list,
598
+ * Merch shows variants, Digital is a download, Service is fulfilled by the
599
+ * creator.
600
+ *
601
+ * Renamed from `ProductType` in the taxonomy split. It used to be the only
602
+ * taxonomy a product had, pointing into a four-row table shared by every artist
603
+ * on the platform — so no creator could organise their own store. Creator
604
+ * authored categories and collections are separate now (`categories`,
605
+ * `collections` on {@link IPublicProduct}).
606
+ *
607
+ * Coaching and Course are gone: they are separate surfaces with their own
608
+ * tables and never appeared in `products`. The storefront still shows a
609
+ * Coaching tab — see {@link STOREFRONT_COACHING_TAB}.
610
+ */
611
+ export enum ProductType {
588
612
  Music = "Music",
589
613
  Merch = "Merch",
590
614
  Digital = "Digital",
591
615
  Service = "Service",
592
- Coaching = "Coaching",
593
- Course = "Course",
594
616
  }
595
617
 
618
+ /**
619
+ * The storefront's product-type navigation is not purely {@link ProductType}: a
620
+ * creator with coaching products gets a "Coaching" tab alongside them, though
621
+ * no product row is ever of that type.
622
+ */
623
+ export const STOREFRONT_COACHING_TAB = "Coaching" as const;
624
+
596
625
  export type PostType = "image" | "video" | "audio" | "poll";
597
626
 
598
627
  export type IPublicComment = {
@@ -658,11 +687,36 @@ export type IPublicProductVariant = {
658
687
  payWhatYouWant?: boolean;
659
688
  payWhatYouWantMaximum?: number;
660
689
  upcCode: string;
690
+ /**
691
+ * Where this variant sits on its product's axes, in the axes' own order.
692
+ *
693
+ * This is what the picker reads. `color` and `size` below are the two
694
+ * hardcoded axes that predate the option library — they cannot express a
695
+ * third ("Format: MP3 / WAV / Stems"), and one product's `color` may hold a
696
+ * hex while another's holds a name, because three different writers filled
697
+ * it. Prefer `options`.
698
+ */
699
+ options: IPublicVariantOption[];
700
+ /** @deprecated Read `options`. */
661
701
  color: string;
702
+ /** @deprecated Read `options`. */
662
703
  size: string;
704
+ /** Whether a buyer of this version also receives the downloadable files. */
705
+ includesDownload?: boolean;
663
706
  availabilityStatus: "active" | "temporarily_out_of_stock";
664
707
  };
665
708
 
709
+ export type IPublicVariantOption = {
710
+ optionValueId: string;
711
+ optionTypeId: string;
712
+ /** "Colour", "Size", "Format" — as the creator named it. */
713
+ axis: string;
714
+ value: string;
715
+ /** Set only when the value genuinely is a colour; a guessed hex would lie. */
716
+ swatchHex: string | null;
717
+ displayType: string;
718
+ };
719
+
666
720
  // ---- Reviews -------------------------------------------------------------------
667
721
 
668
722
  /** Reviewable entity types (v1 — events deferred). */
@@ -750,8 +804,12 @@ export type IPublicProduct = {
750
804
  slug?: string;
751
805
  title: string;
752
806
  description: string;
753
- category: ProductCategory;
807
+ productType: ProductType;
754
808
  media: IMedia[];
809
+ /** Creator-authored categories this product is filed under. Direct only — a parent category resolves its descendants server-side. */
810
+ categories?: { id: string; title: string; slug: string; parentId: string | null }[];
811
+ /** Curated collections this product belongs to, with its position in each. */
812
+ collections?: { id: string; title: string; slug: string; isFeatured: boolean; position: number }[];
755
813
  variants: IPublicProductVariant[];
756
814
  artist: string;
757
815
  credits: string;
@@ -1048,11 +1106,22 @@ export type ITicket = {
1048
1106
  id: string;
1049
1107
  title: string;
1050
1108
  description: string;
1109
+ /**
1110
+ * On a pay-what-you-want tier this is the MINIMUM, not a fixed price — there
1111
+ * is no separate minimum field. Every "from $X" on the storefront reads it,
1112
+ * which is exactly why it stays the floor.
1113
+ */
1051
1114
  price: number;
1052
1115
  // Optional display-only "compare-at" price (higher than `price`). When
1053
1116
  // present the storefront strikes it through next to `price`. Charging uses
1054
- // `price`.
1117
+ // `price`. Mutually exclusive with `payWhatYouWant` — the API refuses both.
1055
1118
  compareAtPrice?: number | string | null;
1119
+ /** Buyer chooses the amount, at or above `price`. */
1120
+ payWhatYouWant?: boolean;
1121
+ /** Pre-fills the buyer's amount box. Presentation only — never a floor. */
1122
+ pwywSuggestedAmount?: number | string | null;
1123
+ /** Enforced server-side at checkout. */
1124
+ payWhatYouWantMaximum?: number | string | null;
1056
1125
  quantity: number;
1057
1126
  order: number;
1058
1127
  sold: number;
@@ -1219,7 +1288,20 @@ export type IPublicOrderItem = {
1219
1288
  quantity: number;
1220
1289
  recipientMessage?: string;
1221
1290
  payWhatYouWant: boolean;
1291
+ /**
1292
+ * What this line WAS, recorded when it was sold.
1293
+ *
1294
+ * Not resolved through `productVariantId` on read: the creator can rename an
1295
+ * option value or delete the version, and an order that re-resolves would
1296
+ * quietly start describing itself differently from how it was bought.
1297
+ *
1298
+ * Null for a product sold one way, and for every order placed before the
1299
+ * snapshot existed — those fall back to `size` below.
1300
+ */
1301
+ variantOptions?: { axis: string; value: string; swatchHex?: string | null }[] | null;
1302
+ /** @deprecated Superseded by `variantOptions`. The only record for older orders. */
1222
1303
  color: string;
1304
+ /** @deprecated Superseded by `variantOptions`. The only record for older orders. */
1223
1305
  size: string;
1224
1306
  };
1225
1307
 
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { ITicket } from "../../../types/models";
3
+ import {
4
+ clampChosenAmount,
5
+ isPayWhatYouWant,
6
+ pwywDefaultAmount,
7
+ pwywMaximum,
8
+ resolveUnitPrice,
9
+ ticketSubtotals,
10
+ } from "../pwyw";
11
+
12
+ /**
13
+ * The pay-what-you-want arithmetic shared by BOTH storefront stacks (Forge and
14
+ * the legacy client). It is shared rather than written twice precisely so the
15
+ * two cannot drift from each other or from the server — these tests pin the
16
+ * behaviour they both depend on.
17
+ *
18
+ * The clamp here is UX, not enforcement: the server clamps again and is what
19
+ * decides the charge. What matters is that the number on screen matches the
20
+ * number that will be charged.
21
+ */
22
+
23
+ const tier = (over: Partial<ITicket> = {}): ITicket => ({
24
+ id: over.id ?? "t1",
25
+ title: "GA",
26
+ description: "",
27
+ price: 20,
28
+ quantity: 100,
29
+ order: 0,
30
+ sold: 0,
31
+ maxPerPerson: 10,
32
+ ...over,
33
+ });
34
+
35
+ describe("pwyw helpers", () => {
36
+ describe("isPayWhatYouWant", () => {
37
+ it("is false when the flag is absent — every pre-feature tier", () => {
38
+ expect(isPayWhatYouWant(tier())).toBe(false);
39
+ });
40
+
41
+ it("is true only when explicitly set", () => {
42
+ expect(isPayWhatYouWant(tier({ payWhatYouWant: true }))).toBe(true);
43
+ });
44
+ });
45
+
46
+ describe("pwywMaximum", () => {
47
+ it("is null on a non-PWYW tier even if a maximum is stored", () => {
48
+ // A stale ceiling on a tier whose toggle was turned off must not start
49
+ // constraining a fixed price.
50
+ expect(pwywMaximum(tier({ payWhatYouWantMaximum: 50 }))).toBeNull();
51
+ });
52
+
53
+ it("parses a numeric string, as the API returns for decimals", () => {
54
+ expect(pwywMaximum(tier({ payWhatYouWant: true, payWhatYouWantMaximum: "50.00" }))).toBe(50);
55
+ });
56
+
57
+ it("is null for an unparseable value rather than NaN", () => {
58
+ expect(pwywMaximum(tier({ payWhatYouWant: true, payWhatYouWantMaximum: "nonsense" }))).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe("pwywDefaultAmount", () => {
63
+ it("falls back to the floor with no suggestion", () => {
64
+ expect(pwywDefaultAmount(tier({ payWhatYouWant: true }))).toBe(20);
65
+ });
66
+
67
+ it("uses the operator's suggestion", () => {
68
+ expect(pwywDefaultAmount(tier({ payWhatYouWant: true, pwywSuggestedAmount: 35 }))).toBe(35);
69
+ });
70
+
71
+ it("never pre-fills below the floor", () => {
72
+ // Otherwise the form opens with a value its own validation rejects.
73
+ expect(pwywDefaultAmount(tier({ payWhatYouWant: true, pwywSuggestedAmount: 5 }))).toBe(20);
74
+ });
75
+
76
+ it("never pre-fills above the maximum", () => {
77
+ expect(
78
+ pwywDefaultAmount(tier({ payWhatYouWant: true, pwywSuggestedAmount: 80, payWhatYouWantMaximum: 50 })),
79
+ ).toBe(50);
80
+ });
81
+ });
82
+
83
+ describe("clampChosenAmount", () => {
84
+ const t = tier({ payWhatYouWant: true });
85
+
86
+ it("keeps a figure above the floor", () => {
87
+ expect(clampChosenAmount(t, 75)).toBe(75);
88
+ });
89
+
90
+ it("raises a figure below the floor — the one-directional property", () => {
91
+ expect(clampChosenAmount(t, 1)).toBe(20);
92
+ });
93
+
94
+ it.each([
95
+ ["null", null],
96
+ ["undefined", undefined],
97
+ ["NaN", NaN],
98
+ ["negative", -50],
99
+ ["zero", 0],
100
+ ])("falls back to the floor for %s", (_label, value) => {
101
+ expect(clampChosenAmount(t, value as number | null | undefined)).toBe(20);
102
+ });
103
+
104
+ it("caps at the maximum", () => {
105
+ expect(clampChosenAmount(tier({ payWhatYouWant: true, payWhatYouWantMaximum: 50 }), 5000)).toBe(50);
106
+ });
107
+ });
108
+
109
+ describe("resolveUnitPrice", () => {
110
+ it("ignores a chosen amount on a non-PWYW tier, matching the server", () => {
111
+ expect(resolveUnitPrice(tier(), 500)).toBe(20);
112
+ expect(resolveUnitPrice(tier(), 1)).toBe(20);
113
+ });
114
+
115
+ it("honours a chosen amount on a PWYW tier", () => {
116
+ expect(resolveUnitPrice(tier({ payWhatYouWant: true }), 75)).toBe(75);
117
+ });
118
+ });
119
+
120
+ describe("ticketSubtotals", () => {
121
+ it("returns identical paid and floor figures with no PWYW tier", () => {
122
+ // The property that makes every existing surface's numbers unchanged.
123
+ const result = ticketSubtotals({
124
+ tickets: [tier({ id: "a", price: 20 }), tier({ id: "b", price: 30 })],
125
+ quantities: { a: 2, b: 1 },
126
+ });
127
+ expect(result).toEqual({ paid: 70, floor: 70 });
128
+ });
129
+
130
+ it("separates what is paid from what the fee is computed on", () => {
131
+ const result = ticketSubtotals({
132
+ tickets: [tier({ id: "a", price: 20, payWhatYouWant: true })],
133
+ quantities: { a: 2 },
134
+ amounts: { a: 75 },
135
+ });
136
+ expect(result).toEqual({ paid: 150, floor: 40 });
137
+ });
138
+
139
+ it("mixes PWYW and fixed tiers correctly", () => {
140
+ const result = ticketSubtotals({
141
+ tickets: [tier({ id: "a", price: 10, payWhatYouWant: true }), tier({ id: "b", price: 40 })],
142
+ quantities: { a: 2, b: 1 },
143
+ amounts: { a: 35 },
144
+ });
145
+ expect(result).toEqual({ paid: 110, floor: 60 });
146
+ });
147
+
148
+ it("ignores tiers with no quantity selected", () => {
149
+ const result = ticketSubtotals({
150
+ tickets: [tier({ id: "a", price: 20, payWhatYouWant: true })],
151
+ quantities: {},
152
+ amounts: { a: 500 },
153
+ });
154
+ expect(result).toEqual({ paid: 0, floor: 0 });
155
+ });
156
+ });
157
+ });
@@ -0,0 +1,95 @@
1
+ import type { ITicket } from "../../types/models";
2
+
3
+ /**
4
+ * Pay-what-you-want, as a buyer-facing surface has to handle it.
5
+ *
6
+ * ## The floor is the ticket, everything above it is a tip
7
+ *
8
+ * On a PWYW tier `ticket.price` IS the minimum — there is no separate minimum
9
+ * field — so every existing "from $X" already reads the right number. What
10
+ * changes is that the buyer may choose to pay MORE, and two totals then exist
11
+ * at once:
12
+ *
13
+ * - the **paid** subtotal, which is what the card is charged, and
14
+ * - the **floor** subtotal, which is what discounts and the artist's booking
15
+ * fee are calculated on.
16
+ *
17
+ * The server computes both the same way (`PublicEventsService.createOrder`).
18
+ * These helpers exist so a storefront cannot accidentally show a fee estimate
19
+ * derived from the paid figure and then be charged one derived from the floor.
20
+ *
21
+ * ## The client is not the guard
22
+ *
23
+ * `clampChosenAmount` is UX, not enforcement. The server reads the PWYW flag
24
+ * off the ticket row and refuses anything below `price` regardless of what a
25
+ * client sends — a buyer cannot pay less than the floor by editing anything
26
+ * here. The clamp exists so the number on screen is the number that will be
27
+ * charged, not to make the request safe.
28
+ */
29
+
30
+ const toNumber = (value: number | string | null | undefined): number | null => {
31
+ if (value == null) return null;
32
+ const parsed = typeof value === "number" ? value : Number(value);
33
+ return Number.isFinite(parsed) ? parsed : null;
34
+ };
35
+
36
+ export const isPayWhatYouWant = (ticket: ITicket): boolean => !!ticket.payWhatYouWant;
37
+
38
+ /** The tier's ceiling, or `null` for no limit. */
39
+ export const pwywMaximum = (ticket: ITicket): number | null =>
40
+ isPayWhatYouWant(ticket) ? toNumber(ticket.payWhatYouWantMaximum) : null;
41
+
42
+ /**
43
+ * What the amount box should start at: the operator's suggestion when there is
44
+ * one, else the floor. Never below the floor — a suggestion under the minimum
45
+ * would pre-fill the form with a value the buyer is not allowed to pay.
46
+ */
47
+ export const pwywDefaultAmount = (ticket: ITicket): number => {
48
+ const floor = ticket.price;
49
+ const suggested = toNumber(ticket.pwywSuggestedAmount);
50
+ if (suggested == null || suggested < floor) return floor;
51
+ const maximum = pwywMaximum(ticket);
52
+ return maximum != null ? Math.min(suggested, maximum) : suggested;
53
+ };
54
+
55
+ /** Pin a buyer-entered figure into [floor, maximum]. Mirrors the server clamp. */
56
+ export const clampChosenAmount = (ticket: ITicket, chosen: number | null | undefined): number => {
57
+ const floor = ticket.price;
58
+ if (chosen == null || !Number.isFinite(chosen) || chosen < floor) return floor;
59
+ const maximum = pwywMaximum(ticket);
60
+ return maximum != null ? Math.min(chosen, maximum) : chosen;
61
+ };
62
+
63
+ /**
64
+ * The per-unit price this tier will actually be charged at.
65
+ *
66
+ * A non-PWYW tier ignores `chosen` entirely, exactly as the server does — so a
67
+ * stale amount left in state after the operator turns PWYW off cannot change
68
+ * what is shown.
69
+ */
70
+ export const resolveUnitPrice = (ticket: ITicket, chosen: number | null | undefined): number =>
71
+ isPayWhatYouWant(ticket) ? clampChosenAmount(ticket, chosen) : ticket.price;
72
+
73
+ /**
74
+ * Both subtotals for a selection, in one pass.
75
+ *
76
+ * `paid` is what the buyer owes for the tickets; `floor` is the base the
77
+ * booking-fee estimate and any percentage discount must be computed against.
78
+ * They are equal on any cart with no PWYW tier, which is why every existing
79
+ * surface keeps its current numbers untouched.
80
+ */
81
+ export function ticketSubtotals(input: {
82
+ tickets: ITicket[];
83
+ quantities: Record<string, number>;
84
+ amounts?: Record<string, number>;
85
+ }): { paid: number; floor: number } {
86
+ let paid = 0;
87
+ let floor = 0;
88
+ for (const ticket of input.tickets) {
89
+ const quantity = input.quantities[ticket.id] ?? 0;
90
+ if (quantity <= 0) continue;
91
+ paid += resolveUnitPrice(ticket, input.amounts?.[ticket.id]) * quantity;
92
+ floor += ticket.price * quantity;
93
+ }
94
+ return { paid, floor };
95
+ }