@decocms/apps 1.1.2 → 1.3.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 +14 -2
- package/vtex/client.ts +1 -1
- package/vtex/commerceLoaders.ts +1 -1
- package/vtex/inline-loaders/productDetailsPage.ts +1 -0
- package/vtex/manifest.gen.ts +2 -0
- package/vtex/utils/transform.ts +71 -1
- package/website/client.ts +20 -0
- package/website/components/Analytics.tsx +146 -0
- package/website/components/Seo.tsx +139 -0
- package/website/components/Theme.tsx +47 -0
- package/website/components/Video.tsx +44 -0
- package/website/flags/audience.ts +56 -0
- package/website/flags/everyone.ts +19 -0
- package/website/flags/flag.ts +15 -0
- package/website/flags/multivariate/image.ts +11 -0
- package/website/flags/multivariate/message.ts +11 -0
- package/website/flags/multivariate/page.ts +16 -0
- package/website/flags/multivariate/section.ts +16 -0
- package/website/flags/multivariate.ts +1 -0
- package/website/index.ts +22 -0
- package/website/loaders/environment.ts +45 -0
- package/website/loaders/fonts/googleFonts.ts +119 -0
- package/website/loaders/fonts/local.ts +85 -0
- package/website/loaders/secret.ts +60 -0
- package/website/loaders/secretString.ts +18 -0
- package/website/manifest.gen.ts +31 -0
- package/website/matchers/always.ts +12 -0
- package/website/matchers/cookie.ts +33 -0
- package/website/matchers/cron.ts +109 -0
- package/website/matchers/date.ts +29 -0
- package/website/matchers/device.ts +40 -0
- package/website/matchers/environment.ts +21 -0
- package/website/matchers/host.ts +25 -0
- package/website/matchers/location.ts +113 -0
- package/website/matchers/multi.ts +24 -0
- package/website/matchers/negate.ts +21 -0
- package/website/matchers/never.ts +12 -0
- package/website/matchers/pathname.ts +69 -0
- package/website/matchers/queryString.ts +98 -0
- package/website/matchers/random.ts +24 -0
- package/website/matchers/site.ts +21 -0
- package/website/matchers/userAgent.ts +23 -0
- package/website/mod.ts +48 -0
- package/website/sections/Analytics/Analytics.tsx +7 -0
- package/website/sections/Seo/Seo.tsx +14 -0
- package/website/sections/Seo/SeoV2.tsx +45 -0
- package/website/types.ts +125 -0
- package/website/utils/html.ts +1 -0
- package/website/utils/location.ts +20 -0
- package/website/utils/multivariate.ts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
|
|
6
6
|
"exports": {
|
|
@@ -47,7 +47,18 @@
|
|
|
47
47
|
"./resend/mod": "./resend/mod.ts",
|
|
48
48
|
"./resend/client": "./resend/client.ts",
|
|
49
49
|
"./resend/types": "./resend/types.ts",
|
|
50
|
-
"./resend/actions/send": "./resend/actions/send.ts"
|
|
50
|
+
"./resend/actions/send": "./resend/actions/send.ts",
|
|
51
|
+
"./website": "./website/index.ts",
|
|
52
|
+
"./website/mod": "./website/mod.ts",
|
|
53
|
+
"./website/client": "./website/client.ts",
|
|
54
|
+
"./website/types": "./website/types.ts",
|
|
55
|
+
"./website/components/*": "./website/components/*.tsx",
|
|
56
|
+
"./website/loaders/*": "./website/loaders/*.ts",
|
|
57
|
+
"./website/loaders/fonts/*": "./website/loaders/fonts/*.ts",
|
|
58
|
+
"./website/matchers/*": "./website/matchers/*.ts",
|
|
59
|
+
"./website/flags/*": "./website/flags/*.ts",
|
|
60
|
+
"./website/flags/multivariate/*": "./website/flags/multivariate/*.ts",
|
|
61
|
+
"./website/utils/*": "./website/utils/*.ts"
|
|
51
62
|
},
|
|
52
63
|
"scripts": {
|
|
53
64
|
"generate:manifests": "tsx scripts/generate-manifests.ts",
|
|
@@ -80,6 +91,7 @@
|
|
|
80
91
|
"shopify/",
|
|
81
92
|
"vtex/",
|
|
82
93
|
"resend/",
|
|
94
|
+
"website/",
|
|
83
95
|
"!**/__tests__/",
|
|
84
96
|
"!scripts/"
|
|
85
97
|
],
|
package/vtex/client.ts
CHANGED
|
@@ -242,7 +242,7 @@ export async function vtexCachedFetch<T>(
|
|
|
242
242
|
export async function vtexFetchWithCookies<T>(path: string, init?: RequestInit): Promise<T> {
|
|
243
243
|
// Auto-inject request cookies from RequestContext
|
|
244
244
|
const existingHeaders = init?.headers as Record<string, string> | undefined;
|
|
245
|
-
if (!existingHeaders?.
|
|
245
|
+
if (!existingHeaders?.cookie) {
|
|
246
246
|
const ctx = RequestContext.current;
|
|
247
247
|
const cookies = ctx?.request.headers.get("cookie");
|
|
248
248
|
if (cookies) {
|
package/vtex/commerceLoaders.ts
CHANGED
|
@@ -92,7 +92,7 @@ export function createVtexCommerceLoaders(
|
|
|
92
92
|
static: options?.cacheProfiles?.static ?? "static",
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
-
const
|
|
95
|
+
const _cachedProductList = createCachedLoader(
|
|
96
96
|
"vtex/productList",
|
|
97
97
|
vtexProductList,
|
|
98
98
|
profiles.listing,
|
package/vtex/manifest.gen.ts
CHANGED
|
@@ -13,6 +13,7 @@ import * as actions_session from "./actions/session";
|
|
|
13
13
|
import * as actions_trigger from "./actions/trigger";
|
|
14
14
|
import * as actions_wishlist from "./actions/wishlist";
|
|
15
15
|
import * as loaders_address from "./loaders/address";
|
|
16
|
+
import * as loaders_autocomplete from "./loaders/autocomplete";
|
|
16
17
|
import * as loaders_brands from "./loaders/brands";
|
|
17
18
|
import * as loaders_cart from "./loaders/cart";
|
|
18
19
|
import * as loaders_catalog from "./loaders/catalog";
|
|
@@ -36,6 +37,7 @@ const manifest = {
|
|
|
36
37
|
name: "vtex",
|
|
37
38
|
loaders: {
|
|
38
39
|
"vtex/loaders/address": loaders_address,
|
|
40
|
+
"vtex/loaders/autocomplete": loaders_autocomplete,
|
|
39
41
|
"vtex/loaders/brands": loaders_brands,
|
|
40
42
|
"vtex/loaders/cart": loaders_cart,
|
|
41
43
|
"vtex/loaders/catalog": loaders_catalog,
|
package/vtex/utils/transform.ts
CHANGED
|
@@ -105,6 +105,10 @@ interface ProductOptions {
|
|
|
105
105
|
imagesByKey?: Map<string, string>;
|
|
106
106
|
/** Original attributes to be included in the transformed product */
|
|
107
107
|
includeOriginalAttributes?: string[];
|
|
108
|
+
/** Use lean toProductVariant for hasVariant[] instead of full toProduct at level=1 */
|
|
109
|
+
leanVariants?: boolean;
|
|
110
|
+
/** Property names to keep on lean variant additionalProperty. Defaults to VARIANT_PROPERTY_NAMES. */
|
|
111
|
+
variantPropertyNames?: Set<string>;
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
/** Returns first available sku */
|
|
@@ -389,7 +393,9 @@ export const toProduct = <P extends LegacyProductVTEX | ProductVTEX>(
|
|
|
389
393
|
? ({
|
|
390
394
|
"@type": "ProductGroup",
|
|
391
395
|
productGroupID: productId,
|
|
392
|
-
hasVariant:
|
|
396
|
+
hasVariant: options.leanVariants
|
|
397
|
+
? items.map((sku) => toProductVariant(product, sku, variantOptions))
|
|
398
|
+
: items.map((sku) => toProduct(product, sku, 1, variantOptions)),
|
|
393
399
|
url: getProductGroupURL(baseUrl, product).href,
|
|
394
400
|
name: product.productName,
|
|
395
401
|
additionalProperty: [
|
|
@@ -659,6 +665,70 @@ export const toProductShelf = <P extends LegacyProductVTEX | ProductVTEX>(
|
|
|
659
665
|
};
|
|
660
666
|
};
|
|
661
667
|
|
|
668
|
+
/** Property names that differentiate SKU variants (used by variant selectors) */
|
|
669
|
+
const VARIANT_PROPERTY_NAMES = new Set(["Cor", "Voltagem", "Tamanho"]);
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Build a minimal offer for variant display. Keeps only availability and seller.
|
|
673
|
+
* No priceSpecification, no inventoryLevel, no teasers.
|
|
674
|
+
*/
|
|
675
|
+
const buildOfferVariant = (offer: Offer): Offer => ({
|
|
676
|
+
"@type": "Offer",
|
|
677
|
+
identifier: offer.identifier,
|
|
678
|
+
price: offer.price,
|
|
679
|
+
seller: offer.seller,
|
|
680
|
+
availability: offer.availability,
|
|
681
|
+
priceSpecification: [],
|
|
682
|
+
inventoryLevel: { value: 0 },
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Minimal product transform for variant entries inside isVariantOf.hasVariant[].
|
|
687
|
+
*
|
|
688
|
+
* Keeps only what variant selectors need:
|
|
689
|
+
* - url, productID, sku, name, inProductGroupWithID
|
|
690
|
+
* - additionalProperty filtered to variant-differentiating props (Cor, Voltagem, Tamanho)
|
|
691
|
+
* - offers with availability + seller only (no price specs)
|
|
692
|
+
*
|
|
693
|
+
* Drops: images, description, video, brand, category, gtin, releaseDate,
|
|
694
|
+
* alternateName, isAccessoryOrSparePartFor, isVariantOf
|
|
695
|
+
*/
|
|
696
|
+
export const toProductVariant = <P extends LegacyProductVTEX | ProductVTEX>(
|
|
697
|
+
product: P,
|
|
698
|
+
sku: P["items"][number],
|
|
699
|
+
options: ProductOptions,
|
|
700
|
+
): Product => {
|
|
701
|
+
const { baseUrl, priceCurrency } = options;
|
|
702
|
+
const { productId } = product;
|
|
703
|
+
const { name, itemId: skuId } = sku;
|
|
704
|
+
const variantProps = options.variantPropertyNames ?? VARIANT_PROPERTY_NAMES;
|
|
705
|
+
|
|
706
|
+
// additionalProperty: only variant-differentiating specs
|
|
707
|
+
const specificationsAdditionalProperty = isLegacySku(sku)
|
|
708
|
+
? toAdditionalPropertiesLegacy(sku)
|
|
709
|
+
: toAdditionalProperties(sku);
|
|
710
|
+
const additionalProperty = specificationsAdditionalProperty.filter((prop) =>
|
|
711
|
+
variantProps.has(prop.name ?? ""),
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
// Offers: all sellers but lean (availability + seller only)
|
|
715
|
+
const offerConverter = isLegacyProduct(product) ? toOfferLegacy : toOffer;
|
|
716
|
+
const allOffers = (sku.sellers ?? []).map(offerConverter).sort(bestOfferFirst);
|
|
717
|
+
const bestOffer = allOffers[0];
|
|
718
|
+
const leanOffers = bestOffer ? [buildOfferVariant(bestOffer)] : [];
|
|
719
|
+
|
|
720
|
+
return {
|
|
721
|
+
"@type": "Product",
|
|
722
|
+
productID: skuId,
|
|
723
|
+
sku: skuId,
|
|
724
|
+
name,
|
|
725
|
+
url: getProductURL(baseUrl, product, sku.itemId).href,
|
|
726
|
+
inProductGroupWithID: productId,
|
|
727
|
+
additionalProperty,
|
|
728
|
+
offers: aggregateOffers(leanOffers, priceCurrency),
|
|
729
|
+
};
|
|
730
|
+
};
|
|
731
|
+
|
|
662
732
|
const toBreadcrumbList = (
|
|
663
733
|
product: ProductVTEX | LegacyProductVTEX,
|
|
664
734
|
{ baseUrl }: ProductOptions,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Website app singleton configuration.
|
|
3
|
+
*
|
|
4
|
+
* Follows the same pattern as vtex/client.ts and resend/client.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { WebsiteConfig } from "./types";
|
|
8
|
+
|
|
9
|
+
let _config: WebsiteConfig | null = null;
|
|
10
|
+
|
|
11
|
+
export function configureWebsite(config: WebsiteConfig): void {
|
|
12
|
+
_config = config;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getWebsiteConfig(): WebsiteConfig {
|
|
16
|
+
if (!_config) {
|
|
17
|
+
throw new Error("Website app not configured. Call configureWebsite() first.");
|
|
18
|
+
}
|
|
19
|
+
return _config;
|
|
20
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
declare global {
|
|
2
|
+
interface Window {
|
|
3
|
+
dataLayer: unknown[];
|
|
4
|
+
DECO: { events: { subscribe: (fn: (event: any) => void) => void } };
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const getGTMIdFromSrc = (src: string | undefined) => {
|
|
9
|
+
if (!src) return undefined;
|
|
10
|
+
try {
|
|
11
|
+
return new URL(src).searchParams.get("id") ?? undefined;
|
|
12
|
+
} catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
interface TagManagerProps {
|
|
18
|
+
trackingId: string;
|
|
19
|
+
src?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function GoogleTagManager(props: TagManagerProps) {
|
|
23
|
+
const _isOnPremises = !!props.src;
|
|
24
|
+
const hasTrackingId = "trackingId" in props;
|
|
25
|
+
const id = _isOnPremises ? props.src : props.trackingId;
|
|
26
|
+
const hostname = _isOnPremises ? props.src : "https://www.googletagmanager.com";
|
|
27
|
+
const src = new URL(`/gtm.js?id=${hasTrackingId ? props.trackingId : ""}`, hostname);
|
|
28
|
+
const noscript = new URL(`/ns.html?id=${hasTrackingId ? props.trackingId : ""}`, hostname);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<script
|
|
33
|
+
id={`gtm-script-${id}`}
|
|
34
|
+
dangerouslySetInnerHTML={{
|
|
35
|
+
__html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
|
36
|
+
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
|
37
|
+
j=d.createElement(s);j.async=true;j.src=i;f.parentNode.insertBefore(j,f);
|
|
38
|
+
})(window,document,'script','dataLayer', '${src.href}');`,
|
|
39
|
+
}}
|
|
40
|
+
/>
|
|
41
|
+
<noscript>
|
|
42
|
+
<iframe
|
|
43
|
+
title="Google Tag Manager"
|
|
44
|
+
src={noscript.href}
|
|
45
|
+
height="0"
|
|
46
|
+
width="0"
|
|
47
|
+
style={{ display: "none", visibility: "hidden" }}
|
|
48
|
+
/>
|
|
49
|
+
</noscript>
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function GTAG({ trackingId }: Pick<TagManagerProps, "trackingId">) {
|
|
55
|
+
const safeId = trackingId.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
56
|
+
return (
|
|
57
|
+
<>
|
|
58
|
+
<script async src={`https://www.googletagmanager.com/gtag/js?id=${safeId}`} />
|
|
59
|
+
<script
|
|
60
|
+
dangerouslySetInnerHTML={{
|
|
61
|
+
__html: `window.dataLayer = window.dataLayer || [];
|
|
62
|
+
function gtag() {
|
|
63
|
+
dataLayer.push(arguments);
|
|
64
|
+
}
|
|
65
|
+
gtag("js", new Date());
|
|
66
|
+
gtag("config", '${safeId}');`,
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
</>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* DataLayer event forwarding snippet.
|
|
75
|
+
* Subscribes to DECO events and pushes them to the GTM dataLayer.
|
|
76
|
+
*/
|
|
77
|
+
const snippetCode = `
|
|
78
|
+
(function() {
|
|
79
|
+
if (typeof globalThis.window !== "undefined" && globalThis.window.DECO && globalThis.window.DECO.events) {
|
|
80
|
+
globalThis.window.DECO.events.subscribe(function(event) {
|
|
81
|
+
globalThis.window.dataLayer = globalThis.window.dataLayer || [];
|
|
82
|
+
if (!event || !globalThis.window.dataLayer || typeof globalThis.window.dataLayer.push !== "function") {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (event.name === "deco") {
|
|
86
|
+
globalThis.window.dataLayer.push({ event: event.name, deco: event.params });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
globalThis.window.dataLayer.push({ ecommerce: null });
|
|
90
|
+
globalThis.window.dataLayer.push({ event: event.name, ecommerce: event.params });
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
export interface Props {
|
|
97
|
+
/**
|
|
98
|
+
* @description google tag manager container id. For more info: https://developers.google.com/tag-platform/tag-manager/web#standard_web_page_installation .
|
|
99
|
+
*/
|
|
100
|
+
trackingIds?: string[];
|
|
101
|
+
/**
|
|
102
|
+
* @title GA Measurement Ids
|
|
103
|
+
* @label measurement id
|
|
104
|
+
* @description the google analytics property measurement id. For more info: https://support.google.com/analytics/answer/9539598
|
|
105
|
+
*/
|
|
106
|
+
googleAnalyticsIds?: string[];
|
|
107
|
+
/**
|
|
108
|
+
* @description custom url for serving google tag manager.
|
|
109
|
+
*/
|
|
110
|
+
src?: string;
|
|
111
|
+
/**
|
|
112
|
+
* @description Disable forwarding events into dataLayer
|
|
113
|
+
*/
|
|
114
|
+
disableAutomaticEventPush?: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default function Analytics({
|
|
118
|
+
trackingIds,
|
|
119
|
+
src,
|
|
120
|
+
googleAnalyticsIds,
|
|
121
|
+
disableAutomaticEventPush,
|
|
122
|
+
}: Props) {
|
|
123
|
+
const isDeploy = process.env.NODE_ENV === "production";
|
|
124
|
+
// Backwards compat: extract GTM ID from src URL
|
|
125
|
+
const trackingId = getGTMIdFromSrc(src) ?? "";
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<>
|
|
129
|
+
{isDeploy && (
|
|
130
|
+
<>
|
|
131
|
+
{trackingIds?.map((id) => (
|
|
132
|
+
<GoogleTagManager key={id} src={src} trackingId={id.trim()} />
|
|
133
|
+
))}
|
|
134
|
+
{googleAnalyticsIds?.map((id) => (
|
|
135
|
+
<GTAG key={id} trackingId={id.trim()} />
|
|
136
|
+
))}
|
|
137
|
+
{src && !trackingIds?.length && <GoogleTagManager src={src} trackingId={trackingId} />}
|
|
138
|
+
</>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{disableAutomaticEventPush !== true && (
|
|
142
|
+
<script defer id="analytics-script" dangerouslySetInnerHTML={{ __html: snippetCode }} />
|
|
143
|
+
)}
|
|
144
|
+
</>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ImageWidget, OGType } from "../types";
|
|
2
|
+
import { stripHTML } from "../utils/html";
|
|
3
|
+
|
|
4
|
+
export const renderTemplateString = (template: string, value: string) =>
|
|
5
|
+
template.replace("%s", value);
|
|
6
|
+
|
|
7
|
+
export type SEOSection = React.JSX.Element;
|
|
8
|
+
|
|
9
|
+
export interface Props {
|
|
10
|
+
title?: string;
|
|
11
|
+
/**
|
|
12
|
+
* @title Title template
|
|
13
|
+
* @description add a %s whenever you want it to be replaced with the product name, category name or search term
|
|
14
|
+
* @default %s
|
|
15
|
+
*/
|
|
16
|
+
titleTemplate?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
/**
|
|
19
|
+
* @title Description template
|
|
20
|
+
* @description add a %s whenever you want it to be replaced with the product name, category name or search term
|
|
21
|
+
* @default %s
|
|
22
|
+
*/
|
|
23
|
+
descriptionTemplate?: string;
|
|
24
|
+
/** @default website */
|
|
25
|
+
type?: OGType;
|
|
26
|
+
/** @description Recommended: 1200 x 630 px (up to 5MB) */
|
|
27
|
+
image?: ImageWidget;
|
|
28
|
+
/** @description Recommended: 16 x 16 px */
|
|
29
|
+
favicon?: ImageWidget;
|
|
30
|
+
/** @description Suggested color that browsers should use to customize the display of the page or of the surrounding user interface */
|
|
31
|
+
themeColor?: string;
|
|
32
|
+
/** @title Canonical URL */
|
|
33
|
+
canonical?: string;
|
|
34
|
+
/**
|
|
35
|
+
* @title Disable indexing
|
|
36
|
+
* @description In testing, you can use this to prevent search engines from indexing your site
|
|
37
|
+
*/
|
|
38
|
+
noIndexing?: boolean;
|
|
39
|
+
|
|
40
|
+
/** @title Open Graph Config */
|
|
41
|
+
openGraphConfig?: {
|
|
42
|
+
title?: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
image?: ImageWidget;
|
|
45
|
+
url?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** @title Twitter Config */
|
|
49
|
+
twitterConfig?: {
|
|
50
|
+
title?: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
image?: ImageWidget;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
jsonLDs?: unknown[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* SEO component — renders meta tags that React 19 / TanStack Start
|
|
60
|
+
* automatically hoists into <head>.
|
|
61
|
+
*/
|
|
62
|
+
function Seo({
|
|
63
|
+
title: t = "",
|
|
64
|
+
titleTemplate = "%s",
|
|
65
|
+
description: desc,
|
|
66
|
+
descriptionTemplate = "%s",
|
|
67
|
+
type,
|
|
68
|
+
image,
|
|
69
|
+
favicon,
|
|
70
|
+
themeColor,
|
|
71
|
+
canonical,
|
|
72
|
+
noIndexing,
|
|
73
|
+
openGraphConfig,
|
|
74
|
+
twitterConfig,
|
|
75
|
+
jsonLDs = [],
|
|
76
|
+
}: Props) {
|
|
77
|
+
const twitterCard = type === "website" ? "summary" : "summary_large_image";
|
|
78
|
+
|
|
79
|
+
const title = stripHTML(t);
|
|
80
|
+
const description = stripHTML(desc || "");
|
|
81
|
+
|
|
82
|
+
const twitterImage = twitterConfig?.image ?? image;
|
|
83
|
+
const twitterTitle = twitterConfig?.title ? stripHTML(twitterConfig.title) : title;
|
|
84
|
+
const twitterDescription = twitterConfig?.description
|
|
85
|
+
? stripHTML(twitterConfig.description)
|
|
86
|
+
: description;
|
|
87
|
+
|
|
88
|
+
const openGraphImage = openGraphConfig?.image ?? image;
|
|
89
|
+
const openGraphTitle = openGraphConfig?.title ? stripHTML(openGraphConfig.title) : title;
|
|
90
|
+
const openGraphDescription = openGraphConfig?.description
|
|
91
|
+
? stripHTML(openGraphConfig.description)
|
|
92
|
+
: description;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
<title>{renderTemplateString(titleTemplate, title)}</title>
|
|
97
|
+
<meta name="description" content={renderTemplateString(descriptionTemplate, description)} />
|
|
98
|
+
<meta name="theme-color" content={themeColor} />
|
|
99
|
+
<link rel="icon" href={favicon} />
|
|
100
|
+
|
|
101
|
+
{/* Twitter tags */}
|
|
102
|
+
<meta property="twitter:title" content={twitterTitle} />
|
|
103
|
+
<meta property="twitter:description" content={twitterDescription} />
|
|
104
|
+
<meta property="twitter:image" content={twitterImage} />
|
|
105
|
+
<meta property="twitter:card" content={twitterCard} />
|
|
106
|
+
|
|
107
|
+
{/* OpenGraph tags */}
|
|
108
|
+
<meta property="og:title" content={openGraphTitle} />
|
|
109
|
+
<meta property="og:description" content={openGraphDescription} />
|
|
110
|
+
<meta property="og:type" content={type} />
|
|
111
|
+
<meta property="og:image" content={openGraphImage} />
|
|
112
|
+
{Boolean(openGraphConfig?.url || canonical) && (
|
|
113
|
+
<meta property="og:url" content={openGraphConfig?.url ?? canonical} />
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{/* Link tags */}
|
|
117
|
+
{canonical && <link rel="canonical" href={canonical} />}
|
|
118
|
+
|
|
119
|
+
{/* No index, no follow */}
|
|
120
|
+
{noIndexing && <meta name="robots" content="noindex, nofollow" />}
|
|
121
|
+
{!noIndexing && <meta name="robots" content="index, follow" />}
|
|
122
|
+
|
|
123
|
+
{jsonLDs.map((json, idx) => (
|
|
124
|
+
<script
|
|
125
|
+
key={idx}
|
|
126
|
+
type="application/ld+json"
|
|
127
|
+
dangerouslySetInnerHTML={{
|
|
128
|
+
__html: JSON.stringify({
|
|
129
|
+
"@context": "https://schema.org",
|
|
130
|
+
...(json as Record<string, unknown>),
|
|
131
|
+
}),
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
))}
|
|
135
|
+
</>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export default Seo;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
2
|
+
import type { Font, Variable } from "../types";
|
|
3
|
+
|
|
4
|
+
export interface Props {
|
|
5
|
+
variables?: Variable[];
|
|
6
|
+
fonts?: Font[];
|
|
7
|
+
colorScheme?: "light" | "dark";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const withPrefersColorScheme = (scheme: "light" | "dark", css: string) =>
|
|
11
|
+
`@media (prefers-color-scheme: ${scheme}) { ${css} }`;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Theme component — injects CSS custom properties and font stylesheets.
|
|
15
|
+
* React 19 / TanStack Start automatically hoists <style> into <head>.
|
|
16
|
+
*/
|
|
17
|
+
function Theme({ fonts = [], variables = [], colorScheme }: Props) {
|
|
18
|
+
const id = useId();
|
|
19
|
+
|
|
20
|
+
const family = fonts.reduce((acc, { family }) => (acc ? `${acc}, ${family}` : family), "");
|
|
21
|
+
|
|
22
|
+
const vars = [{ name: "--font-family", value: family }, ...variables]
|
|
23
|
+
.map(({ name, value }) => `${name}: ${value}`)
|
|
24
|
+
.join(";");
|
|
25
|
+
|
|
26
|
+
const css = `* {${vars}}`;
|
|
27
|
+
const html = colorScheme ? withPrefersColorScheme(colorScheme, css) : css;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
{fonts?.map(({ styleSheet }, idx) =>
|
|
32
|
+
styleSheet ? (
|
|
33
|
+
<style key={idx} type="text/css" dangerouslySetInnerHTML={{ __html: styleSheet }} />
|
|
34
|
+
) : null,
|
|
35
|
+
)}
|
|
36
|
+
{html && (
|
|
37
|
+
<style
|
|
38
|
+
type="text/css"
|
|
39
|
+
id={`__DESIGN_SYSTEM_VARS-${id}`}
|
|
40
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
41
|
+
/>
|
|
42
|
+
)}
|
|
43
|
+
</>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default Theme;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TODO: Implement video preload with link[rel="preload"] tags once
|
|
3
|
+
* browsers support it. More info at: https://stackoverflow.com/a/68368601
|
|
4
|
+
*/
|
|
5
|
+
import { forwardRef } from "react";
|
|
6
|
+
|
|
7
|
+
import { getOptimizedMediaUrl } from "../../commerce/components/Image";
|
|
8
|
+
|
|
9
|
+
export interface Props {
|
|
10
|
+
src: string;
|
|
11
|
+
/** @description Improves Web Vitals (CLS|LCP) */
|
|
12
|
+
width: number;
|
|
13
|
+
/** @description Improves Web Vitals (CLS|LCP) */
|
|
14
|
+
height: number;
|
|
15
|
+
/** @description Force video through the optimization engine */
|
|
16
|
+
forceOptimizedSrc?: boolean;
|
|
17
|
+
className?: string;
|
|
18
|
+
style?: React.CSSProperties;
|
|
19
|
+
autoPlay?: boolean;
|
|
20
|
+
loop?: boolean;
|
|
21
|
+
muted?: boolean;
|
|
22
|
+
playsInline?: boolean;
|
|
23
|
+
controls?: boolean;
|
|
24
|
+
poster?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const Video = forwardRef<HTMLVideoElement, Props>((props, ref) => {
|
|
28
|
+
const { forceOptimizedSrc, src: originalSrc, width, height, ...rest } = props;
|
|
29
|
+
|
|
30
|
+
const src = forceOptimizedSrc
|
|
31
|
+
? getOptimizedMediaUrl({
|
|
32
|
+
originalSrc,
|
|
33
|
+
width,
|
|
34
|
+
height,
|
|
35
|
+
fit: "cover",
|
|
36
|
+
})
|
|
37
|
+
: originalSrc;
|
|
38
|
+
|
|
39
|
+
return <video {...rest} preload={undefined} src={src} width={width} height={height} ref={ref} />;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
Video.displayName = "Video";
|
|
43
|
+
|
|
44
|
+
export default Video;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { FlagObj, Matcher } from "../types";
|
|
2
|
+
import Flag from "./flag";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @title Site Route
|
|
6
|
+
* @titleBy pathTemplate
|
|
7
|
+
*/
|
|
8
|
+
export interface Route {
|
|
9
|
+
pathTemplate: string;
|
|
10
|
+
/**
|
|
11
|
+
* @description if true so the path will be checked against the coming from request instead of using urlpattern.
|
|
12
|
+
*/
|
|
13
|
+
isHref?: boolean;
|
|
14
|
+
handler: {
|
|
15
|
+
value: unknown;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* @title Priority
|
|
19
|
+
* @description higher priority means that this route will be used in favor of other routes with less or none priority
|
|
20
|
+
*/
|
|
21
|
+
highPriority?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @title Routes
|
|
26
|
+
* @description Used to configure your site routes
|
|
27
|
+
*/
|
|
28
|
+
export type Routes = Route[];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @titleBy name
|
|
32
|
+
*/
|
|
33
|
+
export interface Audience {
|
|
34
|
+
matcher: Matcher;
|
|
35
|
+
/**
|
|
36
|
+
* @title The audience name (will be used on cookies).
|
|
37
|
+
* @description Add a meaningful short word for the audience name.
|
|
38
|
+
* @minLength 3
|
|
39
|
+
* @pattern ^[A-Za-z0-9_-]+$
|
|
40
|
+
*/
|
|
41
|
+
name: string;
|
|
42
|
+
routes?: Routes;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @title Audience
|
|
47
|
+
* @description Select routes based on the matched audience.
|
|
48
|
+
*/
|
|
49
|
+
export default function Audience({ matcher, routes, name }: Audience): FlagObj<Route[]> {
|
|
50
|
+
return Flag<Route[]>({
|
|
51
|
+
matcher,
|
|
52
|
+
true: routes ?? [],
|
|
53
|
+
false: [],
|
|
54
|
+
name,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import MatchAlways from "../matchers/always";
|
|
2
|
+
import type { FlagObj } from "../types";
|
|
3
|
+
import Audience, { type Route, type Routes } from "./audience";
|
|
4
|
+
|
|
5
|
+
export interface EveryoneConfig {
|
|
6
|
+
routes?: Routes;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @title Audience Everyone
|
|
11
|
+
* @description Always match regardless of the current user
|
|
12
|
+
*/
|
|
13
|
+
export default function Everyone({ routes }: EveryoneConfig): FlagObj<Route[]> {
|
|
14
|
+
return Audience({
|
|
15
|
+
matcher: MatchAlways,
|
|
16
|
+
routes: routes ?? [],
|
|
17
|
+
name: "Everyone",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FlagObj } from "../types";
|
|
2
|
+
|
|
3
|
+
export type Props<T> = FlagObj<T>;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @title Flag
|
|
7
|
+
*/
|
|
8
|
+
export default function Flag<T>({ matcher, name, true: T, false: F }: Props<T>): FlagObj<T> {
|
|
9
|
+
return {
|
|
10
|
+
matcher,
|
|
11
|
+
true: T,
|
|
12
|
+
false: F,
|
|
13
|
+
name,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ImageWidget, MultivariateFlag } from "../../types";
|
|
2
|
+
import multivariate, { type MultivariateProps } from "../../utils/multivariate";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @title Image Variants
|
|
6
|
+
*/
|
|
7
|
+
export default function Image(
|
|
8
|
+
props: MultivariateProps<ImageWidget>,
|
|
9
|
+
): MultivariateFlag<ImageWidget> {
|
|
10
|
+
return multivariate(props);
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MultivariateFlag } from "../../types";
|
|
2
|
+
import multivariate, { type MultivariateProps } from "../../utils/multivariate";
|
|
3
|
+
|
|
4
|
+
export type Message = string;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @title Message Variants
|
|
8
|
+
*/
|
|
9
|
+
export default function Message(props: MultivariateProps<Message>): MultivariateFlag<Message> {
|
|
10
|
+
return multivariate(props);
|
|
11
|
+
}
|