@tribe-nest/forge 3.25.0 → 3.29.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 (97) hide show
  1. package/package.json +1 -1
  2. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  3. package/src/client/activeFunnel.ts +35 -0
  4. package/src/client/activeLocale.ts +34 -0
  5. package/src/client/createForgeClient.ts +22 -0
  6. package/src/contexts/CartContext.tsx +84 -1
  7. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  8. package/src/data/queries/useCheckouts.ts +83 -0
  9. package/src/data/queries/useForms.ts +48 -3
  10. package/src/data/queries/usePageActions.ts +2 -2
  11. package/src/data/queries/useWebsite.ts +7 -0
  12. package/src/i18n/CONVENTION.md +184 -0
  13. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  14. package/src/i18n/de.json +960 -0
  15. package/src/i18n/en.json +960 -0
  16. package/src/i18n/index.ts +125 -0
  17. package/src/index.ts +21 -0
  18. package/src/provider/ForgeAppProvider.tsx +2 -1
  19. package/src/provider/SiteConfigProvider.tsx +11 -0
  20. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  21. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  22. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  23. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  24. package/src/ui/headless/checkout/useCheckout.ts +59 -14
  25. package/src/ui/headless/event/useEventCheckout.ts +3 -0
  26. package/src/ui/headless/funnel/Funnel.tsx +13 -0
  27. package/src/ui/headless/index.ts +18 -0
  28. package/src/ui/index.ts +2 -0
  29. package/src/ui/shell/TribeNestApp.tsx +36 -27
  30. package/src/ui/styled/AccountDashboard.tsx +197 -93
  31. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  32. package/src/ui/styled/AudioPlayer.tsx +14 -6
  33. package/src/ui/styled/BlogCategory.tsx +4 -2
  34. package/src/ui/styled/BlogComments.tsx +27 -20
  35. package/src/ui/styled/BlogList.tsx +5 -3
  36. package/src/ui/styled/BlogPost.tsx +14 -11
  37. package/src/ui/styled/BundleConfirmation.tsx +86 -46
  38. package/src/ui/styled/CancellationTerms.tsx +3 -1
  39. package/src/ui/styled/Cart.tsx +32 -14
  40. package/src/ui/styled/ChatRoom.tsx +65 -20
  41. package/src/ui/styled/Checkout.tsx +189 -88
  42. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  43. package/src/ui/styled/CoachingBooking.tsx +71 -44
  44. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  45. package/src/ui/styled/CoachingDetail.tsx +15 -11
  46. package/src/ui/styled/CohortPage.tsx +10 -8
  47. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  48. package/src/ui/styled/ContactForm.tsx +31 -12
  49. package/src/ui/styled/CookieConsent.tsx +19 -15
  50. package/src/ui/styled/CourseAccess.tsx +54 -47
  51. package/src/ui/styled/CourseCheckout.tsx +56 -38
  52. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  53. package/src/ui/styled/CourseDetail.tsx +21 -15
  54. package/src/ui/styled/CoursesGrid.tsx +4 -2
  55. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  56. package/src/ui/styled/DiscountCode.tsx +22 -18
  57. package/src/ui/styled/DonationButton.tsx +52 -17
  58. package/src/ui/styled/DonationPage.tsx +4 -4
  59. package/src/ui/styled/EmailListForm.tsx +22 -9
  60. package/src/ui/styled/EventConfirmation.tsx +37 -14
  61. package/src/ui/styled/EventCountdown.tsx +10 -6
  62. package/src/ui/styled/EventDetail.tsx +8 -6
  63. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  64. package/src/ui/styled/EventTickets.tsx +311 -125
  65. package/src/ui/styled/EventWaitlist.tsx +42 -33
  66. package/src/ui/styled/EventsList.tsx +15 -13
  67. package/src/ui/styled/ForgotPasswordForm.tsx +12 -12
  68. package/src/ui/styled/FormRenderer.tsx +12 -7
  69. package/src/ui/styled/InstallBanner.tsx +21 -18
  70. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  71. package/src/ui/styled/InvoicePayment.tsx +37 -16
  72. package/src/ui/styled/LeadMagnet.tsx +9 -7
  73. package/src/ui/styled/Loading.tsx +7 -1
  74. package/src/ui/styled/LoginForm.tsx +22 -14
  75. package/src/ui/styled/MembershipCheckout.tsx +32 -13
  76. package/src/ui/styled/MembershipTiers.tsx +27 -18
  77. package/src/ui/styled/OfferButton.tsx +33 -13
  78. package/src/ui/styled/PageActions.tsx +13 -9
  79. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  80. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  81. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  82. package/src/ui/styled/PodcastList.tsx +9 -3
  83. package/src/ui/styled/PodcastShow.tsx +29 -7
  84. package/src/ui/styled/PostsFeed.tsx +10 -8
  85. package/src/ui/styled/PresaleCode.tsx +8 -11
  86. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  87. package/src/ui/styled/ProductDetail.tsx +58 -28
  88. package/src/ui/styled/ProductGrid.tsx +17 -4
  89. package/src/ui/styled/ReactionBar.tsx +4 -2
  90. package/src/ui/styled/ReplayList.tsx +24 -13
  91. package/src/ui/styled/ResetPasswordForm.tsx +27 -15
  92. package/src/ui/styled/ResumeCart.tsx +289 -0
  93. package/src/ui/styled/ReviewForm.tsx +52 -58
  94. package/src/ui/styled/SignupForm.tsx +29 -16
  95. package/src/ui/styled/TicketTransfer.tsx +55 -54
  96. package/src/ui/styled/UserMenu.tsx +16 -14
  97. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
@@ -20,9 +20,11 @@ import {
20
20
  } from "../../../data/queries/useCheckouts";
21
21
  import { bundleCouponSummary, bundleReturnUrl } from "./bundleCoupon";
22
22
  import { holdExpiredMessage, isHoldExpiredError } from "./inventoryHold";
23
+ import { takeResumedCoupon } from "./resumedCoupon";
23
24
  import { useInventoryHold } from "./useInventoryHold";
24
25
  import { readAttributionRef } from "../../../utils/attribution";
25
26
  import { readLanding } from "../../../utils/landing";
27
+ import { computeBookingFeeAmount } from "../../format/bookingFee";
26
28
  import { ProductDeliveryType, PaymentProviderName, type ApiError, type PublicTaxQuote } from "../../../types/models";
27
29
 
28
30
  /** The four checkout stages (guest details are skipped for a logged-in buyer;
@@ -114,7 +116,15 @@ function messageOf(e: unknown): string | undefined {
114
116
  export function useCheckout(opts: UseCheckoutOptions = {}) {
115
117
  const finalisePath = opts.finalisePath ?? "/checkout/finalise";
116
118
  const { user } = usePublicAuth();
117
- const { cartItems, ticketItems, isBundle, isReady: isCartReady } = useCart();
119
+ const { cartItems, ticketItems, isReady: isCartReady } = useCart();
120
+ /**
121
+ * Any cart holding tickets settles through the checkout (bundle) engine,
122
+ * even with NO products beside them. Tickets have no single-surface order
123
+ * path from this page: `createOrder` only carries `cartItems`, so routing a
124
+ * tickets-only cart there minted an order with a total and no lines. (The
125
+ * event modal's own direct flow is unaffected; it never comes through here.)
126
+ */
127
+ const usesCheckoutEngine = ticketItems.length > 0;
118
128
  const { data: countries = [] } = useShippingCountries();
119
129
  const shippingRates = useShippingRates();
120
130
  const createOrder = useCreateOrder();
@@ -145,6 +155,27 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
145
155
  [cartItems, ticketItems],
146
156
  );
147
157
 
158
+ /**
159
+ * The booking fee the ticket lines will be charged, summed per event from the
160
+ * resolved fee each selection carried out of the modal. An ESTIMATE with the
161
+ * same honesty contract as the modal's own line: it keeps the running total
162
+ * explained while no charge exists, and the server's charged total supersedes
163
+ * it the moment start-payment answers.
164
+ */
165
+ const bookingFeeCost = useMemo(
166
+ () =>
167
+ round2(
168
+ ticketItems.reduce((sum, t) => {
169
+ const ticketSubtotal = Object.entries(t.tickets).reduce(
170
+ (acc, [id, qty]) => acc + (t.ticketMeta[id]?.price ?? 0) * qty,
171
+ 0,
172
+ );
173
+ return sum + computeBookingFeeAmount({ subtotal: ticketSubtotal, fee: t.bookingFee });
174
+ }, 0),
175
+ ),
176
+ [ticketItems],
177
+ );
178
+
148
179
  const [currentStage, setCurrentStage] = useState<CheckoutStage>("userDetails");
149
180
  const [guestUserData, setGuestUserData] = useState<GuestUserData | null>(null);
150
181
  const [shippingData, setShippingData] = useState<CheckoutShippingData>();
@@ -161,8 +192,14 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
161
192
  [selectedShippingRates],
162
193
  );
163
194
 
164
- const [couponCode, setCouponCode] = useState("");
165
- const [showCouponInput, setShowCouponInput] = useState(false);
195
+ /**
196
+ * A code the buyer arrived with from a recovery link, prefilled but NOT
197
+ * applied: they still press Apply and the server re-evaluates it against
198
+ * today. The lazy initialiser consumes the stash, so a code cannot linger and
199
+ * prefill an unrelated checkout later in the same tab.
200
+ */
201
+ const [couponCode, setCouponCode] = useState(takeResumedCoupon);
202
+ const [showCouponInput, setShowCouponInput] = useState(() => couponCode.length > 0);
166
203
  const [couponError, setCouponError] = useState("");
167
204
  const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);
168
205
  const [appliedCoupon, setAppliedCoupon] = useState<AppliedCoupon | null>(null);
@@ -211,10 +248,10 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
211
248
  if (currentStage !== "payment" || !isPaidCheckout || startedRef.current) return;
212
249
  startedRef.current = true;
213
250
 
214
- // A cart spanning tickets AND products goes through the bundle engine so
215
- // both settle on one payment. Every single-surface cart keeps the flow it
216
- // has always had, below.
217
- if (isBundle) {
251
+ // A cart holding tickets (with or without products) goes through the
252
+ // bundle engine so everything settles on one payment. A products-only
253
+ // cart keeps the flow it has always had, below.
254
+ if (usesCheckoutEngine) {
218
255
  const enteredCode = couponCode.trim() || undefined;
219
256
  createCheckout
220
257
  .mutateAsync({
@@ -457,7 +494,7 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
457
494
  * the checkout was created — which for a signed-in buyer with a digital-only
458
495
  * cart was on mount, so the code could never be typed at all.
459
496
  */
460
- const isCouponEditable = isBundle ? !settledCheckoutId : !!orderId;
497
+ const isCouponEditable = usesCheckoutEngine ? !settledCheckoutId : !!orderId;
461
498
 
462
499
  /** Fold an apply/remove answer for a bundle back into the summary + payment. */
463
500
  const absorbBundleCoupon = useCallback((data: ApplyCheckoutCouponResult) => {
@@ -479,7 +516,7 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
479
516
  }, []);
480
517
 
481
518
  const applyCoupon = useCallback(async () => {
482
- if (isBundle) {
519
+ if (usesCheckoutEngine) {
483
520
  if (!couponCode.trim()) return;
484
521
  setCouponError("");
485
522
  // Not created yet (a guest, or a cart with a shipping stage still ahead):
@@ -534,10 +571,10 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
534
571
  } finally {
535
572
  setIsApplyingCoupon(false);
536
573
  }
537
- }, [couponCode, orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, isBundle]);
574
+ }, [couponCode, orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, usesCheckoutEngine]);
538
575
 
539
576
  const removeCoupon = useCallback(async () => {
540
- if (isBundle) {
577
+ if (usesCheckoutEngine) {
541
578
  setCouponError("");
542
579
  // Nothing created yet — the staged code simply never travels.
543
580
  if (!checkoutId) {
@@ -592,7 +629,7 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
592
629
  } finally {
593
630
  setIsApplyingCoupon(false);
594
631
  }
595
- }, [orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, isBundle]);
632
+ }, [orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, usesCheckoutEngine]);
596
633
 
597
634
  // ── Free checkout (nothing to charge) ───────────────────────────────────────
598
635
  /** Create the (free) order and return its id so the caller can navigate to the
@@ -660,6 +697,8 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
660
697
  isCartReady,
661
698
  subTotal,
662
699
  shippingCost,
700
+ /** Estimated booking fee across the cart's ticket lines; 0 with no tickets. */
701
+ bookingFeeCost,
663
702
  discountAmount,
664
703
  hasPhysicalProduct,
665
704
  isPaidCheckout,
@@ -695,7 +734,9 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
695
734
  * page instead of waiting for a payment that will never be asked for.
696
735
  */
697
736
  settledCheckoutId,
698
- isBundle,
737
+ /** True when this checkout settles through the bundle engine (any cart
738
+ holding tickets), which is what the coupon field and summary key off. */
739
+ isBundle: usesCheckoutEngine,
699
740
  ticketItems,
700
741
  isCreatingOrder: createOrder.isPending || flow.isStarting,
701
742
  startError,
@@ -731,7 +772,11 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
731
772
  paymentId: effectivePayment?.paymentId,
732
773
  chargedAmount: effectivePayment?.chargedAmount ?? 0,
733
774
  chargedCurrency: effectivePayment?.chargedCurrency ?? "",
734
- returnUrl: `${typeof window !== "undefined" ? window.location.origin : ""}${finalisePath}?orderId=${orderId || ""}`,
775
+ // The SAME bundle-aware URL handed to start-payment. This was previously
776
+ // rebuilt here from `orderId` alone, so a bundle (which has a checkoutId
777
+ // and no order) confirmed its Stripe payment with `?orderId=` empty and
778
+ // landed on a finalise page that could not resolve anything.
779
+ returnUrl,
735
780
  // ── Free checkout ──────────────────────────────────────────────────────────
736
781
  isFreeCheckoutLoading,
737
782
  freeCheckout,
@@ -264,6 +264,9 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
264
264
  eventSlug: slug ?? event.id,
265
265
  eventTitle: event.title,
266
266
  coverImage: event.media?.find((m: { type: string }) => m.type === "image")?.url,
267
+ // The resolved fee travels with the selection so the CART checkout can
268
+ // show the booking-fee line this modal already shows.
269
+ bookingFee: event.bookingFee ?? null,
267
270
  tickets: { ...selectedTickets },
268
271
  ticketMeta: Object.fromEntries(
269
272
  event.tickets
@@ -1,4 +1,5 @@
1
1
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, type ReactNode } from "react";
2
+ import { setActiveFunnel } from "../../../client/activeFunnel";
2
3
  import { useTrackEvent } from "../../../data/queries/useAnalytics";
3
4
  import { useCookieConsent } from "../consent/useCookieConsent";
4
5
  import { getFunnelSessionId, markOnce, resetFunnelSession } from "./funnelSession";
@@ -114,6 +115,18 @@ export function Funnel({ id, steps, name, enabled = true, requireConsent = true,
114
115
  const emitRef = useRef(emit);
115
116
  emitRef.current = emit;
116
117
 
118
+ // Attribution is registered on `enabled`, NOT on `on`. Analytics is what
119
+ // cookie consent governs; "this lead came from this funnel" is first-party
120
+ // business data about a conversion the visitor deliberately started, and
121
+ // gating it behind performance consent would silently drop the attribution of
122
+ // every lead who declined cookies. Nothing behavioural is attached to the
123
+ // person: the funnel id travels, the funnel SESSION id never does.
124
+ useEffect(() => {
125
+ if (!enabled) return;
126
+ setActiveFunnel(id);
127
+ return () => setActiveFunnel(null);
128
+ }, [enabled, id]);
129
+
117
130
  useEffect(() => {
118
131
  if (!on) return;
119
132
  if (!markOnce(id, "entered")) return;
@@ -29,6 +29,24 @@ export {
29
29
  type InventoryHoldState,
30
30
  type UseInventoryHoldOptions,
31
31
  } from "./checkout/useInventoryHold";
32
+ // Cart recovery: what a resume link does when the buyer clicks it. Shared so
33
+ // `apps/client` and a Forge code site cannot decide differently.
34
+ export {
35
+ useCartResume,
36
+ effectiveUnitCents,
37
+ partitionResumeLines,
38
+ resumeLineToCartItem,
39
+ resumeLinesToTicketItems,
40
+ type ResumePartition,
41
+ type UseCartResumeOptions,
42
+ type UseCartResumeResult,
43
+ type CartResumeStatus,
44
+ type ResumeLine,
45
+ type ResumedCheckout,
46
+ type ResumeLineOption,
47
+ type ResumeLineUnavailableReason,
48
+ } from "./checkout/useCartResume";
49
+ export { rememberResumedCoupon, takeResumedCoupon } from "./checkout/resumedCoupon";
32
50
  export {
33
51
  isHoldExpiredError,
34
52
  holdExpiredMessage,
package/src/ui/index.ts CHANGED
@@ -55,6 +55,8 @@ export { PresaleCodeField, type PresaleCodeFieldProps } from "./styled/PresaleCo
55
55
  export { MembershipCheckout, type MembershipCheckoutProps } from "./styled/MembershipCheckout";
56
56
  export { ForgeStripePayment } from "./payment/ForgeStripePayment";
57
57
  export { Cart, type CartProps } from "./styled/Cart";
58
+ /** The page a cart-recovery email lands on. Drop in at `/i/checkout/resume`. */
59
+ export { ResumeCart, type ResumeCartProps } from "./styled/ResumeCart";
58
60
  // The resolver is exported alongside the component because the Craft themes
59
61
  // are a separate rendering stack (Tailwind, not inline styles) and cannot use
60
62
  // the component — but the legacy `color`/`size` fallback must not diverge.
@@ -8,6 +8,7 @@ import { CookieConsent } from "../styled/CookieConsent";
8
8
  import { AiAgentWidget } from "../styled/AiAgentWidget";
9
9
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
10
10
  import { useInitialSiteConfig } from "../../provider/SiteConfigProvider";
11
+ import { ForgeI18nProvider } from "../../i18n";
11
12
  import { PoweredBy } from "./PoweredBy";
12
13
  import { PreviewDiagnostics } from "./PreviewDiagnostics";
13
14
  import { shellAiAgentEnabled, shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
@@ -142,33 +143,41 @@ export function TribeNestApp({
142
143
  : null;
143
144
  return (
144
145
  <ForgeProvider editable={editable} {...forgeProps}>
145
- {/* First in the tree: it must render even if everything below it doesn't. */}
146
- {ssrFailure && <PreviewDiagnostics diagnostics={ssrFailure} />}
147
- {children}
148
- {/* "Powered by TribeNest" last in the page flow, so it sits below the
149
- tenant's own footer. Released builds only (live + published). */}
150
- <PoweredByGate editable={editable} state={state} />
151
- {/* Global playback bar — self-pins to the viewport bottom, renders nothing
152
- until a track loads. */}
153
- <AudioPlayer />
154
- {/* Auto page views + clicks (+ Meta Pixel, gated on marketing consent).
155
- First-party runs cookieless. Off in the editor so preview isn't counted. */}
156
- {analytics && <ForgeAnalytics enabled={!editable} firstPartyMode="cookieless" />}
157
- {/* Ad attribution capture (tracked-link ref + first-touch landing + visit
158
- beacon). Off in the editor so preview visits aren't credited. */}
159
- <AttributionCapture enabled={!editable} apiUrl={forgeProps.apiUrl} profileId={forgeProps.profileId} />
160
- {/* PWA — SW registration + install banner + (fallback) manifest link. */}
161
- {pwa && <PwaRegistration enabled={pwaEnabled} />}
162
- {pwa && <InstallBanner enabled={pwaEnabled} />}
163
- {pwa && <PwaHeadClient enabled={pwaEnabled} manifestHref={manifestHref} />}
164
- {/* Consent banner shown on first visit, re-openable from anywhere via
165
- useCookieConsent().reopen(). Hidden in the editor. */}
166
- {cookieConsent && !editable && <CookieConsent useTribeNestPrivacy />}
167
- {/* Website AI agent floating bubble, self-hiding when the profile has no
168
- agent enabled. Lives here rather than in each __root so enabling the
169
- agent in admin is enough to make it appear; before this it shipped only
170
- on the Craft stack and no code site ever rendered it. */}
171
- {shellAiAgentEnabled({ aiAgent, editable, appId: forgeProps.appId }) && <AiAgentWidget />}
146
+ {/* Forge's own copy, in the site's language. Mounted here rather than in
147
+ each tenant's __root.tsx, so a Forge upgrade is enough to give an
148
+ existing site i18n. `locale` is the ForgeProvider prop the site
149
+ already passes (absent or unsupported resolves to English). When
150
+ `profile_configurations.locale` lands on SiteConfig, that becomes the
151
+ source and this prop stays the override. */}
152
+ <ForgeI18nProvider locale={forgeProps.locale}>
153
+ {/* First in the tree: it must render even if everything below it doesn't. */}
154
+ {ssrFailure && <PreviewDiagnostics diagnostics={ssrFailure} />}
155
+ {children}
156
+ {/* "Powered by TribeNest" — last in the page flow, so it sits below the
157
+ tenant's own footer. Released builds only (live + published). */}
158
+ <PoweredByGate editable={editable} state={state} />
159
+ {/* Global playback bar self-pins to the viewport bottom, renders nothing
160
+ until a track loads. */}
161
+ <AudioPlayer />
162
+ {/* Auto page views + clicks (+ Meta Pixel, gated on marketing consent).
163
+ First-party runs cookieless. Off in the editor so preview isn't counted. */}
164
+ {analytics && <ForgeAnalytics enabled={!editable} firstPartyMode="cookieless" />}
165
+ {/* Ad attribution capture (tracked-link ref + first-touch landing + visit
166
+ beacon). Off in the editor so preview visits aren't credited. */}
167
+ <AttributionCapture enabled={!editable} apiUrl={forgeProps.apiUrl} profileId={forgeProps.profileId} />
168
+ {/* PWA SW registration + install banner + (fallback) manifest link. */}
169
+ {pwa && <PwaRegistration enabled={pwaEnabled} />}
170
+ {pwa && <InstallBanner enabled={pwaEnabled} />}
171
+ {pwa && <PwaHeadClient enabled={pwaEnabled} manifestHref={manifestHref} />}
172
+ {/* Consent banner shown on first visit, re-openable from anywhere via
173
+ useCookieConsent().reopen(). Hidden in the editor. */}
174
+ {cookieConsent && !editable && <CookieConsent useTribeNestPrivacy />}
175
+ {/* Website AI agent — floating bubble, self-hiding when the profile has no
176
+ agent enabled. Lives here rather than in each __root so enabling the
177
+ agent in admin is enough to make it appear; before this it shipped only
178
+ on the Craft stack and no code site ever rendered it. */}
179
+ {shellAiAgentEnabled({ aiAgent, editable, appId: forgeProps.appId }) && <AiAgentWidget />}
180
+ </ForgeI18nProvider>
172
181
  </ForgeProvider>
173
182
  );
174
183
  }