@tribe-nest/forge 3.24.0 → 3.26.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.
@@ -16,6 +16,7 @@ import {
16
16
  ticketSeatState,
17
17
  } from "../format/ticketAvailability";
18
18
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
19
+ import { usePageActions } from "../../data/queries/usePageActions";
19
20
  import { Loading } from "./Loading";
20
21
  import { PaystackPayButton } from "./PaystackPayButton";
21
22
  import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
@@ -80,6 +81,20 @@ export function EventTickets({
80
81
  const c = useEventCheckout(slug, { finalisePath, onComplete });
81
82
  const renderPay = renderPayment ?? registered ?? undefined;
82
83
 
84
+ // Does this page sell add-ons for this event? Same query key as the page's
85
+ // own <PageActions> mount, so this is a cache read, not a second request.
86
+ // When add-ons exist, the PRIMARY exit from ticket selection is the cart
87
+ // (that's what unlocks the add-on grid); direct payment stays the happy
88
+ // path only on pages without add-ons.
89
+ const { data: pageActionsData } = usePageActions("event_detail", c.event?.id);
90
+ const hasAddons = useMemo(
91
+ () =>
92
+ (pageActionsData?.actions ?? []).some(
93
+ (action) => action.type === "product_cards" && Boolean((action.config as Record<string, unknown>)?.isAddon),
94
+ ),
95
+ [pageActionsData],
96
+ );
97
+
83
98
  const [open, setOpen] = useState(false);
84
99
 
85
100
  const tryClose = (next: boolean) => {
@@ -133,9 +148,20 @@ export function EventTickets({
133
148
  renderPayment={renderPay}
134
149
  loginPath={loginPath}
135
150
  membershipPath={membershipPath}
136
- // Selection is safe in the cart now — close so the buyer can go
137
- // browse the add-ons.
138
- onAddedToCart={() => setOpen(false)}
151
+ hasAddons={hasAddons}
152
+ // Selection is safe in the cart now: close, and bring the
153
+ // freshly unlocked add-on grid into view so the buyer lands on
154
+ // the next step instead of wherever they left the page.
155
+ onAddedToCart={() => {
156
+ setOpen(false);
157
+ if (typeof document !== "undefined") {
158
+ setTimeout(() => {
159
+ document
160
+ .querySelector('[data-testid="addons-block"]')
161
+ ?.scrollIntoView({ behavior: "smooth", block: "center" });
162
+ }, 100);
163
+ }
164
+ }}
139
165
  />
140
166
  )}
141
167
  </DialogContent>
@@ -152,6 +178,7 @@ function EventTicketsBody({
152
178
  fmt,
153
179
  renderPayment,
154
180
  onAddedToCart,
181
+ hasAddons,
155
182
  loginPath,
156
183
  membershipPath,
157
184
  }: {
@@ -160,6 +187,7 @@ function EventTicketsBody({
160
187
  fmt: (n: number) => string;
161
188
  renderPayment?: (props: PaymentRenderProps) => ReactNode;
162
189
  onAddedToCart?: () => void;
190
+ hasAddons?: boolean;
163
191
  loginPath?: string;
164
192
  membershipPath?: string;
165
193
  }) {
@@ -173,6 +201,7 @@ function EventTicketsBody({
173
201
  event={event}
174
202
  fmt={fmt}
175
203
  onAddedToCart={onAddedToCart}
204
+ hasAddons={hasAddons}
176
205
  loginPath={loginPath}
177
206
  membershipPath={membershipPath}
178
207
  />
@@ -232,6 +261,7 @@ function TicketStep({
232
261
  event,
233
262
  fmt,
234
263
  onAddedToCart,
264
+ hasAddons,
235
265
  loginPath,
236
266
  membershipPath,
237
267
  }: {
@@ -239,6 +269,7 @@ function TicketStep({
239
269
  event: IEvent;
240
270
  fmt: (n: number) => string;
241
271
  onAddedToCart?: () => void;
272
+ hasAddons?: boolean;
242
273
  loginPath?: string;
243
274
  membershipPath?: string;
244
275
  }) {
@@ -533,21 +564,37 @@ function TicketStep({
533
564
  </div>
534
565
 
535
566
  {c.error && <p style={{ color: "#ef4444", fontSize: 14, marginTop: 12 }}>{c.error}</p>}
536
- <ActionBar
537
- c={c}
538
- fmt={fmt}
539
- onNext={c.goToDetails}
540
- nextLabel="Continue"
541
- // Buying tickets alone still goes straight down the untouched
542
- // per-event flow; this exit exists so the buyer can go add merch and
543
- // come back with the selection intact.
544
- secondary={{
545
- label: c.ticketsInCart ? "Update cart" : "Add to cart",
546
- onClick: () => {
567
+ {/* Two shapes of this bar. With add-ons on the page, the PRIMARY action
568
+ is the cart: that is the only path that unlocks the add-on grid, so
569
+ it has to be the happy path, with a direct tickets-only checkout as
570
+ the secondary exit. Without add-ons the original flow is untouched:
571
+ Continue goes straight to details, the cart is the secondary exit. */}
572
+ {hasAddons ? (
573
+ <ActionBar
574
+ c={c}
575
+ fmt={fmt}
576
+ onNext={() => {
547
577
  if (c.addTicketsToCart()) onAddedToCart?.();
548
- },
549
- }}
550
- />
578
+ }}
579
+ nextLabel={c.ticketsInCart ? "Update cart & choose add-ons" : "Continue to add-ons"}
580
+ nextTestId="tickets-add-to-cart"
581
+ secondary={{ label: "Checkout tickets only", onClick: c.goToDetails, testId: "tickets-checkout-only" }}
582
+ />
583
+ ) : (
584
+ <ActionBar
585
+ c={c}
586
+ fmt={fmt}
587
+ onNext={c.goToDetails}
588
+ nextLabel="Continue"
589
+ secondary={{
590
+ label: c.ticketsInCart ? "Update cart" : "Add to cart",
591
+ onClick: () => {
592
+ if (c.addTicketsToCart()) onAddedToCart?.();
593
+ },
594
+ testId: "tickets-add-to-cart",
595
+ }}
596
+ />
597
+ )}
551
598
  </div>
552
599
  );
553
600
  }
@@ -692,7 +739,13 @@ function DetailsStep({
692
739
  fmt={fmt}
693
740
  onBack={() => c.setStep("tickets")}
694
741
  onNext={onContinue}
695
- nextLabel={c.isProcessing ? "Processing…" : "Continue to payment"}
742
+ // Nothing to pay means no payment step follows, so promising one is a
743
+ // lie the next screen immediately contradicts. Read off `totalAmount`
744
+ // rather than the ticket prices because it already carries the booking
745
+ // fee and any discount: a free ticket with a FLAT booking fee still
746
+ // costs money and still goes to payment, and a 100%-off code on a paid
747
+ // ticket does not.
748
+ nextLabel={c.isProcessing ? "Processing…" : c.totalAmount > 0 ? "Continue to payment" : "Confirm"}
696
749
  nextDisabled={c.isProcessing}
697
750
  />
698
751
  </div>
@@ -865,6 +918,7 @@ function ActionBar({
865
918
  onNext,
866
919
  nextLabel,
867
920
  nextDisabled,
921
+ nextTestId,
868
922
  secondary,
869
923
  }: {
870
924
  c: Checkout;
@@ -873,8 +927,11 @@ function ActionBar({
873
927
  onNext: () => void;
874
928
  nextLabel: string;
875
929
  nextDisabled?: boolean;
876
- /** Extra exit shown next to Back "Add to cart" on the selection step. */
877
- secondary?: { label: string; onClick: () => void };
930
+ /** Test id for the primary button; the caller says which button is which
931
+ action, because add-to-cart moves between slots depending on add-ons. */
932
+ nextTestId?: string;
933
+ /** Extra exit shown next to Back on the selection step. */
934
+ secondary?: { label: string; onClick: () => void; testId?: string };
878
935
  }) {
879
936
  const theme = useForgeTheme();
880
937
  return (
@@ -904,7 +961,7 @@ function ActionBar({
904
961
  </div>
905
962
  <div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
906
963
  {secondary ? (
907
- <button data-testid="tickets-add-to-cart" onClick={secondary.onClick} style={secondaryButtonStyle(theme)}>
964
+ <button data-testid={secondary.testId} onClick={secondary.onClick} style={secondaryButtonStyle(theme)}>
908
965
  {secondary.label}
909
966
  </button>
910
967
  ) : (
@@ -912,7 +969,12 @@ function ActionBar({
912
969
  Back
913
970
  </button>
914
971
  )}
915
- <button onClick={onNext} disabled={nextDisabled} style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}>
972
+ <button
973
+ data-testid={nextTestId}
974
+ onClick={onNext}
975
+ disabled={nextDisabled}
976
+ style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}
977
+ >
916
978
  {nextLabel}
917
979
  </button>
918
980
  </div>