@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.
- package/package.json +1 -1
- package/src/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeFunnel.ts +35 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +22 -0
- package/src/contexts/CartContext.tsx +84 -1
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useForms.ts +48 -3
- package/src/data/queries/usePageActions.ts +2 -2
- package/src/data/queries/useWebsite.ts +7 -0
- package/src/i18n/CONVENTION.md +184 -0
- package/src/i18n/_tests/translationKeys.spec.ts +153 -0
- package/src/i18n/de.json +960 -0
- package/src/i18n/en.json +960 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +21 -0
- package/src/provider/ForgeAppProvider.tsx +2 -1
- package/src/provider/SiteConfigProvider.tsx +11 -0
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
- package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
- package/src/ui/headless/checkout/useCartResume.ts +340 -0
- package/src/ui/headless/checkout/useCheckout.ts +59 -14
- package/src/ui/headless/event/useEventCheckout.ts +3 -0
- package/src/ui/headless/funnel/Funnel.tsx +13 -0
- package/src/ui/headless/index.ts +18 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +197 -93
- package/src/ui/styled/AiAgentWidget.tsx +10 -6
- package/src/ui/styled/AudioPlayer.tsx +14 -6
- package/src/ui/styled/BlogCategory.tsx +4 -2
- package/src/ui/styled/BlogComments.tsx +27 -20
- package/src/ui/styled/BlogList.tsx +5 -3
- package/src/ui/styled/BlogPost.tsx +14 -11
- package/src/ui/styled/BundleConfirmation.tsx +86 -46
- package/src/ui/styled/CancellationTerms.tsx +3 -1
- package/src/ui/styled/Cart.tsx +32 -14
- package/src/ui/styled/ChatRoom.tsx +65 -20
- package/src/ui/styled/Checkout.tsx +189 -88
- package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
- package/src/ui/styled/CoachingBooking.tsx +71 -44
- package/src/ui/styled/CoachingConfirmation.tsx +56 -19
- package/src/ui/styled/CoachingDetail.tsx +15 -11
- package/src/ui/styled/CohortPage.tsx +10 -8
- package/src/ui/styled/ConfirmSubscription.tsx +14 -8
- package/src/ui/styled/ContactForm.tsx +31 -12
- package/src/ui/styled/CookieConsent.tsx +19 -15
- package/src/ui/styled/CourseAccess.tsx +54 -47
- package/src/ui/styled/CourseCheckout.tsx +56 -38
- package/src/ui/styled/CourseConfirmation.tsx +39 -17
- package/src/ui/styled/CourseDetail.tsx +21 -15
- package/src/ui/styled/CoursesGrid.tsx +4 -2
- package/src/ui/styled/CurrencySwitcher.tsx +4 -1
- package/src/ui/styled/DiscountCode.tsx +22 -18
- package/src/ui/styled/DonationButton.tsx +52 -17
- package/src/ui/styled/DonationPage.tsx +4 -4
- package/src/ui/styled/EmailListForm.tsx +22 -9
- package/src/ui/styled/EventConfirmation.tsx +37 -14
- package/src/ui/styled/EventCountdown.tsx +10 -6
- package/src/ui/styled/EventDetail.tsx +8 -6
- package/src/ui/styled/EventSeriesDetail.tsx +18 -13
- package/src/ui/styled/EventTickets.tsx +311 -125
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +12 -12
- package/src/ui/styled/FormRenderer.tsx +12 -7
- package/src/ui/styled/InstallBanner.tsx +21 -18
- package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
- package/src/ui/styled/InvoicePayment.tsx +37 -16
- package/src/ui/styled/LeadMagnet.tsx +9 -7
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +22 -14
- package/src/ui/styled/MembershipCheckout.tsx +32 -13
- package/src/ui/styled/MembershipTiers.tsx +27 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PageActions.tsx +13 -9
- package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
- package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
- package/src/ui/styled/PodcastEpisode.tsx +20 -9
- package/src/ui/styled/PodcastList.tsx +9 -3
- package/src/ui/styled/PodcastShow.tsx +29 -7
- package/src/ui/styled/PostsFeed.tsx +10 -8
- package/src/ui/styled/PresaleCode.tsx +8 -11
- package/src/ui/styled/ProductBrowseNav.tsx +21 -11
- package/src/ui/styled/ProductDetail.tsx +58 -28
- package/src/ui/styled/ProductGrid.tsx +17 -4
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +27 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +29 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- 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,
|
|
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
|
-
|
|
165
|
-
|
|
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
|
|
215
|
-
//
|
|
216
|
-
// has always had, below.
|
|
217
|
-
if (
|
|
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 =
|
|
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 (
|
|
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,
|
|
574
|
+
}, [couponCode, orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, usesCheckoutEngine]);
|
|
538
575
|
|
|
539
576
|
const removeCoupon = useCallback(async () => {
|
|
540
|
-
if (
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -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
|
-
{/*
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
{
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
}
|