@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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/useCheckouts.ts +84 -1
  3. package/src/data/queries/useCoachingAvailability.ts +18 -3
  4. package/src/data/queries/useCourses.ts +42 -1
  5. package/src/data/queries/useEvents.ts +15 -1
  6. package/src/data/queries/usePaymentFlow.ts +12 -0
  7. package/src/data/queries/useWebsite.ts +6 -0
  8. package/src/index.ts +9 -0
  9. package/src/types/models.ts +66 -0
  10. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  11. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  12. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  13. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  14. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  15. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  16. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  17. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  18. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  19. package/src/ui/headless/index.ts +15 -0
  20. package/src/ui/index.ts +7 -0
  21. package/src/ui/shell/PoweredBy.tsx +60 -0
  22. package/src/ui/shell/TribeNestApp.tsx +15 -1
  23. package/src/ui/shell/shellGating.spec.ts +21 -1
  24. package/src/ui/shell/shellGating.ts +14 -0
  25. package/src/ui/styled/AddToCalendar.tsx +104 -0
  26. package/src/ui/styled/Checkout.tsx +45 -14
  27. package/src/ui/styled/CoachingBooking.tsx +28 -8
  28. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  29. package/src/ui/styled/CourseCheckout.tsx +49 -18
  30. package/src/ui/styled/DiscountCode.tsx +206 -0
  31. package/src/ui/styled/EventConfirmation.tsx +68 -22
  32. package/src/ui/styled/EventDetail.tsx +18 -5
  33. package/src/ui/styled/EventTickets.tsx +49 -5
  34. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  35. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  36. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  37. package/src/utils/ticketOrderOutcome.ts +125 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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
- export type CreateCheckoutResult = { checkoutId: string; currency: string; subtotalCents: number };
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
- /** Attach buyer details to a reserved coaching booking (confirms it if free). */
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<{ bookingId: string; isConfirmed: boolean }, unknown, UpdateCoachingBookingInput>({
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,
@@ -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
+ * `&amp;` is decoded LAST so an encoded entity (`&amp;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(/&nbsp;/gi, " ")
86
+ .replace(/&#39;/g, "'")
87
+ .replace(/&quot;/gi, '"')
88
+ .replace(/&lt;/gi, "<")
89
+ .replace(/&gt;/gi, ">")
90
+ .replace(/&amp;/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
+ }