@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 +2 -2
- package/src/contexts/CartContext.tsx +38 -2
- package/src/data/queries/useCheckouts.ts +14 -0
- package/src/data/queries/useEvents.ts +6 -0
- package/src/data/queries/useProducts.ts +104 -10
- package/src/index.ts +8 -0
- package/src/types/models.ts +87 -5
- package/src/ui/format/_tests/pwyw.spec.ts +157 -0
- package/src/ui/format/pwyw.ts +95 -0
- package/src/ui/headless/event/useEventCheckout.ts +96 -6
- package/src/ui/headless/index.ts +1 -0
- package/src/ui/headless/useVariantSelection.ts +138 -0
- package/src/ui/index.ts +17 -0
- package/src/ui/styled/AccountDashboard.tsx +7 -0
- package/src/ui/styled/Cart.tsx +11 -8
- package/src/ui/styled/CartLineOptions.tsx +107 -0
- package/src/ui/styled/Checkout.tsx +5 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +4 -6
- package/src/ui/styled/EventTickets.tsx +114 -0
- package/src/ui/styled/ProductBrowseNav.tsx +212 -0
- package/src/ui/styled/ProductDetail.tsx +158 -91
- package/src/ui/styled/ProductGrid.tsx +16 -2
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribe-nest/forge",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.17.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"description": "Forge
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
13
|
-
export function useFeaturedProducts(
|
|
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,
|
|
33
|
+
queryKey: ["featured-products", profileId, productType],
|
|
18
34
|
queryFn: async () => {
|
|
19
35
|
const res = await client.get("/public/products/featured", {
|
|
20
|
-
params: { profileId,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
97
|
-
queryKey: ["product-
|
|
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,
|
package/src/types/models.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|