@tribe-nest/forge 3.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/data/queries/useCheckouts.ts +84 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -3
- 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 +9 -0
- package/src/types/models.ts +66 -0
- 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/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/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- package/src/utils/ticketOrderOutcome.ts +125 -0
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMutation } from "@tanstack/react-query";
|
|
2
2
|
import { useForge } from "../../provider/ForgeProvider";
|
|
3
|
+
import { bundleCouponRequestBody } from "../../ui/headless/checkout/bundleCoupon";
|
|
3
4
|
import type { CartItem, TicketCartItem } from "../../contexts/CartContext";
|
|
4
5
|
|
|
5
6
|
/** One line of a bundle, in the shape `POST /public/checkouts` expects. */
|
|
@@ -69,7 +70,35 @@ export function cartToCheckoutLines(cartItems: CartItem[], ticketItems: TicketCa
|
|
|
69
70
|
return [...ticketLines, ...productLines];
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
/**
|
|
74
|
+
* One discount that actually came off a bundle — entered or automatic.
|
|
75
|
+
*
|
|
76
|
+
* `discountAmount` is in MAJOR units, unlike everything else the bundle endpoint
|
|
77
|
+
* returns, because it is the same shape every other pillar's checkout answers
|
|
78
|
+
* with. Naming the coupon is what lets an AUTOMATIC bundle discount render as
|
|
79
|
+
* "SUMMER10 — $6.00 off" rather than an unexplained reduction; before the
|
|
80
|
+
* response carried this, only `discountCents` and a `couponId` came back and
|
|
81
|
+
* there is no public `couponId` → code lookup.
|
|
82
|
+
*/
|
|
83
|
+
export type AppliedBundleCoupon = {
|
|
84
|
+
code: string;
|
|
85
|
+
discountKind: string;
|
|
86
|
+
/** MAJOR units. */
|
|
87
|
+
discountAmount: number;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Note the units: the bundle endpoint is the only checkout answering in MINOR units. */
|
|
91
|
+
export type CreateCheckoutResult = {
|
|
92
|
+
checkoutId: string;
|
|
93
|
+
currency: string;
|
|
94
|
+
/** GROSS, minor units. (Beware: `start-payment` returns a NET `subtotalCents`.) */
|
|
95
|
+
subtotalCents: number;
|
|
96
|
+
discountCents?: number;
|
|
97
|
+
couponId?: string | null;
|
|
98
|
+
/** NET = max(0, subtotalCents − discountCents), minor units. */
|
|
99
|
+
totalCents?: number;
|
|
100
|
+
appliedCoupons?: AppliedBundleCoupon[];
|
|
101
|
+
};
|
|
73
102
|
|
|
74
103
|
export function useCreateCheckout() {
|
|
75
104
|
const { client, profileId } = useForge();
|
|
@@ -82,6 +111,60 @@ export function useCreateCheckout() {
|
|
|
82
111
|
});
|
|
83
112
|
}
|
|
84
113
|
|
|
114
|
+
/**
|
|
115
|
+
* What `POST /public/checkouts/apply-coupon` answers with.
|
|
116
|
+
*
|
|
117
|
+
* The payment fields are populated only when the bundle had already started
|
|
118
|
+
* payment: applying re-prices the children AND re-mints the intent, so the
|
|
119
|
+
* caller swaps its payment element onto the new secret rather than re-running
|
|
120
|
+
* start-payment. An empty `paymentSecret` means there was no intent to replace.
|
|
121
|
+
*/
|
|
122
|
+
export type ApplyCheckoutCouponResult = {
|
|
123
|
+
checkoutId: string;
|
|
124
|
+
currency: string;
|
|
125
|
+
/** GROSS, minor units — what the goods cost undiscounted. */
|
|
126
|
+
subtotalCents: number;
|
|
127
|
+
discountCents: number;
|
|
128
|
+
couponId: string | null;
|
|
129
|
+
/** NET = max(0, subtotalCents − discountCents), minor units. */
|
|
130
|
+
totalCents: number;
|
|
131
|
+
appliedCoupons: AppliedBundleCoupon[];
|
|
132
|
+
paymentSecret: string;
|
|
133
|
+
paymentId: string;
|
|
134
|
+
chargedAmount: number;
|
|
135
|
+
chargedCurrency: string;
|
|
136
|
+
/** True when the re-price took the bundle to zero — it is already settled. */
|
|
137
|
+
isFreeCheckout: boolean;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Apply — or REMOVE — a discount code on a bundle that already exists.
|
|
142
|
+
*
|
|
143
|
+
* Omitting `couponCode` removes whatever is on it and restores the original
|
|
144
|
+
* total. This is the bundle's counterpart to `/public/orders/apply-coupon`, and
|
|
145
|
+
* it is why the code field on a bundle no longer has to freeze the moment the
|
|
146
|
+
* checkout is created.
|
|
147
|
+
*/
|
|
148
|
+
export function useApplyCheckoutCoupon() {
|
|
149
|
+
const { client, profileId } = useForge();
|
|
150
|
+
|
|
151
|
+
return useMutation<
|
|
152
|
+
ApplyCheckoutCouponResult,
|
|
153
|
+
unknown,
|
|
154
|
+
{ checkoutId: string; returnUrl: string; couponCode?: string }
|
|
155
|
+
>({
|
|
156
|
+
mutationFn: async ({ checkoutId, returnUrl, couponCode }) => {
|
|
157
|
+
// The body is built by `bundleCouponRequestBody` so the "remove sends no
|
|
158
|
+
// code at all" rule is asserted in one place rather than trusted here.
|
|
159
|
+
const res = await client.post(
|
|
160
|
+
"/public/checkouts/apply-coupon",
|
|
161
|
+
bundleCouponRequestBody({ profileId, checkoutId, returnUrl, couponCode }),
|
|
162
|
+
);
|
|
163
|
+
return res.data;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
85
168
|
export type FinalizeCheckoutResult = {
|
|
86
169
|
checkoutId: string;
|
|
87
170
|
status: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BookingSlot } from "../../types/models";
|
|
1
|
+
import type { BookingSlot, PillarDiscountQuote } from "../../types/models";
|
|
2
2
|
import { useForge } from "../../provider/ForgeProvider";
|
|
3
3
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
4
4
|
|
|
@@ -40,13 +40,28 @@ export type UpdateCoachingBookingInput = {
|
|
|
40
40
|
confirmIfFree?: boolean;
|
|
41
41
|
questionnaire?: unknown;
|
|
42
42
|
attributionRefId?: string;
|
|
43
|
+
/** Omit (or send `undefined`) to CLEAR a previously applied code. */
|
|
44
|
+
couponCode?: string;
|
|
43
45
|
};
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
export type UpdateCoachingBookingResult = PillarDiscountQuote & {
|
|
48
|
+
bookingId: string;
|
|
49
|
+
isConfirmed: boolean;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Attach buyer details to a reserved coaching booking (confirms it if free) and
|
|
54
|
+
* (re-)price it.
|
|
55
|
+
*
|
|
56
|
+
* This doubles as the coaching pillar's quote: the gross price is re-derived
|
|
57
|
+
* from the PRODUCT on every call, so applying and then removing a discount code
|
|
58
|
+
* returns the buyer to exactly the original total instead of compounding off an
|
|
59
|
+
* already-netted figure. Only a still-`reserved` booking is re-priced.
|
|
60
|
+
*/
|
|
46
61
|
export function useUpdateCoachingBooking(productId?: string) {
|
|
47
62
|
const { client } = useForge();
|
|
48
63
|
|
|
49
|
-
return useMutation<
|
|
64
|
+
return useMutation<UpdateCoachingBookingResult, unknown, UpdateCoachingBookingInput>({
|
|
50
65
|
mutationFn: async (body) => {
|
|
51
66
|
const res = await client.post(`/public/coaching/products/${productId}/booking/update`, body);
|
|
52
67
|
return res.data;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PaginatedData, PublicCourse } from "../../types/models";
|
|
1
|
+
import type { PaginatedData, PillarDiscountQuote, PublicCourse } from "../../types/models";
|
|
2
2
|
import { useForge } from "../../provider/ForgeProvider";
|
|
3
3
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
4
4
|
|
|
@@ -10,6 +10,47 @@ export type CreateCourseBookingInput = {
|
|
|
10
10
|
attributionRefId?: string;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
export type UpdateCourseBookingInput = {
|
|
14
|
+
bookingId: string;
|
|
15
|
+
email: string;
|
|
16
|
+
firstName: string;
|
|
17
|
+
lastName: string;
|
|
18
|
+
/** Confirm the booking outright when nothing is left to charge. */
|
|
19
|
+
confirmIfFree?: boolean;
|
|
20
|
+
questionnaire?: unknown;
|
|
21
|
+
attributionRefId?: string;
|
|
22
|
+
/** Omit (or send `undefined`) to CLEAR a previously applied code. */
|
|
23
|
+
couponCode?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type UpdateCourseBookingResult = PillarDiscountQuote & {
|
|
27
|
+
bookingId: string;
|
|
28
|
+
isConfirmed: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Attach buyer details to a course booking and (re-)price it.
|
|
33
|
+
*
|
|
34
|
+
* This is the course pillar's de-facto quote: it re-derives the gross price
|
|
35
|
+
* from the COURSE on every call — never from the booking's already-netted total
|
|
36
|
+
* — specifically so it can be fired repeatedly as the buyer edits the form.
|
|
37
|
+
* That is what makes apply/remove of a discount code safe here, and why
|
|
38
|
+
* removing one restores the original total rather than compounding.
|
|
39
|
+
*
|
|
40
|
+
* Only a still-`reserved` booking is re-priced; a confirmed one keeps the
|
|
41
|
+
* coupon and total it was actually charged at.
|
|
42
|
+
*/
|
|
43
|
+
export function useUpdateCourseBooking(courseId?: string) {
|
|
44
|
+
const { client, profileId } = useForge();
|
|
45
|
+
|
|
46
|
+
return useMutation<UpdateCourseBookingResult, unknown, UpdateCourseBookingInput>({
|
|
47
|
+
mutationFn: async (body) => {
|
|
48
|
+
const res = await client.post(`/public/courses/${courseId}/booking/update`, { profileId, ...body });
|
|
49
|
+
return res.data;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
13
54
|
/** Create a course booking (buyer details → bookingId; free courses skip payment). */
|
|
14
55
|
export function useCreateCourseBooking(courseId?: string) {
|
|
15
56
|
const { client, profileId } = useForge();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IEvent } from "../../types/models";
|
|
1
|
+
import type { AppliedDiscountCoupon, IEvent } from "../../types/models";
|
|
2
2
|
import { useForge } from "../../provider/ForgeProvider";
|
|
3
3
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
4
4
|
|
|
@@ -48,12 +48,26 @@ export type CreateEventOrderInput = {
|
|
|
48
48
|
lastName?: string;
|
|
49
49
|
questionnaire?: unknown;
|
|
50
50
|
attributionRefId?: string;
|
|
51
|
+
/**
|
|
52
|
+
* The code the buyer typed. Tickets have no apply-coupon endpoint — this call
|
|
53
|
+
* IS where a code is redeemed, so a refused one fails the whole request with
|
|
54
|
+
* the reason as its message and no order is created.
|
|
55
|
+
*/
|
|
56
|
+
couponCode?: string;
|
|
51
57
|
};
|
|
52
58
|
|
|
53
59
|
export type CreateEventOrderResult = {
|
|
54
60
|
orderId: string;
|
|
55
61
|
isFreeCheckout?: boolean;
|
|
62
|
+
/** NET of any discount. */
|
|
56
63
|
totalAmount?: number;
|
|
64
|
+
// Additive (S.3) — present on every response, discounted or not.
|
|
65
|
+
/** GROSS, before any discount. */
|
|
66
|
+
subTotal?: number;
|
|
67
|
+
discountAmount?: number;
|
|
68
|
+
couponId?: string | null;
|
|
69
|
+
/** Every discount that applied, entered OR automatic. */
|
|
70
|
+
appliedCoupons?: AppliedDiscountCoupon[];
|
|
57
71
|
};
|
|
58
72
|
|
|
59
73
|
/** Create an event ticket order (the step before start-payment). */
|
|
@@ -76,5 +76,17 @@ export function usePaymentFlow(opts: UsePaymentFlowOptions) {
|
|
|
76
76
|
startedRef.current = true;
|
|
77
77
|
return mutateAsync(override);
|
|
78
78
|
},
|
|
79
|
+
/**
|
|
80
|
+
* Forget the last result so nothing downstream keeps quoting it.
|
|
81
|
+
*
|
|
82
|
+
* `mutation.data` outlives the thing it described: drop a discount code and
|
|
83
|
+
* the charge that code produced is still sitting here, so a summary reading
|
|
84
|
+
* `result.totalAmount` would go on showing the discounted figure against an
|
|
85
|
+
* order that no longer has the discount.
|
|
86
|
+
*/
|
|
87
|
+
reset: () => {
|
|
88
|
+
startedRef.current = false;
|
|
89
|
+
mutation.reset();
|
|
90
|
+
},
|
|
79
91
|
};
|
|
80
92
|
}
|
|
@@ -29,6 +29,12 @@ export interface SiteConfig {
|
|
|
29
29
|
* installable manifest + head tags. `null` when the creator has no pwaConfig
|
|
30
30
|
* (minimal name-only manifest, not installable). Screenshots are optional. */
|
|
31
31
|
pwa?: PwaConfig | null;
|
|
32
|
+
/** Suppresses the "Powered by TribeNest" badge on released builds. Server-side
|
|
33
|
+
* on purpose: the tenant owns `__root.tsx`, so anything expressed as a prop is
|
|
34
|
+
* an opt-out. Absent/false today — this is the lever a white-label plan
|
|
35
|
+
* entitlement flips, and it works without waiting for a Forge bump to reach
|
|
36
|
+
* every site. */
|
|
37
|
+
hideTribeNestBadge?: boolean;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
/** The PWA identity block on `site-config` (mirrors `pwaConfig` on the profile). */
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,15 @@ export {
|
|
|
37
37
|
export type { AuthActions } from "./contexts/PublicAuthContext";
|
|
38
38
|
export { PUBLIC_REFRESH_TOKEN_KEY, APP_REFRESH_TOKEN_KEY } from "./client/tokenStorage";
|
|
39
39
|
export { safeRedirectPath } from "./utils/safeRedirect";
|
|
40
|
+
// Ticket-confirmation copy decision — shared so the Forge block and the client
|
|
41
|
+
// app's finalise page can never tell a refunded buyer two different stories.
|
|
42
|
+
export {
|
|
43
|
+
getTicketOrderOutcome,
|
|
44
|
+
isTicketOrderRefunded,
|
|
45
|
+
TICKET_ORDER_REFUNDED_COPY,
|
|
46
|
+
type TicketOrderOutcome,
|
|
47
|
+
type TicketOrderOutcomeInput,
|
|
48
|
+
} from "./utils/ticketOrderOutcome";
|
|
40
49
|
// App-user auth (mini-apps) + the /admin guard.
|
|
41
50
|
export {
|
|
42
51
|
useAppAuth,
|
package/src/types/models.ts
CHANGED
|
@@ -88,6 +88,44 @@ export type PaymentFlowResult = PaymentStartResponse & {
|
|
|
88
88
|
provider?: PaymentProviderName;
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* One coupon that ACTUALLY applied to a checkout, exactly as the ticket,
|
|
93
|
+
* booking and course pillars report it. Entered codes and automatic (no-code)
|
|
94
|
+
* discounts are reported identically — which is the only way a buyer ever finds
|
|
95
|
+
* out an automatic discount happened, since nothing else names it.
|
|
96
|
+
*
|
|
97
|
+
* `discountKind` is the engine's kind (`simple`, `free_shipping`, `bogo`,
|
|
98
|
+
* `volume`, `membership_grant`), kept as a string so a new kind added on the
|
|
99
|
+
* server does not fail to type-check here.
|
|
100
|
+
*/
|
|
101
|
+
export type AppliedDiscountCoupon = {
|
|
102
|
+
code: string;
|
|
103
|
+
discountKind: string;
|
|
104
|
+
/** MAJOR units — this coupon's own contribution, not the running total. */
|
|
105
|
+
discountAmount: number;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What a pillar's pre-payment call reports about pricing after discounts.
|
|
110
|
+
* `POST /public/events/:id/orders`, `POST /public/coaching/products/:id/booking/update`
|
|
111
|
+
* and `POST /public/courses/:id/booking/update` all return this same shape
|
|
112
|
+
* (S.3, `89a721af`).
|
|
113
|
+
*
|
|
114
|
+
* Every figure here is the SERVER'S, in major units. Nothing in the UI
|
|
115
|
+
* recomputes a discount: a buyer-visible number that disagrees with the charge
|
|
116
|
+
* is worse than no number at all.
|
|
117
|
+
*/
|
|
118
|
+
export type PillarDiscountQuote = {
|
|
119
|
+
/** GROSS, before any discount. */
|
|
120
|
+
subTotal: number;
|
|
121
|
+
/** NET — what the buyer is being asked to pay. */
|
|
122
|
+
totalAmount: number;
|
|
123
|
+
/** The whole discount, summed across `appliedCoupons`. */
|
|
124
|
+
discountAmount: number;
|
|
125
|
+
couponId: string | null;
|
|
126
|
+
appliedCoupons: AppliedDiscountCoupon[];
|
|
127
|
+
};
|
|
128
|
+
|
|
91
129
|
export enum ProductDeliveryType {
|
|
92
130
|
Digital = "digital",
|
|
93
131
|
Physical = "physical",
|
|
@@ -813,6 +851,23 @@ export interface IEvent {
|
|
|
813
851
|
id: string;
|
|
814
852
|
profileId: string;
|
|
815
853
|
dateTime: string;
|
|
854
|
+
/**
|
|
855
|
+
* When the event ends. Nullable — plenty of events are published with only a
|
|
856
|
+
* start. Calendar links fall back to a default duration when it is absent.
|
|
857
|
+
*/
|
|
858
|
+
endDateTime: string | null;
|
|
859
|
+
/** Doors/arrival time, when the host publishes one separately from the start. */
|
|
860
|
+
doorsOpenAt: string | null;
|
|
861
|
+
/**
|
|
862
|
+
* Event lifecycle (mirrors the backend `EVENT_STATUS` const — Forge cannot
|
|
863
|
+
* import from the API, so the union is restated here).
|
|
864
|
+
* `draft` never reaches a public surface.
|
|
865
|
+
*/
|
|
866
|
+
status: "draft" | "scheduled" | "on_sale" | "sold_out" | "cancelled" | "completed";
|
|
867
|
+
/** Set when the host cancels; distinct from `archivedAt` (a hide-from-lists flag). */
|
|
868
|
+
cancelledAt: string | null;
|
|
869
|
+
/** Host-supplied reason shown to ticket-holders on a cancelled event. */
|
|
870
|
+
cancellationReason: string | null;
|
|
816
871
|
timezone?: string;
|
|
817
872
|
type: "physical" | "virtual" | "hybrid";
|
|
818
873
|
ticketSaleMessage?: string;
|
|
@@ -1093,6 +1148,17 @@ export type ITicketOrder = {
|
|
|
1093
1148
|
taxInclusive?: boolean;
|
|
1094
1149
|
currency?: string;
|
|
1095
1150
|
status: OrderStatus;
|
|
1151
|
+
/**
|
|
1152
|
+
* Refund tracking, written by the refund service and returned by the public
|
|
1153
|
+
* finalize endpoint (`getOrderById` is `selectAll` over the order row).
|
|
1154
|
+
*
|
|
1155
|
+
* These are what tell a refund-voided `cancelled` order apart from an
|
|
1156
|
+
* abandoned one — `status` alone cannot. See `getTicketOrderOutcome`.
|
|
1157
|
+
* `refundedAmountCents` is a bigint column, so it arrives as a string.
|
|
1158
|
+
*/
|
|
1159
|
+
refundedAmountCents?: number | string | null;
|
|
1160
|
+
refundState?: "none" | "partial" | "full" | string | null;
|
|
1161
|
+
lastRefundedAt?: string | null;
|
|
1096
1162
|
customerName: string;
|
|
1097
1163
|
customerEmail: string;
|
|
1098
1164
|
createdAt: string;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Add to calendar" link building — the whole of it. Styled components render
|
|
5
|
+
* anchors; they never assemble a URL themselves, so a fix to (say) Google's
|
|
6
|
+
* `dates` format lands in exactly one place.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is framework-free and dependency-free: Forge ships no date
|
|
9
|
+
* library, so the two formats the providers want are derived from the platform
|
|
10
|
+
* `Date` alone.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type AddToCalendarInput = {
|
|
14
|
+
/** Event/session name — becomes the calendar entry's title. */
|
|
15
|
+
title: string;
|
|
16
|
+
/** Absolute instant the thing starts (ISO-8601 with an offset or `Z`). */
|
|
17
|
+
start: string | Date;
|
|
18
|
+
/** Absolute instant it ends. Nullable — falls back to `start + defaultDurationMinutes`. */
|
|
19
|
+
end?: string | Date | null;
|
|
20
|
+
/** Fallback length when `end` is absent. Defaults to 120 minutes. */
|
|
21
|
+
defaultDurationMinutes?: number;
|
|
22
|
+
/** Long description. HTML is stripped and truncated before it goes in a URL. */
|
|
23
|
+
description?: string | null;
|
|
24
|
+
/** Human-readable place (a street address, or "Virtual"). */
|
|
25
|
+
location?: string | null;
|
|
26
|
+
/** Canonical page for the event; appended to the description body when present. */
|
|
27
|
+
url?: string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Absolute URL to a downloadable `.ics` (what Apple Calendar / Outlook desktop
|
|
30
|
+
* want). Forge does not synthesize this — the host passes the endpoint it has.
|
|
31
|
+
* When absent, `ics` comes back `null` and no Apple option is offered.
|
|
32
|
+
*/
|
|
33
|
+
icsUrl?: string | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type AddToCalendarLinks = {
|
|
37
|
+
/** Google Calendar "TEMPLATE" prefill. */
|
|
38
|
+
google: string;
|
|
39
|
+
/** Outlook.com (personal accounts). */
|
|
40
|
+
outlook: string;
|
|
41
|
+
/** Outlook on the web for work/school accounts (Microsoft 365). */
|
|
42
|
+
office365: string;
|
|
43
|
+
/** Yahoo Calendar. */
|
|
44
|
+
yahoo: string;
|
|
45
|
+
/** Pass-through of `icsUrl` — the Apple/desktop route. `null` when not supplied. */
|
|
46
|
+
ics: string | null;
|
|
47
|
+
startsAt: Date;
|
|
48
|
+
endsAt: Date;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Length of a calendar entry when the caller has no end time. */
|
|
52
|
+
const DEFAULT_DURATION_MINUTES = 120;
|
|
53
|
+
|
|
54
|
+
/** Calendar providers put the description in a URL; keep it well under limits. */
|
|
55
|
+
const MAX_DESCRIPTION_CHARS = 800;
|
|
56
|
+
|
|
57
|
+
/** Parse anything the caller has to a valid `Date`, or `null` if it isn't one. */
|
|
58
|
+
function toDate(value: string | Date | null | undefined): Date | null {
|
|
59
|
+
if (value === null || value === undefined || value === "") return null;
|
|
60
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
61
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* UTC "basic format" (RFC 5545 / what Google and Yahoo want):
|
|
66
|
+
* `2026-03-15T06:30:00.000Z` → `20260315T063000Z`.
|
|
67
|
+
*/
|
|
68
|
+
export function toUtcBasic(d: Date): string {
|
|
69
|
+
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Flatten an HTML description to plain text. Event/session descriptions in this
|
|
74
|
+
* codebase are rich text rendered with `dangerouslySetInnerHTML`, so the raw
|
|
75
|
+
* value is markup — pasting it into a calendar body would show tags.
|
|
76
|
+
*
|
|
77
|
+
* `&` is decoded LAST so an encoded entity (`&lt;`) doesn't turn into a
|
|
78
|
+
* live one.
|
|
79
|
+
*/
|
|
80
|
+
export function stripHtml(html: string, maxLength: number = MAX_DESCRIPTION_CHARS): string {
|
|
81
|
+
const text = html
|
|
82
|
+
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
|
|
83
|
+
.replace(/<\s*\/\s*(p|div|li|tr|h[1-6])\s*>/gi, "\n")
|
|
84
|
+
.replace(/<[^>]*>/g, "")
|
|
85
|
+
.replace(/ /gi, " ")
|
|
86
|
+
.replace(/'/g, "'")
|
|
87
|
+
.replace(/"/gi, '"')
|
|
88
|
+
.replace(/</gi, "<")
|
|
89
|
+
.replace(/>/gi, ">")
|
|
90
|
+
.replace(/&/gi, "&")
|
|
91
|
+
.replace(/[ \t]+/g, " ")
|
|
92
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
93
|
+
.trim();
|
|
94
|
+
|
|
95
|
+
return text.length > maxLength ? `${text.slice(0, maxLength - 1).trimEnd()}…` : text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const enc = encodeURIComponent;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build every provider URL for one calendar entry. Returns `null` when the
|
|
102
|
+
* start is missing or unparseable — callers render nothing rather than a link
|
|
103
|
+
* that would prefill garbage.
|
|
104
|
+
*/
|
|
105
|
+
export function buildAddToCalendarLinks(input: AddToCalendarInput): AddToCalendarLinks | null {
|
|
106
|
+
const startsAt = toDate(input.start);
|
|
107
|
+
if (!startsAt) return null;
|
|
108
|
+
|
|
109
|
+
const durationMinutes =
|
|
110
|
+
input.defaultDurationMinutes && input.defaultDurationMinutes > 0
|
|
111
|
+
? input.defaultDurationMinutes
|
|
112
|
+
: DEFAULT_DURATION_MINUTES;
|
|
113
|
+
|
|
114
|
+
const parsedEnd = toDate(input.end);
|
|
115
|
+
// An end at-or-before the start is as useless as no end at all.
|
|
116
|
+
const endsAt =
|
|
117
|
+
parsedEnd && parsedEnd.getTime() > startsAt.getTime()
|
|
118
|
+
? parsedEnd
|
|
119
|
+
: new Date(startsAt.getTime() + durationMinutes * 60_000);
|
|
120
|
+
|
|
121
|
+
const title = (input.title || "").trim();
|
|
122
|
+
const location = (input.location || "").trim();
|
|
123
|
+
|
|
124
|
+
const body = [input.description ? stripHtml(input.description) : "", (input.url || "").trim()]
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.join("\n\n");
|
|
127
|
+
|
|
128
|
+
const startBasic = toUtcBasic(startsAt);
|
|
129
|
+
const endBasic = toUtcBasic(endsAt);
|
|
130
|
+
const startIso = startsAt.toISOString();
|
|
131
|
+
const endIso = endsAt.toISOString();
|
|
132
|
+
|
|
133
|
+
const outlookQuery =
|
|
134
|
+
`path=${enc("/calendar/action/compose")}` +
|
|
135
|
+
`&rru=addevent` +
|
|
136
|
+
`&subject=${enc(title)}` +
|
|
137
|
+
`&startdt=${enc(startIso)}` +
|
|
138
|
+
`&enddt=${enc(endIso)}` +
|
|
139
|
+
`&body=${enc(body)}` +
|
|
140
|
+
`&location=${enc(location)}`;
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
google:
|
|
144
|
+
`https://calendar.google.com/calendar/render?action=TEMPLATE` +
|
|
145
|
+
`&text=${enc(title)}` +
|
|
146
|
+
`&dates=${startBasic}/${endBasic}` +
|
|
147
|
+
`&details=${enc(body)}` +
|
|
148
|
+
`&location=${enc(location)}`,
|
|
149
|
+
outlook: `https://outlook.live.com/calendar/0/deeplink/compose?${outlookQuery}`,
|
|
150
|
+
office365: `https://outlook.office.com/calendar/0/deeplink/compose?${outlookQuery}`,
|
|
151
|
+
yahoo:
|
|
152
|
+
`https://calendar.yahoo.com/?v=60` +
|
|
153
|
+
`&title=${enc(title)}` +
|
|
154
|
+
`&st=${startBasic}` +
|
|
155
|
+
`&et=${endBasic}` +
|
|
156
|
+
`&desc=${enc(body)}` +
|
|
157
|
+
`&in_loc=${enc(location)}`,
|
|
158
|
+
ics: input.icsUrl || null,
|
|
159
|
+
startsAt,
|
|
160
|
+
endsAt,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* React wrapper over {@link buildAddToCalendarLinks}. Memoized on the primitive
|
|
166
|
+
* inputs so an anchor's `href` is referentially stable between renders.
|
|
167
|
+
*
|
|
168
|
+
* Returns `null` for an unusable start time — safe to call unconditionally with
|
|
169
|
+
* data that may still be loading.
|
|
170
|
+
*/
|
|
171
|
+
export function useAddToCalendar(input: AddToCalendarInput | null | undefined): AddToCalendarLinks | null {
|
|
172
|
+
const title = input?.title;
|
|
173
|
+
const start = input?.start;
|
|
174
|
+
const end = input?.end;
|
|
175
|
+
const defaultDurationMinutes = input?.defaultDurationMinutes;
|
|
176
|
+
const description = input?.description;
|
|
177
|
+
const location = input?.location;
|
|
178
|
+
const url = input?.url;
|
|
179
|
+
const icsUrl = input?.icsUrl;
|
|
180
|
+
|
|
181
|
+
return useMemo(() => {
|
|
182
|
+
if (!start) return null;
|
|
183
|
+
return buildAddToCalendarLinks({
|
|
184
|
+
title: title ?? "",
|
|
185
|
+
start,
|
|
186
|
+
end,
|
|
187
|
+
defaultDurationMinutes,
|
|
188
|
+
description,
|
|
189
|
+
location,
|
|
190
|
+
url,
|
|
191
|
+
icsUrl,
|
|
192
|
+
});
|
|
193
|
+
}, [title, start, end, defaultDurationMinutes, description, location, url, icsUrl]);
|
|
194
|
+
}
|