@tribe-nest/forge 2.2.0 → 3.4.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/createForgeClient.ts +85 -2
- package/src/client/tokenStorage.ts +31 -0
- package/src/contexts/AppAuthContext.tsx +100 -15
- package/src/contexts/PublicAuthContext.tsx +46 -9
- package/src/data/queries/useCheckouts.ts +84 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -3
- package/src/data/queries/useCourseAccess.ts +1 -1
- package/src/data/queries/useCourses.ts +42 -1
- package/src/data/queries/useEvents.ts +15 -1
- package/src/data/queries/usePaymentFlow.ts +12 -0
- package/src/data/queries/useWebsite.ts +6 -0
- package/src/index.ts +19 -2
- package/src/provider/ForgeProvider.tsx +30 -1
- package/src/server/_tests/platformEvents.spec.ts +315 -0
- package/src/server/index.ts +17 -0
- package/src/server/jobs.ts +41 -10
- package/src/server/platform.ts +234 -9
- package/src/server/platformEvents.generated.ts +422 -0
- package/src/types/models.ts +110 -3
- package/src/ui/headless/auth/useSignupForm.ts +69 -4
- package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
- package/src/ui/headless/checkout/useCheckout.ts +156 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
- package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
- package/src/ui/headless/coupon/useCouponField.ts +164 -0
- package/src/ui/headless/course/useCourseCheckout.ts +113 -18
- package/src/ui/headless/event/useEventCheckout.ts +53 -2
- package/src/ui/headless/index.ts +15 -0
- package/src/ui/headless/work/useWorkPortal.ts +24 -21
- package/src/ui/index.ts +7 -0
- package/src/ui/shell/PoweredBy.tsx +60 -0
- package/src/ui/shell/TribeNestApp.tsx +15 -1
- package/src/ui/shell/shellGating.spec.ts +21 -1
- package/src/ui/shell/shellGating.ts +14 -0
- package/src/ui/styled/AddToCalendar.tsx +104 -0
- package/src/ui/styled/Checkout.tsx +45 -14
- package/src/ui/styled/CoachingBooking.tsx +28 -8
- package/src/ui/styled/CoachingConfirmation.tsx +12 -0
- package/src/ui/styled/CourseCheckout.tsx +49 -18
- package/src/ui/styled/DiscountCode.tsx +206 -0
- package/src/ui/styled/EventConfirmation.tsx +68 -22
- package/src/ui/styled/EventDetail.tsx +18 -5
- package/src/ui/styled/EventTickets.tsx +49 -5
- package/src/ui/styled/SignupForm.tsx +86 -35
- package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
- package/src/utils/_tests/safeRedirect.spec.ts +117 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- package/src/utils/safeRedirect.ts +41 -0
- package/src/utils/ticketOrderOutcome.ts +125 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
2
|
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
3
|
-
import { useGetCourse, useCreateCourseBooking } from "../../../data/queries/useCourses";
|
|
3
|
+
import { useGetCourse, useCreateCourseBooking, useUpdateCourseBooking } from "../../../data/queries/useCourses";
|
|
4
4
|
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
5
|
+
import { useCouponField, type CouponQuoteFn } from "../coupon/useCouponField";
|
|
5
6
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
6
7
|
import { readLanding } from "../../../utils/landing";
|
|
7
8
|
|
|
@@ -21,8 +22,15 @@ const errMessage = (e: unknown) =>
|
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Headless course purchase: buyer details → payment. Composes `useGetCourse`,
|
|
24
|
-
* `useCreateCourseBooking`, `usePaymentFlow`. Free
|
|
25
|
-
* redirect to the finalise page. Finalize via
|
|
25
|
+
* `useCreateCourseBooking`, `useUpdateCourseBooking`, `usePaymentFlow`. Free
|
|
26
|
+
* courses skip payment and redirect to the finalise page. Finalize via
|
|
27
|
+
* `useCourseBookingFinalize`.
|
|
28
|
+
*
|
|
29
|
+
* `POST /public/courses/:id/bookings` takes NO coupon code — the course pillar
|
|
30
|
+
* accepts one only on `booking/update`, which exists to be called repeatedly and
|
|
31
|
+
* re-derives the gross price from the course each time. So a booking is created
|
|
32
|
+
* as soon as the buyer applies a code, and reused from then on. A buyer who
|
|
33
|
+
* never touches the discount field takes exactly the path this hook always took.
|
|
26
34
|
*/
|
|
27
35
|
export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions = {}) {
|
|
28
36
|
const { user } = usePublicAuth();
|
|
@@ -30,6 +38,7 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
30
38
|
const { data: course, isLoading } = useGetCourse(slug);
|
|
31
39
|
const courseId = course?.id;
|
|
32
40
|
const createBooking = useCreateCourseBooking(courseId);
|
|
41
|
+
const updateBooking = useUpdateCourseBooking(courseId);
|
|
33
42
|
const flow = usePaymentFlow({ path: `/public/courses/${courseId}/start-payment`, autoStart: false });
|
|
34
43
|
|
|
35
44
|
const [step, setStep] = useState<CourseCheckoutStep>("details");
|
|
@@ -39,10 +48,58 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
39
48
|
const [questionnaire, setQuestionnaire] = useState<unknown>(undefined);
|
|
40
49
|
const [returnUrl, setReturnUrl] = useState("");
|
|
41
50
|
const [error, setError] = useState<string | null>(null);
|
|
51
|
+
/** The booking created for quoting, reused so a code never mints a second one. */
|
|
52
|
+
const bookingIdRef = useRef<string | null>(null);
|
|
42
53
|
|
|
43
54
|
const finalisePath =
|
|
44
55
|
opts.finalisePath ?? ((s, bookingId) => `/i/courses/${s}/finalise?bookingId=${bookingId}`);
|
|
45
56
|
|
|
57
|
+
const requireBuyerDetails = () => {
|
|
58
|
+
if (!firstName.trim() || !lastName.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
59
|
+
throw new Error("Enter your name and a valid email before applying a code.");
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Create the booking once; every later call reuses it. */
|
|
64
|
+
const ensureBooking = async (): Promise<string> => {
|
|
65
|
+
if (bookingIdRef.current) return bookingIdRef.current;
|
|
66
|
+
const created = await createBooking.mutateAsync({
|
|
67
|
+
email,
|
|
68
|
+
firstName,
|
|
69
|
+
lastName,
|
|
70
|
+
questionnaire,
|
|
71
|
+
attributionRefId: readAttributionRef() ?? undefined,
|
|
72
|
+
...(readLanding() ?? {}),
|
|
73
|
+
});
|
|
74
|
+
bookingIdRef.current = created.bookingId;
|
|
75
|
+
return created.bookingId;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `confirmIfFree` is false here on purpose: quoting a code must never be able
|
|
80
|
+
* to complete an enrollment. Only "Continue" confirms.
|
|
81
|
+
*/
|
|
82
|
+
const quoteCoupon: CouponQuoteFn = useCallback(
|
|
83
|
+
async (code) => {
|
|
84
|
+
if (!courseId) throw new Error("This course is not available.");
|
|
85
|
+
requireBuyerDetails();
|
|
86
|
+
const bookingId = await ensureBooking();
|
|
87
|
+
return updateBooking.mutateAsync({
|
|
88
|
+
bookingId,
|
|
89
|
+
email,
|
|
90
|
+
firstName,
|
|
91
|
+
lastName,
|
|
92
|
+
confirmIfFree: false,
|
|
93
|
+
questionnaire,
|
|
94
|
+
couponCode: code ?? undefined,
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
// `createBooking`/`updateBooking` are stable react-query mutation objects.
|
|
98
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
99
|
+
[courseId, firstName, lastName, email, questionnaire],
|
|
100
|
+
);
|
|
101
|
+
const coupon = useCouponField(quoteCoupon);
|
|
102
|
+
|
|
46
103
|
const continueToPayment = async () => {
|
|
47
104
|
setError(null);
|
|
48
105
|
if (!slug || !courseId) return;
|
|
@@ -51,27 +108,56 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
51
108
|
return;
|
|
52
109
|
}
|
|
53
110
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
111
|
+
// A discount can only be recorded through `booking/update`, so any buyer
|
|
112
|
+
// who touched the code field settles through that endpoint. Everyone else
|
|
113
|
+
// keeps the original create-only path, unchanged.
|
|
114
|
+
const usesCoupon = !!coupon.submittedCode || !!bookingIdRef.current;
|
|
115
|
+
let bookingId: string;
|
|
116
|
+
let isFree: boolean;
|
|
117
|
+
|
|
118
|
+
if (usesCoupon) {
|
|
119
|
+
bookingId = await ensureBooking();
|
|
120
|
+
const updated = await updateBooking.mutateAsync({
|
|
121
|
+
bookingId,
|
|
122
|
+
email,
|
|
123
|
+
firstName,
|
|
124
|
+
lastName,
|
|
125
|
+
confirmIfFree: true,
|
|
126
|
+
questionnaire,
|
|
127
|
+
couponCode: coupon.submittedCode,
|
|
128
|
+
});
|
|
129
|
+
coupon.record(updated);
|
|
130
|
+
isFree = updated.isConfirmed;
|
|
131
|
+
} else {
|
|
132
|
+
const data = await createBooking.mutateAsync({
|
|
133
|
+
email,
|
|
134
|
+
firstName,
|
|
135
|
+
lastName,
|
|
136
|
+
questionnaire,
|
|
137
|
+
attributionRefId: readAttributionRef() ?? undefined,
|
|
138
|
+
...(readLanding() ?? {}),
|
|
139
|
+
});
|
|
140
|
+
bookingIdRef.current = data.bookingId;
|
|
141
|
+
bookingId = data.bookingId;
|
|
142
|
+
isFree = !!data.isFree;
|
|
143
|
+
}
|
|
144
|
+
|
|
62
145
|
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
63
|
-
const ru = `${origin}${finalisePath(slug,
|
|
146
|
+
const ru = `${origin}${finalisePath(slug, bookingId)}`;
|
|
64
147
|
setReturnUrl(ru);
|
|
65
|
-
if (
|
|
66
|
-
if (opts.onComplete) opts.onComplete({ slug, bookingId
|
|
148
|
+
if (isFree) {
|
|
149
|
+
if (opts.onComplete) opts.onComplete({ slug, bookingId });
|
|
67
150
|
else if (typeof window !== "undefined") window.location.href = ru;
|
|
68
151
|
return;
|
|
69
152
|
}
|
|
70
|
-
const result = await flow.start({ bookingId
|
|
153
|
+
const result = await flow.start({ bookingId, returnUrl: ru });
|
|
71
154
|
if (result.provider && result.provider !== "stripe") return;
|
|
72
155
|
setStep("payment");
|
|
73
156
|
} catch (e) {
|
|
74
|
-
|
|
157
|
+
// With a code entered, the API's rejection reason belongs at the field the
|
|
158
|
+
// buyer used, not in the generic error slot.
|
|
159
|
+
if (coupon.submittedCode) coupon.fail(e);
|
|
160
|
+
else setError(errMessage(e));
|
|
75
161
|
}
|
|
76
162
|
};
|
|
77
163
|
|
|
@@ -90,10 +176,19 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
90
176
|
setQuestionnaire,
|
|
91
177
|
continueToPayment,
|
|
92
178
|
clientSecret: flow.clientSecret,
|
|
179
|
+
// The booking quote WINS over start-payment: every `continueToPayment`
|
|
180
|
+
// re-quotes before starting the payment, and applying or removing a code
|
|
181
|
+
// re-quotes again — so the quote is never staler, and preferring the
|
|
182
|
+
// payment result would leave a removed discount on screen.
|
|
183
|
+
totalAmount: coupon.quote?.totalAmount ?? flow.result?.totalAmount ?? (course ? Number(course.price) : undefined),
|
|
184
|
+
/** GROSS course price, before any discount. */
|
|
185
|
+
subTotal: coupon.quote?.subTotal ?? (course ? Number(course.price) : undefined),
|
|
186
|
+
/** Discount code state + the server's quote. See `useCouponField`. */
|
|
187
|
+
coupon,
|
|
93
188
|
/** Authoritative sales-tax quote from start-payment (display only). */
|
|
94
189
|
taxQuote: flow.result?.taxQuote ?? null,
|
|
95
190
|
returnUrl,
|
|
96
|
-
isProcessing: createBooking.isPending || flow.isStarting,
|
|
191
|
+
isProcessing: createBooking.isPending || updateBooking.isPending || flow.isStarting,
|
|
97
192
|
error,
|
|
98
193
|
};
|
|
99
194
|
}
|
|
@@ -3,6 +3,7 @@ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
|
3
3
|
import { useCart } from "../../../contexts/CartContext";
|
|
4
4
|
import { useEvent, useCreateEventOrder } from "../../../data/queries/useEvents";
|
|
5
5
|
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
6
|
+
import { useCouponField } from "../coupon/useCouponField";
|
|
6
7
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
7
8
|
import { readLanding } from "../../../utils/landing";
|
|
8
9
|
|
|
@@ -47,6 +48,19 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
47
48
|
const [returnUrl, setReturnUrl] = useState("");
|
|
48
49
|
const [error, setError] = useState<string | null>(null);
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Discount code — STAGED, not quoted.
|
|
53
|
+
*
|
|
54
|
+
* Tickets have no apply-coupon endpoint and no quote endpoint: `POST
|
|
55
|
+
* /public/events/:id/orders` is what redeems a code, and it creates the order
|
|
56
|
+
* row. Pricing a code by calling it would leave a real abandoned order (and a
|
|
57
|
+
* released inventory hold) behind every keystroke, so no quote function is
|
|
58
|
+
* passed here. The code rides along on `continueToPayment`, and the order
|
|
59
|
+
* response — which carries `subTotal`, `discountAmount` and every applied
|
|
60
|
+
* coupon — is recorded as the quote.
|
|
61
|
+
*/
|
|
62
|
+
const coupon = useCouponField();
|
|
63
|
+
|
|
50
64
|
const totalAmount = useMemo(() => {
|
|
51
65
|
if (!event) return 0;
|
|
52
66
|
return event.tickets.reduce((sum, t) => sum + t.price * (selectedTickets[t.id] ?? 0), 0);
|
|
@@ -124,9 +138,19 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
124
138
|
firstName,
|
|
125
139
|
lastName,
|
|
126
140
|
questionnaire,
|
|
141
|
+
couponCode: coupon.submittedCode,
|
|
127
142
|
attributionRefId: readAttributionRef() ?? undefined,
|
|
128
143
|
...(readLanding() ?? {}),
|
|
129
144
|
});
|
|
145
|
+
// What the server actually charged and why — including any AUTOMATIC
|
|
146
|
+
// discount the buyer never asked for, which nothing else would reveal.
|
|
147
|
+
coupon.record({
|
|
148
|
+
subTotal: data.subTotal ?? data.totalAmount ?? totalAmount,
|
|
149
|
+
totalAmount: data.totalAmount ?? totalAmount,
|
|
150
|
+
discountAmount: data.discountAmount ?? 0,
|
|
151
|
+
couponId: data.couponId ?? null,
|
|
152
|
+
appliedCoupons: data.appliedCoupons ?? [],
|
|
153
|
+
});
|
|
130
154
|
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
131
155
|
const ru = `${origin}${finalisePath(slug, data.orderId)}`;
|
|
132
156
|
setReturnUrl(ru);
|
|
@@ -141,10 +165,29 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
141
165
|
if (result.provider && result.provider !== "stripe") return; // Paystack redirected
|
|
142
166
|
setStep("payment");
|
|
143
167
|
} catch (e) {
|
|
144
|
-
|
|
168
|
+
// A refused code fails the whole request, so with one entered the message
|
|
169
|
+
// belongs next to the field the buyer just used. The message is the API's
|
|
170
|
+
// own — "This coupon has expired", "…does not reach this coupon's minimum
|
|
171
|
+
// spend" — never a house "invalid code" that hides which it was.
|
|
172
|
+
if (coupon.submittedCode) coupon.fail(e);
|
|
173
|
+
else setError(errMessage(e));
|
|
145
174
|
}
|
|
146
175
|
};
|
|
147
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Drop the code and go back to re-enter details. There is nothing to un-apply
|
|
179
|
+
* server-side: the order was created WITH the discount and is simply left
|
|
180
|
+
* behind, exactly as pressing "Back" already does. The next
|
|
181
|
+
* `continueToPayment` creates a fresh, undiscounted order.
|
|
182
|
+
*/
|
|
183
|
+
const clearCoupon = () => {
|
|
184
|
+
coupon.reset();
|
|
185
|
+
// The started payment described the DISCOUNTED order; leaving it would keep
|
|
186
|
+
// the reduced total on screen after the discount is gone.
|
|
187
|
+
flow.reset();
|
|
188
|
+
setStep("details");
|
|
189
|
+
};
|
|
190
|
+
|
|
148
191
|
return {
|
|
149
192
|
event,
|
|
150
193
|
isLoading,
|
|
@@ -153,7 +196,15 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
153
196
|
selectedTickets,
|
|
154
197
|
setTicketQty,
|
|
155
198
|
ticketCount,
|
|
156
|
-
|
|
199
|
+
// The server's figure wins the moment there is one: start-payment first,
|
|
200
|
+
// then the order response (which is already net of any discount), and only
|
|
201
|
+
// the locally summed ticket prices before either exists.
|
|
202
|
+
totalAmount: flow.result?.totalAmount ?? coupon.quote?.totalAmount ?? totalAmount,
|
|
203
|
+
/** GROSS ticket total, before any discount. */
|
|
204
|
+
subTotal: coupon.quote?.subTotal ?? totalAmount,
|
|
205
|
+
/** Discount code state + the server's quote. See `useCouponField`. */
|
|
206
|
+
coupon,
|
|
207
|
+
clearCoupon,
|
|
157
208
|
/** Authoritative sales-tax quote from start-payment (display only). */
|
|
158
209
|
taxQuote: flow.result?.taxQuote ?? null,
|
|
159
210
|
firstName,
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -23,6 +23,13 @@ export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
|
|
|
23
23
|
// Flow primitives (Part A2) — multi-step purchase/booking/subscribe/chat flows,
|
|
24
24
|
// each composed from the data hooks so a creator can rebuild any page.
|
|
25
25
|
export { useEventCheckout, type UseEventCheckoutOptions, type EventCheckoutStep } from "./event/useEventCheckout";
|
|
26
|
+
export {
|
|
27
|
+
useCouponField,
|
|
28
|
+
apiErrorMessage,
|
|
29
|
+
couponFailureMessage,
|
|
30
|
+
type CouponField,
|
|
31
|
+
type CouponQuoteFn,
|
|
32
|
+
} from "./coupon/useCouponField";
|
|
26
33
|
export { useCourseCheckout, type UseCourseCheckoutOptions, type CourseCheckoutStep } from "./course/useCourseCheckout";
|
|
27
34
|
export {
|
|
28
35
|
useCoachingBooking,
|
|
@@ -43,6 +50,14 @@ export { useMessageThread, type UseMessageThreadResult } from "./chat/useMessage
|
|
|
43
50
|
export { useChatAttachmentUpload, type PendingAttachment } from "./chat/useChatAttachmentUpload";
|
|
44
51
|
export { attachmentKind, formatBytes, type AttachmentKind } from "./chat/attachmentKind";
|
|
45
52
|
export { useCookieConsent, type UseCookieConsentResult } from "./consent/useCookieConsent";
|
|
53
|
+
export {
|
|
54
|
+
useAddToCalendar,
|
|
55
|
+
buildAddToCalendarLinks,
|
|
56
|
+
toUtcBasic,
|
|
57
|
+
stripHtml,
|
|
58
|
+
type AddToCalendarInput,
|
|
59
|
+
type AddToCalendarLinks,
|
|
60
|
+
} from "./calendar/useAddToCalendar";
|
|
46
61
|
|
|
47
62
|
export { usePodcastPlayer, type UsePodcastPlayerOptions, type PodcastPlayerState } from "./podcast/usePodcastPlayer";
|
|
48
63
|
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
useMutation,
|
|
3
|
-
useQuery,
|
|
4
|
-
useQueryClient,
|
|
5
|
-
type UseQueryResult,
|
|
6
|
-
} from "@tanstack/react-query";
|
|
1
|
+
import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
|
|
7
2
|
import { useForge } from "../../../provider/ForgeProvider";
|
|
8
3
|
|
|
9
4
|
/**
|
|
@@ -113,10 +108,24 @@ export interface WorkInviteContext {
|
|
|
113
108
|
profileName: string;
|
|
114
109
|
email: string;
|
|
115
110
|
firstName: string | null;
|
|
111
|
+
/**
|
|
112
|
+
* The invited address already has an account, so the invite grants portal
|
|
113
|
+
* access but cannot set a credential — render "sign in", not a password
|
|
114
|
+
* form (C10).
|
|
115
|
+
*/
|
|
116
|
+
requiresExistingLogin?: boolean;
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
export interface WorkInviteAcceptResult {
|
|
119
|
-
|
|
120
|
+
/**
|
|
121
|
+
* True when the invited address already has an account: the invite granted
|
|
122
|
+
* portal access but issued NO session, and the person signs in normally
|
|
123
|
+
* (C10). `token` is absent in that case.
|
|
124
|
+
*/
|
|
125
|
+
requiresLogin?: boolean;
|
|
126
|
+
token?: string;
|
|
127
|
+
/** Rotating refresh token for the portal session (backend §7) — persist it. */
|
|
128
|
+
refreshToken?: string;
|
|
120
129
|
projectId: string;
|
|
121
130
|
account: { id: string; email: string; firstName: string | null; lastName: string | null };
|
|
122
131
|
}
|
|
@@ -125,8 +134,7 @@ export interface WorkInviteAcceptResult {
|
|
|
125
134
|
|
|
126
135
|
const keys = {
|
|
127
136
|
projects: (profileId?: string) => ["work-portal-projects", profileId] as const,
|
|
128
|
-
report: (profileId: string | undefined, projectId: string) =>
|
|
129
|
-
["work-portal-report", profileId, projectId] as const,
|
|
137
|
+
report: (profileId: string | undefined, projectId: string) => ["work-portal-report", profileId, projectId] as const,
|
|
130
138
|
tokenReport: (token: string) => ["work-portal-token-report", token] as const,
|
|
131
139
|
taskComments: (profileId: string | undefined, projectId: string, taskId: string) =>
|
|
132
140
|
["work-portal-task-comments", profileId, projectId, taskId] as const,
|
|
@@ -212,10 +220,9 @@ export function useWorkTaskComments(projectId: string, taskId: string): UseWorkT
|
|
|
212
220
|
const query = useQuery<WorkTaskComment[]>({
|
|
213
221
|
queryKey: keys.taskComments(profileId, projectId, taskId),
|
|
214
222
|
queryFn: async () => {
|
|
215
|
-
const res = await client.get(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
);
|
|
223
|
+
const res = await client.get(`/public/work/projects/${projectId}/tasks/${taskId}/comments`, {
|
|
224
|
+
params: { profileId },
|
|
225
|
+
});
|
|
219
226
|
return res.data as WorkTaskComment[];
|
|
220
227
|
},
|
|
221
228
|
enabled: !!profileId && !!client && !!projectId && !!taskId,
|
|
@@ -255,18 +262,14 @@ export function useWorkTaskComments(projectId: string, taskId: string): UseWorkT
|
|
|
255
262
|
* projects — GET /public/work/projects/:projectId/tasks/:taskId/attachments.
|
|
256
263
|
* Tier-1 primitive: returns clean data, renders nothing.
|
|
257
264
|
*/
|
|
258
|
-
export function useWorkTaskAttachments(
|
|
259
|
-
projectId: string,
|
|
260
|
-
taskId: string,
|
|
261
|
-
): UseQueryResult<WorkTaskAttachment[]> {
|
|
265
|
+
export function useWorkTaskAttachments(projectId: string, taskId: string): UseQueryResult<WorkTaskAttachment[]> {
|
|
262
266
|
const { client, profileId } = useForge();
|
|
263
267
|
return useQuery<WorkTaskAttachment[]>({
|
|
264
268
|
queryKey: keys.taskAttachments(profileId, projectId, taskId),
|
|
265
269
|
queryFn: async () => {
|
|
266
|
-
const res = await client.get(
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
);
|
|
270
|
+
const res = await client.get(`/public/work/projects/${projectId}/tasks/${taskId}/attachments`, {
|
|
271
|
+
params: { profileId },
|
|
272
|
+
});
|
|
270
273
|
return res.data as WorkTaskAttachment[];
|
|
271
274
|
},
|
|
272
275
|
enabled: !!profileId && !!client && !!projectId && !!taskId,
|
package/src/ui/index.ts
CHANGED
|
@@ -45,6 +45,12 @@ export {
|
|
|
45
45
|
export { DonationButton, type DonationButtonProps } from "./styled/DonationButton";
|
|
46
46
|
export { OfferButton, type OfferButtonProps } from "./styled/OfferButton";
|
|
47
47
|
export { Checkout, type CheckoutProps } from "./styled/Checkout";
|
|
48
|
+
export {
|
|
49
|
+
DiscountCodeField,
|
|
50
|
+
type DiscountCodeFieldProps,
|
|
51
|
+
DiscountSummaryLines,
|
|
52
|
+
type DiscountSummaryLinesProps,
|
|
53
|
+
} from "./styled/DiscountCode";
|
|
48
54
|
export { MembershipCheckout, type MembershipCheckoutProps } from "./styled/MembershipCheckout";
|
|
49
55
|
export { ForgeStripePayment } from "./payment/ForgeStripePayment";
|
|
50
56
|
export { Cart, type CartProps } from "./styled/Cart";
|
|
@@ -67,6 +73,7 @@ export { EventsList, type EventsListProps } from "./styled/EventsList";
|
|
|
67
73
|
export { EventTickets, type EventTicketsProps } from "./styled/EventTickets";
|
|
68
74
|
export { EventCountdown, type EventCountdownProps } from "./styled/EventCountdown";
|
|
69
75
|
export { EventDetail, type EventDetailProps } from "./styled/EventDetail";
|
|
76
|
+
export { AddToCalendar, type AddToCalendarProps } from "./styled/AddToCalendar";
|
|
70
77
|
export { CoachingBooking, type CoachingBookingProps } from "./styled/CoachingBooking";
|
|
71
78
|
export { CoachingDetail, type CoachingDetailProps } from "./styled/CoachingDetail";
|
|
72
79
|
export { CourseCheckout, type CourseCheckoutProps } from "./styled/CourseCheckout";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
2
|
+
|
|
3
|
+
/** Where the badge points. utm params so the referral traffic is attributable. */
|
|
4
|
+
const HREF = "https://tribenest.co/?utm_source=powered_by&utm_medium=badge&utm_campaign=creator_site";
|
|
5
|
+
|
|
6
|
+
export interface PoweredByProps {
|
|
7
|
+
/** Live released build only — see `shellPoweredByEnabled`. */
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The "Powered by TribeNest" line every released site and app carries.
|
|
13
|
+
*
|
|
14
|
+
* Rendered by `TribeNestApp` after the page tree, so it lands BELOW the tenant's
|
|
15
|
+
* own footer and needs no edit to the tenant-owned `__root.tsx` — a Forge bump
|
|
16
|
+
* distributes it to every existing site. Both starters (`apps/site-starter` and
|
|
17
|
+
* `apps/app-starter`) wrap in `TribeNestApp`, so websites and mini-apps are
|
|
18
|
+
* covered by the same component.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately NOT a prop on `TribeNestApp`: the tenant owns `__root.tsx`, so an
|
|
21
|
+
* opt-out prop is an opt-out. Removal is a server-side decision instead —
|
|
22
|
+
* `siteConfig.hideTribeNestBadge`, which is how a white-label plan entitlement
|
|
23
|
+
* will switch it off without waiting for a Forge release to propagate.
|
|
24
|
+
*
|
|
25
|
+
* Styled from theme tokens and kept in the normal document flow (not fixed), so
|
|
26
|
+
* it inherits the site's palette and never collides with the pinned AudioPlayer.
|
|
27
|
+
*/
|
|
28
|
+
export function PoweredBy({ enabled = true }: PoweredByProps) {
|
|
29
|
+
const t = useThemeTokens();
|
|
30
|
+
if (!enabled) return null;
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
style={{
|
|
34
|
+
display: "flex",
|
|
35
|
+
justifyContent: "center",
|
|
36
|
+
padding: "14px 16px",
|
|
37
|
+
borderTop: `1px solid ${t.border}`,
|
|
38
|
+
background: t.background,
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<a
|
|
42
|
+
href={HREF}
|
|
43
|
+
target="_blank"
|
|
44
|
+
// nofollow: the same link on every site we host is exactly the pattern
|
|
45
|
+
// Google reads as a link scheme. Drop it if the SEO value is judged
|
|
46
|
+
// worth that risk — it is a one-word change.
|
|
47
|
+
rel="noopener noreferrer nofollow"
|
|
48
|
+
style={{
|
|
49
|
+
fontSize: 12,
|
|
50
|
+
lineHeight: 1,
|
|
51
|
+
color: t.muted,
|
|
52
|
+
textDecoration: "none",
|
|
53
|
+
letterSpacing: 0.2,
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
Powered by <span style={{ fontWeight: 600 }}>TribeNest</span>
|
|
57
|
+
</a>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -6,7 +6,9 @@ import { PwaRegistration } from "../styled/PwaRegistration";
|
|
|
6
6
|
import { InstallBanner } from "../styled/InstallBanner";
|
|
7
7
|
import { CookieConsent } from "../styled/CookieConsent";
|
|
8
8
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
9
|
-
import {
|
|
9
|
+
import { useInitialSiteConfig } from "../../provider/SiteConfigProvider";
|
|
10
|
+
import { PoweredBy } from "./PoweredBy";
|
|
11
|
+
import { shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
|
|
10
12
|
import { captureAttributionRefFromUrl, readAttributionRef } from "../../utils/attribution";
|
|
11
13
|
import { captureLandingFromUrl, postLandingBeacon } from "../../utils/landing";
|
|
12
14
|
|
|
@@ -65,6 +67,15 @@ function PwaHeadClient({ enabled, manifestHref }: { enabled: boolean; manifestHr
|
|
|
65
67
|
return null;
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
// Reads the server-supplied site config, so the white-label entitlement can turn
|
|
71
|
+
// the badge off without a Forge release. Must live INSIDE ForgeProvider (that is
|
|
72
|
+
// what mounts SiteConfigProvider), hence a component rather than a call in the
|
|
73
|
+
// TribeNestApp body.
|
|
74
|
+
function PoweredByGate({ editable, state }: { editable?: boolean; state?: "draft" | "published" }) {
|
|
75
|
+
const siteConfig = useInitialSiteConfig();
|
|
76
|
+
return <PoweredBy enabled={shellPoweredByEnabled({ editable, state, hideBadge: siteConfig?.hideTribeNestBadge })} />;
|
|
77
|
+
}
|
|
78
|
+
|
|
68
79
|
/**
|
|
69
80
|
* The single root component for a TribeNest code website. Wrap the page tree once
|
|
70
81
|
* — it owns the whole provider tree (via `ForgeProvider`) PLUS the global shell
|
|
@@ -100,6 +111,9 @@ export function TribeNestApp({
|
|
|
100
111
|
return (
|
|
101
112
|
<ForgeProvider editable={editable} {...forgeProps}>
|
|
102
113
|
{children}
|
|
114
|
+
{/* "Powered by TribeNest" — last in the page flow, so it sits below the
|
|
115
|
+
tenant's own footer. Released builds only (live + published). */}
|
|
116
|
+
<PoweredByGate editable={editable} state={state} />
|
|
103
117
|
{/* Global playback bar — self-pins to the viewport bottom, renders nothing
|
|
104
118
|
until a track loads. */}
|
|
105
119
|
<AudioPlayer />
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { shellPwaEnabled } from "./shellGating";
|
|
2
|
+
import { shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
|
|
3
3
|
|
|
4
4
|
// The PWA (SW register + install prompt) must be ON only for the live published
|
|
5
5
|
// site, and OFF everywhere else (editor, preview/draft), so review Workers never
|
|
@@ -26,3 +26,23 @@ describe("shellPwaEnabled", () => {
|
|
|
26
26
|
expect(shellPwaEnabled({ editable: false, state: "published" })).toBe(true);
|
|
27
27
|
});
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
// "Powered by TribeNest" ships on released builds only, and the ONLY way to
|
|
31
|
+
// remove it is the server-supplied flag — there is no client-side opt-out,
|
|
32
|
+
// because the tenant owns __root.tsx.
|
|
33
|
+
describe("shellPoweredByEnabled", () => {
|
|
34
|
+
it("is true on a live published site", () => {
|
|
35
|
+
expect(shellPoweredByEnabled({ editable: false, state: "published" })).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("is false in the editor and on draft/preview deploys", () => {
|
|
39
|
+
expect(shellPoweredByEnabled({ editable: true, state: "published" })).toBe(false);
|
|
40
|
+
expect(shellPoweredByEnabled({ editable: false, state: "draft" })).toBe(false);
|
|
41
|
+
expect(shellPoweredByEnabled({ editable: false })).toBe(false); // state undefined
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("is false only when the SERVER says to hide it", () => {
|
|
45
|
+
expect(shellPoweredByEnabled({ editable: false, state: "published", hideBadge: true })).toBe(false);
|
|
46
|
+
expect(shellPoweredByEnabled({ editable: false, state: "published", hideBadge: false })).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -9,3 +9,17 @@ export function shellPwaEnabled(opts: {
|
|
|
9
9
|
}): boolean {
|
|
10
10
|
return (opts.pwa ?? true) && !opts.editable && opts.state === "published";
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
// "Powered by TribeNest" shows on RELEASED builds only — the live published
|
|
14
|
+
// site, never the HMR editor or a preview-<versionId>.* review Worker. Same
|
|
15
|
+
// released-build test as the PWA, with one extra lever: `hideBadge` comes from
|
|
16
|
+
// the server (`siteConfig.hideTribeNestBadge`), which is where a white-label
|
|
17
|
+
// plan entitlement turns it off. It is deliberately not a `TribeNestApp` prop —
|
|
18
|
+
// the tenant owns __root.tsx, so a prop would make the badge opt-out.
|
|
19
|
+
export function shellPoweredByEnabled(opts: {
|
|
20
|
+
editable?: boolean;
|
|
21
|
+
state?: "draft" | "published";
|
|
22
|
+
hideBadge?: boolean;
|
|
23
|
+
}): boolean {
|
|
24
|
+
return !opts.hideBadge && !opts.editable && opts.state === "published";
|
|
25
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { CalendarPlus } from "lucide-react";
|
|
2
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
3
|
+
import { useAddToCalendar, type AddToCalendarInput } from "../headless/calendar/useAddToCalendar";
|
|
4
|
+
|
|
5
|
+
export interface AddToCalendarProps extends AddToCalendarInput {
|
|
6
|
+
/** Heading above the provider links. Pass `null` to drop it entirely. */
|
|
7
|
+
label?: string | null;
|
|
8
|
+
/**
|
|
9
|
+
* `compact` renders a single row of small chips with no heading — for tight
|
|
10
|
+
* spots like a confirmation card. `default` renders the label + chips.
|
|
11
|
+
*/
|
|
12
|
+
variant?: "default" | "compact";
|
|
13
|
+
/** Extra class(es) appended to the root element. */
|
|
14
|
+
className?: string;
|
|
15
|
+
/** Inline style merged LAST into the root element (callers can override). */
|
|
16
|
+
style?: React.CSSProperties;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* "Add to calendar" — deep links that prefill Google / Outlook / Microsoft 365
|
|
21
|
+
* with the event, plus an `.ics` download when the host supplies one (that is
|
|
22
|
+
* the Apple Calendar route; there is no Apple web endpoint to link to).
|
|
23
|
+
*
|
|
24
|
+
* Purely presentational: every URL comes from `useAddToCalendar`. Renders
|
|
25
|
+
* nothing at all when the start time is missing or unparseable.
|
|
26
|
+
*/
|
|
27
|
+
export function AddToCalendar({
|
|
28
|
+
label = "Add to calendar",
|
|
29
|
+
variant = "default",
|
|
30
|
+
className,
|
|
31
|
+
style,
|
|
32
|
+
...input
|
|
33
|
+
}: AddToCalendarProps) {
|
|
34
|
+
const t = useThemeTokens();
|
|
35
|
+
const links = useAddToCalendar(input);
|
|
36
|
+
|
|
37
|
+
if (!links) return null;
|
|
38
|
+
|
|
39
|
+
const compact = variant === "compact";
|
|
40
|
+
|
|
41
|
+
const options: { key: string; text: string; href: string; download?: boolean }[] = [
|
|
42
|
+
{ key: "google", text: "Google", href: links.google },
|
|
43
|
+
{ key: "outlook", text: "Outlook", href: links.outlook },
|
|
44
|
+
{ key: "office365", text: "Microsoft 365", href: links.office365 },
|
|
45
|
+
];
|
|
46
|
+
if (links.ics) options.push({ key: "ics", text: "Apple / .ics", href: links.ics, download: true });
|
|
47
|
+
|
|
48
|
+
const chip: React.CSSProperties = {
|
|
49
|
+
display: "inline-flex",
|
|
50
|
+
alignItems: "center",
|
|
51
|
+
gap: 6,
|
|
52
|
+
padding: compact ? "5px 10px" : "7px 12px",
|
|
53
|
+
fontSize: compact ? 12 : 13,
|
|
54
|
+
fontWeight: 600,
|
|
55
|
+
lineHeight: 1.2,
|
|
56
|
+
color: t.text,
|
|
57
|
+
textDecoration: "none",
|
|
58
|
+
border: `1px solid ${t.border}`,
|
|
59
|
+
borderRadius: t.cornerRadius,
|
|
60
|
+
background: t.surface,
|
|
61
|
+
fontFamily: t.fontFamily,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
data-testid="add-to-calendar"
|
|
67
|
+
className={className}
|
|
68
|
+
style={{ display: "flex", flexDirection: "column", gap: compact ? 6 : 8, ...style }}
|
|
69
|
+
>
|
|
70
|
+
{label && !compact && (
|
|
71
|
+
<span
|
|
72
|
+
style={{
|
|
73
|
+
display: "inline-flex",
|
|
74
|
+
alignItems: "center",
|
|
75
|
+
gap: 6,
|
|
76
|
+
fontSize: 13,
|
|
77
|
+
fontWeight: 700,
|
|
78
|
+
color: t.muted,
|
|
79
|
+
fontFamily: t.fontFamily,
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<CalendarPlus size={15} color={t.primary} /> {label}
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
|
87
|
+
{compact && <CalendarPlus size={15} color={t.primary} style={{ alignSelf: "center" }} />}
|
|
88
|
+
{options.map((o) => (
|
|
89
|
+
<a
|
|
90
|
+
key={o.key}
|
|
91
|
+
href={o.href}
|
|
92
|
+
target="_blank"
|
|
93
|
+
rel="noopener noreferrer"
|
|
94
|
+
data-testid={`add-to-calendar-${o.key}`}
|
|
95
|
+
{...(o.download ? { download: "" } : {})}
|
|
96
|
+
style={chip}
|
|
97
|
+
>
|
|
98
|
+
{o.text}
|
|
99
|
+
</a>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|