@tribe-nest/forge 3.54.0 → 3.58.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 (102) hide show
  1. package/package.json +4 -2
  2. package/src/_tests/workspaceAliases.ts +1 -0
  3. package/src/contexts/CartContext.tsx +88 -5
  4. package/src/data/queries/useCallBreakouts.ts +112 -0
  5. package/src/data/queries/useCheckouts.ts +15 -2
  6. package/src/data/queries/useCoachingProducts.ts +15 -1
  7. package/src/data/queries/useCourses.ts +15 -1
  8. package/src/data/queries/useEvents.ts +80 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyBookings.ts +16 -0
  11. package/src/data/queries/useNotifications.ts +1 -1
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +153 -0
  15. package/src/i18n/en.json +153 -0
  16. package/src/index.ts +1 -0
  17. package/src/server/_tests/buildPwaHead.spec.ts +1 -1
  18. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  19. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
  20. package/src/server/index.ts +75 -1
  21. package/src/server/platformEvents.generated.ts +60 -0
  22. package/src/server/pwa.ts +1 -1
  23. package/src/types/models.ts +137 -0
  24. package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
  25. package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
  26. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
  27. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
  28. package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
  29. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
  30. package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
  31. package/src/ui/headless/checkout/seatConflict.ts +117 -0
  32. package/src/ui/headless/checkout/useCheckout.ts +178 -55
  33. package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
  34. package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
  35. package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
  36. package/src/ui/headless/event/useEventCheckout.ts +362 -22
  37. package/src/ui/headless/event/useEventSeating.ts +233 -0
  38. package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
  39. package/src/ui/headless/forms/useEmailListForm.ts +37 -6
  40. package/src/ui/headless/forms/usePhoneInput.ts +294 -0
  41. package/src/ui/headless/forms/useSectionedForm.ts +2 -2
  42. package/src/ui/headless/index.ts +48 -1
  43. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  44. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  45. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  49. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  50. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  51. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  52. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  53. package/src/ui/index.ts +16 -0
  54. package/src/ui/media/CallStage.tsx +107 -10
  55. package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
  56. package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
  57. package/src/ui/media/_tests/callState.spec.ts +103 -1
  58. package/src/ui/media/callBreakouts.ts +57 -0
  59. package/src/ui/media/callState.ts +83 -23
  60. package/src/ui/media/index.ts +11 -0
  61. package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
  62. package/src/ui/media/panels/CallSidePanel.tsx +37 -4
  63. package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
  64. package/src/ui/media/stage/CallControlsBar.tsx +46 -0
  65. package/src/ui/media/stage/CallTopBar.tsx +1 -1
  66. package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
  67. package/src/ui/payment/ForgeStripePayment.tsx +78 -7
  68. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  69. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  70. package/src/ui/payment/stripeIntentKind.ts +32 -0
  71. package/src/ui/shell/TribeNestApp.tsx +1 -1
  72. package/src/ui/styled/AccountDashboard.tsx +165 -2
  73. package/src/ui/styled/BundleConfirmation.tsx +47 -2
  74. package/src/ui/styled/Cart.tsx +211 -135
  75. package/src/ui/styled/Checkout.tsx +163 -32
  76. package/src/ui/styled/EmailListForm.tsx +7 -9
  77. package/src/ui/styled/EventTickets.tsx +143 -4
  78. package/src/ui/styled/LoginForm.tsx +5 -3
  79. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  80. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  81. package/src/ui/styled/MembershipTiers.tsx +148 -6
  82. package/src/ui/styled/PhoneInput.tsx +127 -0
  83. package/src/ui/styled/ProductGrid.tsx +14 -2
  84. package/src/ui/styled/SignupForm.tsx +4 -2
  85. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
  86. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  87. package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
  88. package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
  89. package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
  90. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  91. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  92. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  93. package/src/ui/styled/forge-utilities.css +67 -0
  94. package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
  95. package/src/ui/styled/seating/SeatLegend.tsx +73 -0
  96. package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
  97. package/src/ui/styled/seating/paint.ts +79 -0
  98. package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
  99. package/src/ui/styled/seating/viewport.ts +86 -0
  100. package/src/utils/_tests/engagementTracker.spec.ts +111 -0
  101. package/src/utils/engagementTracker.ts +73 -0
  102. package/src/utils/landing.ts +1 -1
@@ -0,0 +1,233 @@
1
+ import { useMemo } from "react";
2
+ import { flattenSeats, type SeatMapDocument } from "@tribe-nest/seating-core";
3
+ import { useEventSeatingQuery, type PublicEventSeat, type PublicEventSeating } from "../../../data/queries/useEvents";
4
+
5
+ /**
6
+ * Reserved seating on the buyer surface (seating wave 2, docs §11.6).
7
+ *
8
+ * This hook is the ONE join between the two halves of the seating read: the
9
+ * frozen layout (where a seat is drawn, what it is called) and the live seat
10
+ * list (which event seat it is, which tier prices it, whether it is free). The
11
+ * picker draws off the layout and toggles off the seat list, and every label a
12
+ * buyer reads ("Stalls · Row A · Seat 12") is built here, once, so the alert
13
+ * that names a seat somebody else took and the chip under the tier row can
14
+ * never disagree about what a seat is called.
15
+ */
16
+
17
+ /** A public event seat joined to its place in the layout. */
18
+ export interface EventSeat extends PublicEventSeat {
19
+ /** The section or table the seat belongs to, in the layout. */
20
+ objectId: string;
21
+ objectType: "section" | "table";
22
+ /** Position of the parent in the document's z-order: the "section order" of best-available. */
23
+ objectOrder: number;
24
+ sectionName: string;
25
+ /** Empty for a table seat. */
26
+ rowLabel: string;
27
+ seatLabel: string;
28
+ rowOrder: number;
29
+ seatOrder: number;
30
+ /** "Stalls · Row A · Seat 12", or "Table 4 · Seat 2". What every surface prints. */
31
+ label: string;
32
+ }
33
+
34
+ export interface EventSeatingState {
35
+ /** `undefined` while loading, `null` when the event has no seat map. */
36
+ seating: PublicEventSeating | null | undefined;
37
+ isLoading: boolean;
38
+ /** True once a read has completed, whatever it said. Selection rules wait for this. */
39
+ isFetched: boolean;
40
+ refetch: () => Promise<unknown>;
41
+ /** By EVENT seat id (what an order names). */
42
+ byId: Map<string, EventSeat>;
43
+ /** By the seat's id inside the layout (what a `<circle>` carries). */
44
+ bySource: Map<string, EventSeat>;
45
+ /** Seats that read `available`, per ticket tier. Tiers with no seat are absent. */
46
+ availableByTier: Record<string, number>;
47
+ /** Tiers that sell reserved seats on this event: the ones an order must carry `seats` for. */
48
+ seatedTierIds: Set<string>;
49
+ /** Every seat, in layout order (section, row, seat). */
50
+ seats: EventSeat[];
51
+ }
52
+
53
+ export interface UseEventSeatingOptions {
54
+ /** Fetch at all. Default true. */
55
+ enabled?: boolean;
56
+ /** Poll interval while enabled. Default 5000; `0`/`false` polls nothing. */
57
+ pollMs?: number | false;
58
+ }
59
+
60
+ const EMPTY_SEATS: EventSeat[] = [];
61
+
62
+ /** The buyer-facing name of a seat. Table seats have no row. */
63
+ export const seatLabel = (parts: { sectionName: string; rowLabel: string; seatLabel: string }): string =>
64
+ parts.rowLabel
65
+ ? `${parts.sectionName} · Row ${parts.rowLabel} · Seat ${parts.seatLabel}`
66
+ : `${parts.sectionName} · Seat ${parts.seatLabel}`;
67
+
68
+ /**
69
+ * Join the seat list to the layout. Pure, so the picker's tests and the
70
+ * checkout's tests can build the same index off a fixture.
71
+ *
72
+ * A seat the layout does not know (it cannot happen on a bound map, but the
73
+ * two halves are separate rows) still gets an entry, labelled by its id, so an
74
+ * order can name it and an alert can still say something.
75
+ */
76
+ export function indexEventSeats(seating: PublicEventSeating | null | undefined): {
77
+ seats: EventSeat[];
78
+ byId: Map<string, EventSeat>;
79
+ bySource: Map<string, EventSeat>;
80
+ } {
81
+ const byId = new Map<string, EventSeat>();
82
+ const bySource = new Map<string, EventSeat>();
83
+ if (!seating) return { seats: EMPTY_SEATS, byId, bySource };
84
+
85
+ const flat = new Map(flattenSeats(seating.layout as SeatMapDocument).map((seat) => [seat.seatId, seat]));
86
+ const objectOrder = new Map<string, number>();
87
+ seating.layout.objects.forEach((object, index) => objectOrder.set(object.id, index));
88
+
89
+ const seats: EventSeat[] = seating.seats.map((seat) => {
90
+ const place = flat.get(seat.sourceSeatId);
91
+ if (!place) {
92
+ return {
93
+ ...seat,
94
+ objectId: "",
95
+ objectType: "section",
96
+ objectOrder: Number.MAX_SAFE_INTEGER,
97
+ sectionName: "",
98
+ rowLabel: "",
99
+ seatLabel: seat.sourceSeatId,
100
+ rowOrder: Number.MAX_SAFE_INTEGER,
101
+ seatOrder: Number.MAX_SAFE_INTEGER,
102
+ label: `Seat ${seat.sourceSeatId}`,
103
+ };
104
+ }
105
+ return {
106
+ ...seat,
107
+ objectId: place.objectId,
108
+ objectType: place.objectType,
109
+ objectOrder: objectOrder.get(place.objectId) ?? Number.MAX_SAFE_INTEGER,
110
+ sectionName: place.sectionName,
111
+ rowLabel: place.rowLabel,
112
+ seatLabel: place.seatLabel,
113
+ rowOrder: place.rowOrder,
114
+ seatOrder: place.seatOrder,
115
+ label: seatLabel(place),
116
+ };
117
+ });
118
+ seats.sort(compareSeats);
119
+ for (const seat of seats) {
120
+ byId.set(seat.id, seat);
121
+ bySource.set(seat.sourceSeatId, seat);
122
+ }
123
+ return { seats, byId, bySource };
124
+ }
125
+
126
+ /** Section (z-order), then row, then seat: the "lowest" a best-available pick means. */
127
+ export const compareSeats = (a: EventSeat, b: EventSeat): number =>
128
+ a.objectOrder - b.objectOrder || a.rowOrder - b.rowOrder || a.seatOrder - b.seatOrder;
129
+
130
+ /**
131
+ * The best `count` available seats of a tier.
132
+ *
133
+ * The lowest contiguous run in the lowest row first: a party of three wants
134
+ * three seats side by side more than it wants the three lowest numbers in the
135
+ * room. A run means consecutive `seatOrder` inside ONE row, and an unavailable
136
+ * seat breaks it, so "A1, A2, A4" is never offered as a run. When no row has a
137
+ * free run that long, the lowest `count` seats are returned instead. Fewer
138
+ * than `count` available: whatever is left, and the caller decides what that
139
+ * means.
140
+ *
141
+ * `exclude` is the buyer's current selection, so "+" adds seats rather than
142
+ * re-offering the ones already chosen. `near` prefers, for a single seat, one
143
+ * adjacent to a seat already held in the same row, which is what makes the
144
+ * stepper build a party rather than scatter it.
145
+ */
146
+ export function bestAvailableSeats(
147
+ seats: readonly EventSeat[],
148
+ ticketId: string,
149
+ count: number,
150
+ exclude: ReadonlySet<string> = new Set(),
151
+ near: readonly EventSeat[] = [],
152
+ ): EventSeat[] {
153
+ if (count <= 0) return [];
154
+ const candidates = seats
155
+ .filter((seat) => seat.eventTicketId === ticketId && seat.status === "available" && !exclude.has(seat.id))
156
+ .sort(compareSeats);
157
+ if (candidates.length === 0) return [];
158
+
159
+ if (count === 1 && near.length > 0) {
160
+ for (const held of [...near].sort(compareSeats)) {
161
+ const neighbour = candidates.find(
162
+ (seat) =>
163
+ seat.objectId === held.objectId &&
164
+ seat.rowOrder === held.rowOrder &&
165
+ Math.abs(seat.seatOrder - held.seatOrder) === 1,
166
+ );
167
+ if (neighbour) return [neighbour];
168
+ }
169
+ }
170
+
171
+ // Rows in order; the first row holding a run of `count` wins.
172
+ const rows = new Map<string, EventSeat[]>();
173
+ for (const seat of candidates) {
174
+ const key = `${seat.objectId}\u0000${seat.rowOrder}`;
175
+ const row = rows.get(key);
176
+ if (row) row.push(seat);
177
+ else rows.set(key, [seat]);
178
+ }
179
+ for (const row of rows.values()) {
180
+ for (let i = 0; i + count <= row.length; i += 1) {
181
+ const first = row[i]!;
182
+ const last = row[i + count - 1]!;
183
+ if (last.seatOrder - first.seatOrder === count - 1) return row.slice(i, i + count);
184
+ }
185
+ }
186
+ return candidates.slice(0, count);
187
+ }
188
+
189
+ /**
190
+ * The seat map and live availability for one event, joined and indexed.
191
+ *
192
+ * Polls every `pollMs` while `enabled`: the picker passes `enabled` only while
193
+ * it is on screen, so a fan reading the event page costs the API nothing, and
194
+ * a fan choosing seats sees a seat somebody else took within five seconds.
195
+ */
196
+ export function useEventSeating(eventId?: string, opts: UseEventSeatingOptions = {}): EventSeatingState {
197
+ const { enabled = true, pollMs = 5000 } = opts;
198
+ const query = useEventSeatingQuery(eventId, { enabled, pollMs });
199
+ const seating = query.data;
200
+
201
+ const index = useMemo(() => indexEventSeats(seating), [seating]);
202
+
203
+ const availableByTier = useMemo(() => {
204
+ const out: Record<string, number> = {};
205
+ for (const seat of index.seats) {
206
+ if (!(seat.eventTicketId in out)) out[seat.eventTicketId] = 0;
207
+ if (seat.status === "available") out[seat.eventTicketId] += 1;
208
+ }
209
+ return out;
210
+ }, [index.seats]);
211
+
212
+ // A tier is seated when it HAS seats on this event, which is the server's
213
+ // own definition (§11.2). `tierMapping` is deliberately not consulted: a
214
+ // category mapped to a tier through a general admission AREA puts no seat
215
+ // on the map, and that tier keeps its plain quantity.
216
+ const seatedTierIds = useMemo(() => {
217
+ const ids = new Set<string>();
218
+ for (const seat of index.seats) ids.add(seat.eventTicketId);
219
+ return ids;
220
+ }, [index.seats]);
221
+
222
+ return {
223
+ seating,
224
+ isLoading: enabled && query.isLoading,
225
+ isFetched: query.isFetched,
226
+ refetch: query.refetch,
227
+ byId: index.byId,
228
+ bySource: index.bySource,
229
+ availableByTier,
230
+ seatedTierIds,
231
+ seats: index.seats,
232
+ };
233
+ }
@@ -0,0 +1,187 @@
1
+ // @vitest-environment jsdom
2
+ // Hooks with effects need a DOM; the runner defaults to node and opts in per
3
+ // file, per the note in vitest.config.ts.
4
+ import { describe, it, expect } from "vitest";
5
+ import { renderHook, act, waitFor } from "@testing-library/react";
6
+ import { usePhoneInput } from "../usePhoneInput";
7
+
8
+ /**
9
+ * The contract these pin is narrow and load-bearing: whatever a visitor types,
10
+ * what leaves this hook is either strict E.164 or null.
11
+ *
12
+ * `services/admin/consent/phone.ts` keys suppression by number and deliberately
13
+ * refuses to guess a country for a bare national string, so a number stored
14
+ * without its country never matches the E.164 one a carrier sends on a STOP.
15
+ * These are the cases where that used to happen.
16
+ *
17
+ * On the fixtures: UK numbers here are from ASSIGNABLE ranges. Ofcom reserves
18
+ * 07700 900xxx and 020 7946 0xxx for drama, and libphonenumber correctly calls
19
+ * those invalid, so a spec written with them fails for a reason that has
20
+ * nothing to do with this hook.
21
+ */
22
+ const ready = async (hook: { result: { current: { isReady: boolean } } }) =>
23
+ waitFor(() => expect(hook.result.current.isReady).toBe(true));
24
+
25
+ describe("usePhoneInput", () => {
26
+ it("turns a national number under a chosen country into E.164", async () => {
27
+ const hook = renderHook(() => usePhoneInput({ defaultCountry: "GB" }));
28
+ await ready(hook);
29
+
30
+ act(() => hook.result.current.setValue("07400123456"));
31
+
32
+ expect(hook.result.current.e164).toBe("+447400123456");
33
+ expect(hook.result.current.isValid).toBe(true);
34
+ });
35
+
36
+ it("drops the trunk prefix, which is the whole reason a bare national number never matched", async () => {
37
+ const hook = renderHook(() => usePhoneInput({ defaultCountry: "GB" }));
38
+ await ready(hook);
39
+
40
+ act(() => hook.result.current.setValue("07400 123456"));
41
+
42
+ // The leading 0 is a national dialling convention, not part of the number.
43
+ // Stored verbatim it would never equal the +44… a carrier reports on STOP.
44
+ expect(hook.result.current.e164).toBe("+447400123456");
45
+ expect(hook.result.current.e164).not.toContain("+4407");
46
+ });
47
+
48
+ it("keeps a pasted international number intact and moves the flag to match", async () => {
49
+ // The field says "without the country code" and a large share of people
50
+ // paste the full number anyway. Formatted as a GB national string that
51
+ // would come out mangled.
52
+ const hook = renderHook(() => usePhoneInput({ defaultCountry: "GB" }));
53
+ await ready(hook);
54
+
55
+ act(() => hook.result.current.setValue("+12015550123"));
56
+
57
+ expect(hook.result.current.e164).toBe("+12015550123");
58
+ expect(hook.result.current.country).toBe("US");
59
+ });
60
+
61
+ it("re-reads the same digits under a corrected country instead of clearing them", async () => {
62
+ const hook = renderHook(() => usePhoneInput({ defaultCountry: "GB" }));
63
+ await ready(hook);
64
+
65
+ act(() => hook.result.current.setValue("2015550123"));
66
+ act(() => hook.result.current.setCountry("US"));
67
+
68
+ // Someone who typed first and fixed the flag afterwards should not have to
69
+ // retype, and the answer must follow the new country rather than the old.
70
+ expect(hook.result.current.e164).toBe("+12015550123");
71
+ });
72
+
73
+ it("emits null rather than a half-number while the input is incomplete", async () => {
74
+ const hook = renderHook(() => usePhoneInput({ defaultCountry: "GB" }));
75
+ await ready(hook);
76
+
77
+ act(() => hook.result.current.setValue("0740"));
78
+
79
+ expect(hook.result.current.e164).toBeNull();
80
+ expect(hook.result.current.isValid).toBe(false);
81
+ });
82
+
83
+ it("rejects a number that is not valid for the country selected", async () => {
84
+ const hook = renderHook(() => usePhoneInput({ defaultCountry: "GB" }));
85
+ await ready(hook);
86
+
87
+ act(() => hook.result.current.setValue("1111111"));
88
+ act(() => hook.result.current.markTouched());
89
+
90
+ expect(hook.result.current.e164).toBeNull();
91
+ expect(hook.result.current.error).toBe("invalid");
92
+ });
93
+
94
+ it("asks for a country before it will judge a bare national number", async () => {
95
+ const hook = renderHook(() => usePhoneInput({}));
96
+ await ready(hook);
97
+
98
+ act(() => hook.result.current.setValue("7700900123"));
99
+ act(() => hook.result.current.markTouched());
100
+
101
+ // Guessing a country here is exactly the mistake the consent normaliser
102
+ // refuses to make, so the hook asks instead.
103
+ expect(hook.result.current.error).toBe("no_country");
104
+ expect(hook.result.current.e164).toBeNull();
105
+ });
106
+
107
+ it("stays quiet until the field has been blurred", async () => {
108
+ const hook = renderHook(() => usePhoneInput({ defaultCountry: "GB" }));
109
+ await ready(hook);
110
+
111
+ act(() => hook.result.current.setValue("07"));
112
+
113
+ // Shouting "invalid" at someone two characters into typing is noise.
114
+ expect(hook.result.current.error).toBeNull();
115
+ });
116
+
117
+ it("flags a landline for messaging but still yields the number", async () => {
118
+ const hook = renderHook(() => usePhoneInput({ defaultCountry: "GB", mode: "messaging" }));
119
+ await ready(hook);
120
+
121
+ act(() => hook.result.current.setValue("02072193000"));
122
+
123
+ // A warning, never a block: line type comes from a numbering plan that many
124
+ // countries do not partition cleanly, so refusing on it would reject real
125
+ // numbers.
126
+ expect(hook.result.current.isValid).toBe(true);
127
+ expect(hook.result.current.e164).toBe("+442072193000");
128
+ expect(hook.result.current.warning).toBe("landline");
129
+ });
130
+
131
+ it("says nothing about line type when the number is for a courier", async () => {
132
+ const hook = renderHook(() => usePhoneInput({ defaultCountry: "GB", mode: "shipping" }));
133
+ await ready(hook);
134
+
135
+ act(() => hook.result.current.setValue("02072193000"));
136
+
137
+ expect(hook.result.current.isValid).toBe(true);
138
+ expect(hook.result.current.warning).toBeNull();
139
+ });
140
+
141
+ it("adopts a default country that only resolves after the first render", async () => {
142
+ // Checkout learns the shipping country after the address is filled.
143
+ const hook = renderHook(({ c }) => usePhoneInput({ defaultCountry: c }), {
144
+ initialProps: { c: undefined as "GB" | undefined },
145
+ });
146
+ await ready(hook);
147
+ expect(hook.result.current.country).toBeUndefined();
148
+
149
+ hook.rerender({ c: "GB" });
150
+
151
+ expect(hook.result.current.country).toBe("GB");
152
+ });
153
+
154
+ it("does not overrule a country the visitor chose themselves", async () => {
155
+ const hook = renderHook(({ c }) => usePhoneInput({ defaultCountry: c }), {
156
+ initialProps: { c: undefined as "GB" | undefined },
157
+ });
158
+ await ready(hook);
159
+
160
+ act(() => hook.result.current.setCountry("NG"));
161
+ hook.rerender({ c: "GB" });
162
+
163
+ expect(hook.result.current.country).toBe("NG");
164
+ });
165
+
166
+ it("offers a national example for the chosen country as the placeholder", async () => {
167
+ const hook = renderHook(() => usePhoneInput({ defaultCountry: "GB" }));
168
+ await ready(hook);
169
+
170
+ // Teaching the shape beats a sentence under the field, and it has to be the
171
+ // national form because that is what we are asking for.
172
+ expect(hook.result.current.placeholder).toBeTruthy();
173
+ expect(hook.result.current.placeholder).not.toContain("+44");
174
+ });
175
+
176
+ it("reports the caller's changes as E.164 or null, never as raw text", async () => {
177
+ const seen: (string | null)[] = [];
178
+ const hook = renderHook(() => usePhoneInput({ defaultCountry: "GB", onChange: (v) => seen.push(v) }));
179
+ await ready(hook);
180
+
181
+ act(() => hook.result.current.setValue("0740"));
182
+ act(() => hook.result.current.setValue("07400123456"));
183
+
184
+ expect(seen).toContain("+447400123456");
185
+ expect(seen.every((v) => v === null || v.startsWith("+"))).toBe(true);
186
+ });
187
+ });
@@ -1,5 +1,7 @@
1
1
  import { useState } from "react";
2
+ import type { CountryCode } from "libphonenumber-js";
2
3
  import { useJoinEmailList } from "../../../data/queries/useLeadMagnet";
4
+ import { usePhoneInput } from "./usePhoneInput";
3
5
 
4
6
  export type FormStatus = "idle" | "submitting" | "success" | "error";
5
7
 
@@ -8,10 +10,16 @@ export interface UseEmailListFormOptions {
8
10
  leadMagnetId?: string;
9
11
  includeName?: boolean;
10
12
  includePhoneNumber?: boolean;
13
+ /**
14
+ * Seeds the country select. Pass the most confident source the site has (the
15
+ * visitor's geo, the creator's own country). Left undefined the visitor picks
16
+ * for themselves, which is better than a guess: an unasked-for default is how
17
+ * a number ends up stored under the wrong country.
18
+ */
19
+ defaultCountry?: CountryCode;
11
20
  }
12
21
 
13
22
  const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
14
- const phoneRe = /^\+?[0-9\s\-()]{7,20}$/;
15
23
 
16
24
  /**
17
25
  * Headless email-list-join behavior: form state + validation + the join
@@ -21,7 +29,20 @@ export function useEmailListForm(opts: UseEmailListFormOptions = {}) {
21
29
  const join = useJoinEmailList();
22
30
  const [email, setEmail] = useState("");
23
31
  const [name, setName] = useState("");
24
- const [phoneNumber, setPhoneNumber] = useState("");
32
+ /**
33
+ * The phone field submits E.164, never what was typed.
34
+ *
35
+ * The regex this replaced accepted `0000000` and any bracket soup, and a
36
+ * national number stored verbatim can never match the E.164 a carrier sends
37
+ * on a STOP, so the suppression list misses and we keep texting someone who
38
+ * asked us not to. `usePhoneInput` asks for the country instead of guessing
39
+ * it, which is the only point in the flow where it can be known.
40
+ */
41
+ const phone = usePhoneInput({
42
+ enabled: !!opts.includePhoneNumber,
43
+ defaultCountry: opts.defaultCountry,
44
+ mode: "messaging",
45
+ });
25
46
  const [status, setStatus] = useState<FormStatus>("idle");
26
47
  const [error, setError] = useState<string | null>(null);
27
48
  const [requiresConfirmation, setRequiresConfirmation] = useState(false);
@@ -36,7 +57,8 @@ export function useEmailListForm(opts: UseEmailListFormOptions = {}) {
36
57
  setError("Please enter your name");
37
58
  return;
38
59
  }
39
- if (opts.includePhoneNumber && (!phoneNumber.trim() || !phoneRe.test(phoneNumber.trim()))) {
60
+ if (opts.includePhoneNumber && !phone.e164) {
61
+ phone.markTouched();
40
62
  setError("Please enter a valid phone number");
41
63
  return;
42
64
  }
@@ -47,7 +69,7 @@ export function useEmailListForm(opts: UseEmailListFormOptions = {}) {
47
69
  emailListId: opts.emailListId,
48
70
  leadMagnetId: opts.leadMagnetId,
49
71
  firstName: opts.includeName ? name.trim() : undefined,
50
- phoneNumber: opts.includePhoneNumber ? phoneNumber.trim() : undefined,
72
+ phoneNumber: opts.includePhoneNumber ? (phone.e164 ?? undefined) : undefined,
51
73
  });
52
74
  setRequiresConfirmation(!!data?.requiresConfirmation);
53
75
  setStatus("success");
@@ -63,8 +85,17 @@ export function useEmailListForm(opts: UseEmailListFormOptions = {}) {
63
85
  setEmail,
64
86
  name,
65
87
  setName,
66
- phoneNumber,
67
- setPhoneNumber,
88
+ /** The phone field's controller. Hand it to <PhoneInput controller={...} />. */
89
+ phone,
90
+ /** E.164 or empty. Kept for callers that only want the value. */
91
+ phoneNumber: phone.e164 ?? "",
92
+ /**
93
+ * @deprecated Use `phone.setValue`, or hand `phone` to <PhoneInput>.
94
+ * Kept so this release stays a minor: it forwards into the same formatter,
95
+ * so a caller still on it gets the country handling for free rather than
96
+ * writing an unvalidated string.
97
+ */
98
+ setPhoneNumber: phone.setValue,
68
99
  status,
69
100
  error,
70
101
  requiresConfirmation,