@tribe-nest/forge 1.20.2 → 2.2.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/contexts/AppAuthContext.tsx +26 -0
- package/src/contexts/CartContext.tsx +132 -8
- package/src/data/queries/useCheckouts.ts +101 -0
- package/src/data/queries/useCollections.ts +24 -4
- package/src/data/queries/useFinalize.ts +41 -0
- package/src/data/queries/usePageActions.ts +2 -0
- package/src/index.ts +6 -1
- package/src/server/_tests/appUserPermissions.spec.ts +197 -0
- package/src/server/appAuth.ts +37 -2
- package/src/server/appUsers.ts +133 -0
- package/src/server/index.ts +17 -0
- package/src/server/jobs.ts +141 -6
- package/src/server/platform.ts +208 -0
- package/src/types/models.ts +26 -2
- package/src/ui/headless/checkout/useCheckout.ts +56 -9
- package/src/ui/headless/event/useEventCheckout.ts +36 -0
- package/src/ui/headless/funnel/Funnel.tsx +159 -0
- package/src/ui/headless/funnel/funnelSession.spec.ts +108 -0
- package/src/ui/headless/funnel/funnelSession.ts +88 -0
- package/src/ui/headless/funnel/index.ts +3 -0
- package/src/ui/headless/funnel/useFunnelStep.ts +70 -0
- package/src/ui/headless/index.ts +3 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/styled/Addons.tsx +77 -0
- package/src/ui/styled/BundleConfirmation.tsx +161 -0
- package/src/ui/styled/Cart.tsx +66 -9
- package/src/ui/styled/CheckoutConfirmation.tsx +25 -1
- package/src/ui/styled/EventTickets.tsx +52 -7
- package/src/ui/styled/PageActions.tsx +34 -3
- package/src/ui/styled/ProductGrid.tsx +45 -9
- package/src/utils/formatDateTime.ts +25 -0
- package/src/utils/headMeta.ts +50 -0
|
@@ -16,12 +16,19 @@ import { readableTextOn } from "../theme/contrast";
|
|
|
16
16
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
17
17
|
import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, Perforation, ConfirmationRow, alpha } from "./Confirmation";
|
|
18
18
|
import { useOrderFinalize } from "../../data/queries/useFinalize";
|
|
19
|
+
import { BundleConfirmation } from "./BundleConfirmation";
|
|
19
20
|
import { useCart } from "../../contexts/CartContext";
|
|
20
21
|
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
21
22
|
import { OrderStatus, ProductDeliveryType, type IPublicOrder } from "../../types/models";
|
|
22
23
|
|
|
23
24
|
export interface CheckoutConfirmationProps {
|
|
24
25
|
orderId?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Set instead of `orderId` when the payment was a BUNDLE — one intent across
|
|
28
|
+
* tickets and products. Delegates to `<BundleConfirmation>`, which finalizes
|
|
29
|
+
* every surface in one call.
|
|
30
|
+
*/
|
|
31
|
+
checkoutId?: string;
|
|
25
32
|
/**
|
|
26
33
|
* The Stripe `redirect_status` appended to the return URL (`succeeded` /
|
|
27
34
|
* `failed` / `processing`). When `failed`, the page shows the failed state
|
|
@@ -97,7 +104,24 @@ function statusIcon(status: OrderStatus, color: string) {
|
|
|
97
104
|
* status), still-processing (polls the gateway webhook race), or failed/not-found
|
|
98
105
|
* — with the full order breakdown (delivery groups, gifts, variants, totals).
|
|
99
106
|
*/
|
|
100
|
-
export function CheckoutConfirmation({
|
|
107
|
+
export function CheckoutConfirmation(props: CheckoutConfirmationProps) {
|
|
108
|
+
// A bundle settles every surface through one endpoint, so it gets its own
|
|
109
|
+
// confirmation rather than threading a second shape through the order one.
|
|
110
|
+
if (props.checkoutId) {
|
|
111
|
+
return (
|
|
112
|
+
<BundleConfirmation
|
|
113
|
+
checkoutId={props.checkoutId}
|
|
114
|
+
redirectStatus={props.redirectStatus}
|
|
115
|
+
explorePath={props.explorePath}
|
|
116
|
+
accountPath={props.accountPath}
|
|
117
|
+
checkoutPath={props.checkoutPath}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return <OrderConfirmation {...props} />;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function OrderConfirmation({
|
|
101
125
|
orderId,
|
|
102
126
|
redirectStatus,
|
|
103
127
|
formatAmount,
|
|
@@ -101,7 +101,17 @@ export function EventTickets({
|
|
|
101
101
|
|
|
102
102
|
{c.isLoading && <Loading />}
|
|
103
103
|
{!c.isLoading && !c.event && <p style={{ marginTop: 8 }}>Event not found.</p>}
|
|
104
|
-
{c.event &&
|
|
104
|
+
{c.event && (
|
|
105
|
+
<EventTicketsBody
|
|
106
|
+
c={c}
|
|
107
|
+
event={c.event}
|
|
108
|
+
fmt={fmt}
|
|
109
|
+
renderPayment={renderPay}
|
|
110
|
+
// Selection is safe in the cart now — close so the buyer can go
|
|
111
|
+
// browse the add-ons.
|
|
112
|
+
onAddedToCart={() => setOpen(false)}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
105
115
|
</DialogContent>
|
|
106
116
|
</DialogPortal>
|
|
107
117
|
</DialogRoot>
|
|
@@ -115,17 +125,19 @@ function EventTicketsBody({
|
|
|
115
125
|
event,
|
|
116
126
|
fmt,
|
|
117
127
|
renderPayment,
|
|
128
|
+
onAddedToCart,
|
|
118
129
|
}: {
|
|
119
130
|
c: Checkout;
|
|
120
131
|
event: IEvent;
|
|
121
132
|
fmt: (n: number) => string;
|
|
122
133
|
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
134
|
+
onAddedToCart?: () => void;
|
|
123
135
|
}) {
|
|
124
136
|
const stepIndex = c.step === "tickets" ? 0 : c.step === "details" ? 1 : 2;
|
|
125
137
|
return (
|
|
126
138
|
<div style={{ width: "100%" }}>
|
|
127
139
|
<Progress current={stepIndex} />
|
|
128
|
-
{c.step === "tickets" && <TicketStep c={c} event={event} fmt={fmt} />}
|
|
140
|
+
{c.step === "tickets" && <TicketStep c={c} event={event} fmt={fmt} onAddedToCart={onAddedToCart} />}
|
|
129
141
|
{c.step === "details" && <DetailsStep c={c} event={event} fmt={fmt} />}
|
|
130
142
|
{c.step === "payment" && <PaymentStep c={c} fmt={fmt} renderPayment={renderPayment} />}
|
|
131
143
|
</div>
|
|
@@ -174,7 +186,17 @@ function Progress({ current }: { current: number }) {
|
|
|
174
186
|
}
|
|
175
187
|
|
|
176
188
|
// ── Step 1: ticket selection (with live expiry/urgency + sold-out/max) ────────
|
|
177
|
-
function TicketStep({
|
|
189
|
+
function TicketStep({
|
|
190
|
+
c,
|
|
191
|
+
event,
|
|
192
|
+
fmt,
|
|
193
|
+
onAddedToCart,
|
|
194
|
+
}: {
|
|
195
|
+
c: Checkout;
|
|
196
|
+
event: IEvent;
|
|
197
|
+
fmt: (n: number) => string;
|
|
198
|
+
onAddedToCart?: () => void;
|
|
199
|
+
}) {
|
|
178
200
|
const theme = useForgeTheme();
|
|
179
201
|
// Inclusive stores caption ticket prices as tax-inclusive (display only).
|
|
180
202
|
const pricesIncludeTax = usePricesIncludeTax();
|
|
@@ -311,7 +333,21 @@ function TicketStep({ c, event, fmt }: { c: Checkout; event: IEvent; fmt: (n: nu
|
|
|
311
333
|
</div>
|
|
312
334
|
|
|
313
335
|
{c.error && <p style={{ color: "#ef4444", fontSize: 14, marginTop: 12 }}>{c.error}</p>}
|
|
314
|
-
<ActionBar
|
|
336
|
+
<ActionBar
|
|
337
|
+
c={c}
|
|
338
|
+
fmt={fmt}
|
|
339
|
+
onNext={c.goToDetails}
|
|
340
|
+
nextLabel="Continue"
|
|
341
|
+
// Buying tickets alone still goes straight down the untouched
|
|
342
|
+
// per-event flow; this exit exists so the buyer can go add merch and
|
|
343
|
+
// come back with the selection intact.
|
|
344
|
+
secondary={{
|
|
345
|
+
label: c.ticketsInCart ? "Update cart" : "Add to cart",
|
|
346
|
+
onClick: () => {
|
|
347
|
+
if (c.addTicketsToCart()) onAddedToCart?.();
|
|
348
|
+
},
|
|
349
|
+
}}
|
|
350
|
+
/>
|
|
315
351
|
</div>
|
|
316
352
|
);
|
|
317
353
|
}
|
|
@@ -469,6 +505,7 @@ function ActionBar({
|
|
|
469
505
|
onNext,
|
|
470
506
|
nextLabel,
|
|
471
507
|
nextDisabled,
|
|
508
|
+
secondary,
|
|
472
509
|
}: {
|
|
473
510
|
c: Checkout;
|
|
474
511
|
fmt: (n: number) => string;
|
|
@@ -476,6 +513,8 @@ function ActionBar({
|
|
|
476
513
|
onNext: () => void;
|
|
477
514
|
nextLabel: string;
|
|
478
515
|
nextDisabled?: boolean;
|
|
516
|
+
/** Extra exit shown next to Back — "Add to cart" on the selection step. */
|
|
517
|
+
secondary?: { label: string; onClick: () => void };
|
|
479
518
|
}) {
|
|
480
519
|
const theme = useForgeTheme();
|
|
481
520
|
return (
|
|
@@ -495,9 +534,15 @@ function ActionBar({
|
|
|
495
534
|
<span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(c.totalAmount)}</span>
|
|
496
535
|
</div>
|
|
497
536
|
<div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
537
|
+
{secondary ? (
|
|
538
|
+
<button data-testid="tickets-add-to-cart" onClick={secondary.onClick} style={secondaryButtonStyle(theme)}>
|
|
539
|
+
{secondary.label}
|
|
540
|
+
</button>
|
|
541
|
+
) : (
|
|
542
|
+
<button onClick={onBack} disabled={!onBack} style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}>
|
|
543
|
+
Back
|
|
544
|
+
</button>
|
|
545
|
+
)}
|
|
501
546
|
<button onClick={onNext} disabled={nextDisabled} style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}>
|
|
502
547
|
{nextLabel}
|
|
503
548
|
</button>
|
|
@@ -5,6 +5,7 @@ import { readableTextOn } from "../theme/contrast";
|
|
|
5
5
|
import { EmailListForm } from "./EmailListForm";
|
|
6
6
|
import { OfferButton } from "./OfferButton";
|
|
7
7
|
import { ProductGrid } from "./ProductGrid";
|
|
8
|
+
import { Addons } from "./Addons";
|
|
8
9
|
import { DonationButton } from "./DonationButton";
|
|
9
10
|
import { MembershipTiers } from "./MembershipTiers";
|
|
10
11
|
|
|
@@ -12,11 +13,24 @@ export interface PageActionsProps {
|
|
|
12
13
|
pageType: string;
|
|
13
14
|
entityId?: string;
|
|
14
15
|
placement?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Where product detail lives on this site — `/i/store` on code sites,
|
|
18
|
+
* `/products` on Craft ones. Only the `addons` block uses it.
|
|
19
|
+
*/
|
|
20
|
+
productBasePath?: string;
|
|
15
21
|
className?: string;
|
|
16
22
|
style?: CSSProperties;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
|
-
function ActionRenderer({
|
|
25
|
+
function ActionRenderer({
|
|
26
|
+
action,
|
|
27
|
+
entityId,
|
|
28
|
+
productBasePath,
|
|
29
|
+
}: {
|
|
30
|
+
action: PageActionDescriptor;
|
|
31
|
+
entityId?: string;
|
|
32
|
+
productBasePath?: string;
|
|
33
|
+
}) {
|
|
20
34
|
const theme = useForgeTheme();
|
|
21
35
|
const c = action.config as Record<string, any>;
|
|
22
36
|
|
|
@@ -55,6 +69,16 @@ function ActionRenderer({ action }: { action: PageActionDescriptor }) {
|
|
|
55
69
|
<ProductGrid columns={c.columns} limit={c.limit} />
|
|
56
70
|
</div>
|
|
57
71
|
);
|
|
72
|
+
case "addons":
|
|
73
|
+
return (
|
|
74
|
+
<Addons
|
|
75
|
+
entityId={entityId}
|
|
76
|
+
productIds={c.productIds ?? []}
|
|
77
|
+
title={c.title}
|
|
78
|
+
columns={c.columns}
|
|
79
|
+
productBasePath={productBasePath}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
58
82
|
case "donation":
|
|
59
83
|
return <DonationButton donationId={c.donationId} text={c.text} />;
|
|
60
84
|
case "membership":
|
|
@@ -106,7 +130,14 @@ const RESPONSIVE_CSS = `
|
|
|
106
130
|
* AI-code-built sites) — each descriptor maps to an existing Forge primitive.
|
|
107
131
|
* Renders nothing when the slot is empty.
|
|
108
132
|
*/
|
|
109
|
-
export function PageActions({
|
|
133
|
+
export function PageActions({
|
|
134
|
+
pageType,
|
|
135
|
+
entityId,
|
|
136
|
+
placement = "after_content",
|
|
137
|
+
productBasePath,
|
|
138
|
+
className,
|
|
139
|
+
style,
|
|
140
|
+
}: PageActionsProps) {
|
|
110
141
|
const { data } = usePageActions(pageType, entityId, placement);
|
|
111
142
|
const actions = data?.actions ?? [];
|
|
112
143
|
if (actions.length === 0) return null;
|
|
@@ -116,7 +147,7 @@ export function PageActions({ pageType, entityId, placement = "after_content", c
|
|
|
116
147
|
<style dangerouslySetInnerHTML={{ __html: RESPONSIVE_CSS }} />
|
|
117
148
|
{actions.map((action) => (
|
|
118
149
|
<div key={action.id} className={COMPACT_TYPES.has(action.type) ? "forge-pa-compact" : undefined}>
|
|
119
|
-
<ActionRenderer action={action} />
|
|
150
|
+
<ActionRenderer action={action} entityId={entityId} productBasePath={productBasePath} />
|
|
120
151
|
</div>
|
|
121
152
|
))}
|
|
122
153
|
</div>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useGetProducts } from "../../data/queries/useProducts";
|
|
1
|
+
import { useGetProducts, useGetProductsByIds } from "../../data/queries/useProducts";
|
|
2
2
|
import type { IPublicProduct } from "../../types/models";
|
|
3
3
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
4
4
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
@@ -9,8 +9,22 @@ import { Loading } from "./Loading";
|
|
|
9
9
|
export interface ProductGridProps {
|
|
10
10
|
columns?: number;
|
|
11
11
|
limit?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Show these specific products, in this order, instead of the shop listing.
|
|
14
|
+
* What the add-ons block passes — an organiser's chosen list rather than
|
|
15
|
+
* "the first N products".
|
|
16
|
+
*/
|
|
17
|
+
productIds?: string[];
|
|
12
18
|
formatAmount?: (amount: number) => string;
|
|
13
19
|
onSelect?: (product: IPublicProduct) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Render each card as a link to this href instead of a button. Router-
|
|
22
|
+
* agnostic on purpose: Forge has no router, and the host decides the path
|
|
23
|
+
* (`/i/store/:slug` on code sites, `/products/:slug` on Craft ones).
|
|
24
|
+
*/
|
|
25
|
+
hrefFor?: (product: IPublicProduct) => string;
|
|
26
|
+
/** Message shown in place of the grid when there is nothing to show. */
|
|
27
|
+
emptyLabel?: string;
|
|
14
28
|
className?: string;
|
|
15
29
|
style?: React.CSSProperties;
|
|
16
30
|
}
|
|
@@ -21,15 +35,33 @@ function minPrice(p: IPublicProduct): number {
|
|
|
21
35
|
}
|
|
22
36
|
|
|
23
37
|
/** Themed product grid, built on `useGetProducts`. */
|
|
24
|
-
export function ProductGrid({
|
|
38
|
+
export function ProductGrid({
|
|
39
|
+
columns = 3,
|
|
40
|
+
limit,
|
|
41
|
+
productIds,
|
|
42
|
+
formatAmount,
|
|
43
|
+
onSelect,
|
|
44
|
+
hrefFor,
|
|
45
|
+
emptyLabel,
|
|
46
|
+
className,
|
|
47
|
+
style,
|
|
48
|
+
}: ProductGridProps) {
|
|
25
49
|
const t = useThemeTokens();
|
|
26
50
|
const fmt = useAmountFormatter(formatAmount);
|
|
27
51
|
const pricesIncludeTax = usePricesIncludeTax();
|
|
28
|
-
const
|
|
29
|
-
|
|
52
|
+
const byIds = !!productIds?.length;
|
|
53
|
+
// Both hooks are called unconditionally (rules of hooks); each disables
|
|
54
|
+
// itself when it isn't the one in use.
|
|
55
|
+
const listQuery = useGetProducts({ page: 1 }, !byIds);
|
|
56
|
+
const idsQuery = useGetProductsByIds(byIds ? productIds! : []);
|
|
57
|
+
|
|
58
|
+
const isLoading = byIds ? idsQuery.isLoading : listQuery.isLoading;
|
|
59
|
+
const products = byIds
|
|
60
|
+
? (idsQuery.data ?? [])
|
|
61
|
+
: (listQuery.data?.data ?? []).slice(0, limit ?? 100);
|
|
30
62
|
|
|
31
63
|
if (isLoading) return <Loading />;
|
|
32
|
-
if (!products.length) return <p style={{ color: t.text, opacity: 0.7 }}>No products yet
|
|
64
|
+
if (!products.length) return <p style={{ color: t.text, opacity: 0.7 }}>{emptyLabel ?? "No products yet."}</p>;
|
|
33
65
|
|
|
34
66
|
// Responsive: each card is at least ~150px wide, but also at least
|
|
35
67
|
// (container / columns), so it renders `columns` across on wide screens and
|
|
@@ -50,13 +82,17 @@ export function ProductGrid({ columns = 3, limit, formatAmount, onSelect, classN
|
|
|
50
82
|
>
|
|
51
83
|
{products.map((p) => {
|
|
52
84
|
const cover = p.media?.find((m) => m.type === "image")?.url ?? p.media?.[0]?.url;
|
|
85
|
+
const href = hrefFor?.(p);
|
|
86
|
+
const Card = (href ? "a" : "button") as React.ElementType;
|
|
53
87
|
return (
|
|
54
|
-
<
|
|
88
|
+
<Card
|
|
55
89
|
key={p.id}
|
|
56
|
-
|
|
90
|
+
{...(href ? { href } : { onClick: () => onSelect?.(p) })}
|
|
57
91
|
style={{
|
|
58
92
|
textAlign: "left",
|
|
59
|
-
|
|
93
|
+
display: "block",
|
|
94
|
+
textDecoration: "none",
|
|
95
|
+
cursor: href || onSelect ? "pointer" : "default",
|
|
60
96
|
background: t.surface,
|
|
61
97
|
border: `1px solid ${t.primary}20`,
|
|
62
98
|
borderRadius: t.cornerRadius,
|
|
@@ -74,7 +110,7 @@ export function ProductGrid({ columns = 3, limit, formatAmount, onSelect, classN
|
|
|
74
110
|
<PriceDisplay amount={minPrice(p)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
|
|
75
111
|
</div>
|
|
76
112
|
</div>
|
|
77
|
-
</
|
|
113
|
+
</Card>
|
|
78
114
|
);
|
|
79
115
|
})}
|
|
80
116
|
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Date/time formatting for Forge sites and apps.
|
|
2
|
+
//
|
|
3
|
+
// Consolidated here because it was already inlined three times inside Forge
|
|
4
|
+
// itself (EventDetail, ReplayList — one of which is literally commented "inlined
|
|
5
|
+
// from the app's formatDateTime") on top of the starter's own copy. Four copies
|
|
6
|
+
// of the same twelve lines is four places for a timezone bug to be fixed in
|
|
7
|
+
// three of them.
|
|
8
|
+
|
|
9
|
+
/** Locale date-time formatting, replacing the client's `useEditorContext().formatDateTime`. */
|
|
10
|
+
export const formatDateTime = (value: string | number | Date, timezone?: string): string =>
|
|
11
|
+
new Date(value).toLocaleString("en-US", {
|
|
12
|
+
weekday: "long",
|
|
13
|
+
year: "numeric",
|
|
14
|
+
month: "long",
|
|
15
|
+
day: "numeric",
|
|
16
|
+
hour: "numeric",
|
|
17
|
+
minute: "2-digit",
|
|
18
|
+
hour12: true,
|
|
19
|
+
timeZone: timezone || "UTC",
|
|
20
|
+
timeZoneName: "short",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** Short date only, e.g. "Jun 30, 2026". */
|
|
24
|
+
export const formatDate = (value: string | number | Date): string =>
|
|
25
|
+
new Date(value).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SEO head() builder for Forge sites and apps.
|
|
2
|
+
//
|
|
3
|
+
// Lives in Forge, not in a starter, because BOTH starters need it and it has no
|
|
4
|
+
// coupling to either. It used to sit in the site-starter's `src/routes/i/-lib/`,
|
|
5
|
+
// which made it reachable only by copying a directory an app has no use for —
|
|
6
|
+
// the root routes (`/offline`, `/documents/$token`) imported it out of `/i/` and
|
|
7
|
+
// broke the moment those fan-site routes were removed.
|
|
8
|
+
//
|
|
9
|
+
// Being here also means a fix reaches existing sites: Forge is versioned and
|
|
10
|
+
// published, and sites adopt it through the same update flow. One source, both
|
|
11
|
+
// starters, no drift.
|
|
12
|
+
//
|
|
13
|
+
// Pages pass plain values (title/description/image) rather than a CMS WebPage —
|
|
14
|
+
// code-site pages are authored in code, not the page builder.
|
|
15
|
+
|
|
16
|
+
export interface HeadMetaInput {
|
|
17
|
+
title: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
image?: string;
|
|
20
|
+
canonicalUrl?: string;
|
|
21
|
+
ogType?: string;
|
|
22
|
+
noindex?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildHeadMeta(input: HeadMetaInput) {
|
|
26
|
+
const { title, description, image, canonicalUrl, ogType = "website", noindex } = input;
|
|
27
|
+
const desc = description || title;
|
|
28
|
+
return {
|
|
29
|
+
meta: [
|
|
30
|
+
{ title },
|
|
31
|
+
{ name: "description", content: desc },
|
|
32
|
+
{ property: "og:title", content: title },
|
|
33
|
+
{ property: "og:description", content: desc },
|
|
34
|
+
{ property: "og:type", content: ogType },
|
|
35
|
+
...(canonicalUrl ? [{ property: "og:url", content: canonicalUrl }] : []),
|
|
36
|
+
...(image ? [{ property: "og:image", content: image }] : []),
|
|
37
|
+
{ name: "twitter:card", content: "summary_large_image" },
|
|
38
|
+
{ name: "twitter:title", content: title },
|
|
39
|
+
{ name: "twitter:description", content: desc },
|
|
40
|
+
...(image ? [{ name: "twitter:image", content: image }] : []),
|
|
41
|
+
...(noindex ? [{ name: "robots", content: "noindex, nofollow" }] : []),
|
|
42
|
+
],
|
|
43
|
+
links: canonicalUrl ? [{ rel: "canonical", href: canonicalUrl }] : [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_HEAD = {
|
|
48
|
+
meta: [{ title: "My site" }],
|
|
49
|
+
links: [] as { rel: string; href: string }[],
|
|
50
|
+
};
|