@decocms/apps 0.20.1
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/.github/workflows/release.yml +34 -0
- package/.releaserc.json +25 -0
- package/commerce/components/Image.tsx +209 -0
- package/commerce/components/JsonLd.tsx +285 -0
- package/commerce/sdk/analytics.ts +24 -0
- package/commerce/sdk/formatPrice.ts +23 -0
- package/commerce/sdk/url.ts +9 -0
- package/commerce/sdk/useOffer.ts +75 -0
- package/commerce/sdk/useVariantPossibilities.ts +43 -0
- package/commerce/types/commerce.ts +1105 -0
- package/commerce/utils/canonical.ts +11 -0
- package/commerce/utils/constants.ts +9 -0
- package/commerce/utils/filters.ts +10 -0
- package/commerce/utils/productToAnalyticsItem.ts +67 -0
- package/commerce/utils/stateByZip.ts +50 -0
- package/knip.json +19 -0
- package/package.json +77 -0
- package/shopify/actions/cart/addItems.ts +37 -0
- package/shopify/actions/cart/updateCoupons.ts +32 -0
- package/shopify/actions/cart/updateItems.ts +32 -0
- package/shopify/actions/user/signIn.ts +45 -0
- package/shopify/actions/user/signUp.ts +36 -0
- package/shopify/client.ts +58 -0
- package/shopify/index.ts +32 -0
- package/shopify/init.ts +40 -0
- package/shopify/loaders/ProductDetailsPage.ts +35 -0
- package/shopify/loaders/ProductList.ts +101 -0
- package/shopify/loaders/ProductListingPage.ts +180 -0
- package/shopify/loaders/RelatedProducts.ts +45 -0
- package/shopify/loaders/cart.ts +73 -0
- package/shopify/loaders/shop.ts +40 -0
- package/shopify/loaders/user.ts +44 -0
- package/shopify/utils/admin/admin.ts +57 -0
- package/shopify/utils/admin/queries.ts +29 -0
- package/shopify/utils/cart.ts +28 -0
- package/shopify/utils/cookies.ts +85 -0
- package/shopify/utils/enums.ts +438 -0
- package/shopify/utils/graphql.ts +69 -0
- package/shopify/utils/storefront/queries.ts +530 -0
- package/shopify/utils/storefront/storefront.graphql.gen.ts +113 -0
- package/shopify/utils/transform.ts +436 -0
- package/shopify/utils/types.ts +191 -0
- package/shopify/utils/user.ts +23 -0
- package/shopify/utils/utils.ts +164 -0
- package/tsconfig.json +11 -0
- package/vtex/README.md +6 -0
- package/vtex/actions/address.ts +211 -0
- package/vtex/actions/auth.ts +337 -0
- package/vtex/actions/checkout.ts +497 -0
- package/vtex/actions/index.ts +11 -0
- package/vtex/actions/masterData.ts +170 -0
- package/vtex/actions/misc.ts +196 -0
- package/vtex/actions/newsletter.ts +108 -0
- package/vtex/actions/orders.ts +37 -0
- package/vtex/actions/profile.ts +119 -0
- package/vtex/actions/session.ts +87 -0
- package/vtex/actions/trigger.ts +43 -0
- package/vtex/actions/wishlist.ts +116 -0
- package/vtex/client.ts +423 -0
- package/vtex/hooks/index.ts +4 -0
- package/vtex/hooks/useAutocomplete.ts +89 -0
- package/vtex/hooks/useCart.ts +219 -0
- package/vtex/hooks/useUser.ts +78 -0
- package/vtex/hooks/useWishlist.ts +119 -0
- package/vtex/index.ts +14 -0
- package/vtex/inline-loaders/productDetailsPage.ts +75 -0
- package/vtex/inline-loaders/productList.ts +163 -0
- package/vtex/inline-loaders/productListingPage.ts +447 -0
- package/vtex/inline-loaders/relatedProducts.ts +83 -0
- package/vtex/inline-loaders/suggestions.ts +49 -0
- package/vtex/inline-loaders/workflowProducts.ts +68 -0
- package/vtex/invoke.ts +202 -0
- package/vtex/loaders/address.ts +120 -0
- package/vtex/loaders/brands.ts +51 -0
- package/vtex/loaders/cart.ts +49 -0
- package/vtex/loaders/catalog.ts +165 -0
- package/vtex/loaders/collections.ts +57 -0
- package/vtex/loaders/index.ts +19 -0
- package/vtex/loaders/legacy.ts +671 -0
- package/vtex/loaders/logistics.ts +115 -0
- package/vtex/loaders/navbar.ts +29 -0
- package/vtex/loaders/orders.ts +103 -0
- package/vtex/loaders/pageType.ts +62 -0
- package/vtex/loaders/payment.ts +107 -0
- package/vtex/loaders/profile.ts +138 -0
- package/vtex/loaders/promotion.ts +33 -0
- package/vtex/loaders/search.ts +127 -0
- package/vtex/loaders/session.ts +91 -0
- package/vtex/loaders/user.ts +89 -0
- package/vtex/loaders/wishlist.ts +89 -0
- package/vtex/loaders/wishlistProducts.ts +81 -0
- package/vtex/loaders/workflow.ts +323 -0
- package/vtex/logo.png +0 -0
- package/vtex/middleware.ts +229 -0
- package/vtex/types.ts +248 -0
- package/vtex/utils/batch.ts +21 -0
- package/vtex/utils/cookies.ts +76 -0
- package/vtex/utils/enrichment.ts +540 -0
- package/vtex/utils/fetchCache.ts +150 -0
- package/vtex/utils/index.ts +17 -0
- package/vtex/utils/intelligentSearch.ts +84 -0
- package/vtex/utils/legacy.ts +155 -0
- package/vtex/utils/pickAndOmit.ts +30 -0
- package/vtex/utils/proxy.ts +196 -0
- package/vtex/utils/resourceRange.ts +10 -0
- package/vtex/utils/segment.ts +163 -0
- package/vtex/utils/similars.ts +38 -0
- package/vtex/utils/sitemap.ts +133 -0
- package/vtex/utils/slugCache.ts +32 -0
- package/vtex/utils/slugify.ts +13 -0
- package/vtex/utils/transform.ts +1331 -0
- package/vtex/utils/types.ts +1884 -0
- package/vtex/utils/vtexId.ts +103 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
packages: write
|
|
11
|
+
issues: write
|
|
12
|
+
pull-requests: write
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
release:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
with:
|
|
20
|
+
fetch-depth: 0
|
|
21
|
+
|
|
22
|
+
- uses: actions/setup-node@v4
|
|
23
|
+
with:
|
|
24
|
+
node-version: 22
|
|
25
|
+
registry-url: https://npm.pkg.github.com
|
|
26
|
+
scope: "@decocms"
|
|
27
|
+
|
|
28
|
+
- run: npm install
|
|
29
|
+
|
|
30
|
+
- name: Release
|
|
31
|
+
run: npx semantic-release
|
|
32
|
+
env:
|
|
33
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
34
|
+
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
package/.releaserc.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"branches": ["main"],
|
|
3
|
+
"plugins": [
|
|
4
|
+
["@semantic-release/commit-analyzer", {
|
|
5
|
+
"preset": "angular",
|
|
6
|
+
"releaseRules": [
|
|
7
|
+
{ "type": "feat", "release": "minor" },
|
|
8
|
+
{ "type": "fix", "release": "patch" },
|
|
9
|
+
{ "type": "perf", "release": "patch" },
|
|
10
|
+
{ "type": "refactor", "release": "patch" },
|
|
11
|
+
{ "type": "docs", "release": false },
|
|
12
|
+
{ "type": "chore", "release": false },
|
|
13
|
+
{ "type": "style", "release": false },
|
|
14
|
+
{ "type": "test", "release": false }
|
|
15
|
+
]
|
|
16
|
+
}],
|
|
17
|
+
"@semantic-release/release-notes-generator",
|
|
18
|
+
"@semantic-release/npm",
|
|
19
|
+
"@semantic-release/github",
|
|
20
|
+
["@semantic-release/git", {
|
|
21
|
+
"assets": ["package.json"],
|
|
22
|
+
"message": "chore(release): ${nextRelease.version} [skip ci]"
|
|
23
|
+
}]
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optimized Image component with CDN-aware transforms.
|
|
3
|
+
*
|
|
4
|
+
* Generates responsive srcset, enforces width/height for CLS prevention,
|
|
5
|
+
* and builds optimized URLs for different image CDNs (VTEX, Shopify,
|
|
6
|
+
* Deco, Cloudflare).
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* <Image
|
|
11
|
+
* src="https://store.vteximg.com.br/products/123.jpg"
|
|
12
|
+
* width={400}
|
|
13
|
+
* height={400}
|
|
14
|
+
* alt="Product name"
|
|
15
|
+
* cdn="vtex"
|
|
16
|
+
* />
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ImgHTMLAttributes } from "react";
|
|
21
|
+
|
|
22
|
+
export type ImageCDN = "vtex" | "shopify" | "deco" | "cloudflare" | "none";
|
|
23
|
+
|
|
24
|
+
export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "width" | "height"> {
|
|
25
|
+
src: string;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
/** Image CDN to use for URL transforms. @default "none" */
|
|
29
|
+
cdn?: ImageCDN;
|
|
30
|
+
/**
|
|
31
|
+
* Responsive sizes descriptor.
|
|
32
|
+
* @default "(max-width: 768px) 100vw, 50vw"
|
|
33
|
+
*/
|
|
34
|
+
sizes?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Multipliers for srcset generation.
|
|
37
|
+
* @default [1, 2]
|
|
38
|
+
*/
|
|
39
|
+
srcSetMultipliers?: number[];
|
|
40
|
+
/** Preload the image (adds fetchPriority="high"). */
|
|
41
|
+
preload?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// -------------------------------------------------------------------------
|
|
45
|
+
// CDN URL builders
|
|
46
|
+
// -------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function vtexImageUrl(src: string, width: number, height: number): string {
|
|
49
|
+
if (src.includes("vteximg.com.br") || src.includes("vtexassets.com")) {
|
|
50
|
+
return src.replace(
|
|
51
|
+
/(-\d+-\d+)(\.\w+)$/,
|
|
52
|
+
`-${width}-${height}$2`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const url = new URL(src, "https://placeholder.com");
|
|
57
|
+
url.searchParams.set("width", String(width));
|
|
58
|
+
url.searchParams.set("height", String(height));
|
|
59
|
+
return url.toString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function shopifyImageUrl(src: string, width: number): string {
|
|
63
|
+
if (src.includes("cdn.shopify.com")) {
|
|
64
|
+
return src.replace(/(\.\w+)(\?.*)?$/, `_${width}x$1$2`);
|
|
65
|
+
}
|
|
66
|
+
return src;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function decoImageUrl(src: string, width: number, height: number): string {
|
|
70
|
+
if (src.includes("decocache.com") || src.includes("ozksgdmyrqcxcwhnbepg")) {
|
|
71
|
+
const url = new URL(src);
|
|
72
|
+
url.searchParams.set("width", String(width));
|
|
73
|
+
url.searchParams.set("height", String(height));
|
|
74
|
+
url.searchParams.set("fit", "cover");
|
|
75
|
+
return url.toString();
|
|
76
|
+
}
|
|
77
|
+
return src;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function cloudflareImageUrl(src: string, width: number, height: number): string {
|
|
81
|
+
return `/cdn-cgi/image/width=${width},height=${height},fit=cover,format=auto,quality=80/${src}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildUrl(src: string, width: number, height: number, cdn: ImageCDN): string {
|
|
85
|
+
switch (cdn) {
|
|
86
|
+
case "vtex": return vtexImageUrl(src, width, height);
|
|
87
|
+
case "shopify": return shopifyImageUrl(src, width);
|
|
88
|
+
case "deco": return decoImageUrl(src, width, height);
|
|
89
|
+
case "cloudflare": return cloudflareImageUrl(src, width, height);
|
|
90
|
+
default: return src;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildSrcSet(
|
|
95
|
+
src: string,
|
|
96
|
+
width: number,
|
|
97
|
+
height: number,
|
|
98
|
+
cdn: ImageCDN,
|
|
99
|
+
multipliers: number[],
|
|
100
|
+
): string {
|
|
101
|
+
return multipliers
|
|
102
|
+
.map((m) => {
|
|
103
|
+
const w = Math.round(width * m);
|
|
104
|
+
const h = Math.round(height * m);
|
|
105
|
+
return `${buildUrl(src, w, h, cdn)} ${w}w`;
|
|
106
|
+
})
|
|
107
|
+
.join(", ");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// -------------------------------------------------------------------------
|
|
111
|
+
// Component
|
|
112
|
+
// -------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
export function Image({
|
|
115
|
+
src,
|
|
116
|
+
width,
|
|
117
|
+
height,
|
|
118
|
+
cdn = "none",
|
|
119
|
+
sizes = "(max-width: 768px) 100vw, 50vw",
|
|
120
|
+
srcSetMultipliers = [1, 2],
|
|
121
|
+
preload,
|
|
122
|
+
loading,
|
|
123
|
+
decoding,
|
|
124
|
+
...rest
|
|
125
|
+
}: ImageProps) {
|
|
126
|
+
const optimizedSrc = buildUrl(src, width, height, cdn);
|
|
127
|
+
const srcSet = cdn !== "none"
|
|
128
|
+
? buildSrcSet(src, width, height, cdn, srcSetMultipliers)
|
|
129
|
+
: undefined;
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<img
|
|
133
|
+
src={optimizedSrc}
|
|
134
|
+
srcSet={srcSet}
|
|
135
|
+
sizes={srcSet ? sizes : undefined}
|
|
136
|
+
width={width}
|
|
137
|
+
height={height}
|
|
138
|
+
loading={loading ?? (preload ? "eager" : "lazy")}
|
|
139
|
+
decoding={decoding ?? "async"}
|
|
140
|
+
fetchPriority={preload ? "high" : undefined}
|
|
141
|
+
{...rest}
|
|
142
|
+
/>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// -------------------------------------------------------------------------
|
|
147
|
+
// Picture (responsive art direction)
|
|
148
|
+
// -------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
export interface PictureSource {
|
|
151
|
+
src: string;
|
|
152
|
+
width: number;
|
|
153
|
+
height: number;
|
|
154
|
+
media: string;
|
|
155
|
+
cdn?: ImageCDN;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface PictureProps extends Omit<ImageProps, "sizes"> {
|
|
159
|
+
sources: PictureSource[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Picture component for responsive art direction.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```tsx
|
|
167
|
+
* <Picture
|
|
168
|
+
* sources={[
|
|
169
|
+
* { src: mobileSrc, width: 375, height: 200, media: "(max-width: 767px)", cdn: "vtex" },
|
|
170
|
+
* { src: desktopSrc, width: 1200, height: 400, media: "(min-width: 768px)", cdn: "vtex" },
|
|
171
|
+
* ]}
|
|
172
|
+
* src={desktopSrc}
|
|
173
|
+
* width={1200}
|
|
174
|
+
* height={400}
|
|
175
|
+
* alt="Banner"
|
|
176
|
+
* />
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
export function Picture({
|
|
180
|
+
sources,
|
|
181
|
+
src,
|
|
182
|
+
width,
|
|
183
|
+
height,
|
|
184
|
+
cdn = "none",
|
|
185
|
+
preload,
|
|
186
|
+
...rest
|
|
187
|
+
}: PictureProps) {
|
|
188
|
+
return (
|
|
189
|
+
<picture>
|
|
190
|
+
{sources.map((source, i) => (
|
|
191
|
+
<source
|
|
192
|
+
key={i}
|
|
193
|
+
srcSet={buildUrl(source.src, source.width, source.height, source.cdn ?? cdn)}
|
|
194
|
+
media={source.media}
|
|
195
|
+
width={source.width}
|
|
196
|
+
height={source.height}
|
|
197
|
+
/>
|
|
198
|
+
))}
|
|
199
|
+
<Image
|
|
200
|
+
src={src}
|
|
201
|
+
width={width}
|
|
202
|
+
height={height}
|
|
203
|
+
cdn={cdn}
|
|
204
|
+
preload={preload}
|
|
205
|
+
{...rest}
|
|
206
|
+
/>
|
|
207
|
+
</picture>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO JSON-LD structured data components.
|
|
3
|
+
*
|
|
4
|
+
* Generates JSON-LD script tags for Product (PDP), ProductList (PLP),
|
|
5
|
+
* and BreadcrumbList schemas. Compatible with Google's Rich Results
|
|
6
|
+
* requirements.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* import { ProductJsonLd, PLPJsonLd, BreadcrumbJsonLd } from "@decocms/apps/commerce/components/JsonLd";
|
|
11
|
+
*
|
|
12
|
+
* // In a PDP route
|
|
13
|
+
* <ProductJsonLd product={product} />
|
|
14
|
+
*
|
|
15
|
+
* // In a PLP route
|
|
16
|
+
* <PLPJsonLd page={productListingPage} />
|
|
17
|
+
*
|
|
18
|
+
* // Anywhere with breadcrumbs
|
|
19
|
+
* <BreadcrumbJsonLd breadcrumb={breadcrumbList} />
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
Product,
|
|
25
|
+
ProductListingPage,
|
|
26
|
+
BreadcrumbList,
|
|
27
|
+
ListItem,
|
|
28
|
+
Offer,
|
|
29
|
+
AggregateOffer,
|
|
30
|
+
UnitPriceSpecification,
|
|
31
|
+
AggregateRating,
|
|
32
|
+
} from "../types/commerce";
|
|
33
|
+
|
|
34
|
+
// -------------------------------------------------------------------------
|
|
35
|
+
// JSON-LD script renderer
|
|
36
|
+
// -------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function JsonLdScript({ data }: { data: unknown }) {
|
|
39
|
+
return (
|
|
40
|
+
<script
|
|
41
|
+
type="application/ld+json"
|
|
42
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// -------------------------------------------------------------------------
|
|
48
|
+
// Product (PDP)
|
|
49
|
+
// -------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export interface ProductJsonLdProps {
|
|
52
|
+
product: Product;
|
|
53
|
+
/** Override the canonical URL. Defaults to product.url. */
|
|
54
|
+
url?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getBestOffer(offers: Offer[] | AggregateOffer | undefined): {
|
|
58
|
+
price?: number;
|
|
59
|
+
priceCurrency?: string;
|
|
60
|
+
availability?: string;
|
|
61
|
+
seller?: string;
|
|
62
|
+
priceValidUntil?: string;
|
|
63
|
+
} {
|
|
64
|
+
if (!offers) return {};
|
|
65
|
+
|
|
66
|
+
if ("@type" in offers && offers["@type"] === "AggregateOffer") {
|
|
67
|
+
const agg = offers as AggregateOffer;
|
|
68
|
+
return {
|
|
69
|
+
price: agg.lowPrice,
|
|
70
|
+
priceCurrency: agg.priceCurrency,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (Array.isArray(offers) && offers.length > 0) {
|
|
75
|
+
const best = offers.reduce((a, b) => {
|
|
76
|
+
const ap = a.price ?? Infinity;
|
|
77
|
+
const bp = b.price ?? Infinity;
|
|
78
|
+
return ap <= bp ? a : b;
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
price: best.price,
|
|
82
|
+
priceCurrency: best.priceCurrency,
|
|
83
|
+
availability: best.availability,
|
|
84
|
+
seller: best.seller,
|
|
85
|
+
priceValidUntil: best.priceValidUntil,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getListPrice(
|
|
93
|
+
priceSpec: UnitPriceSpecification[] | undefined,
|
|
94
|
+
): number | undefined {
|
|
95
|
+
if (!priceSpec) return undefined;
|
|
96
|
+
const list = priceSpec.find(
|
|
97
|
+
(p) =>
|
|
98
|
+
p.priceType === "https://schema.org/ListPrice" ||
|
|
99
|
+
p.priceType === "https://schema.org/SRP",
|
|
100
|
+
);
|
|
101
|
+
return list?.price;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function ProductJsonLd({ product, url }: ProductJsonLdProps) {
|
|
105
|
+
const offer = getBestOffer(product.offers as Offer[] | AggregateOffer | undefined);
|
|
106
|
+
const images = product.image?.map((img) => img.url).filter(Boolean) ?? [];
|
|
107
|
+
const rating = product.aggregateRating as AggregateRating | undefined;
|
|
108
|
+
|
|
109
|
+
const data: Record<string, unknown> = {
|
|
110
|
+
"@context": "https://schema.org",
|
|
111
|
+
"@type": "Product",
|
|
112
|
+
name: product.name,
|
|
113
|
+
description: product.description,
|
|
114
|
+
image: images.length === 1 ? images[0] : images,
|
|
115
|
+
url: url ?? product.url,
|
|
116
|
+
sku: product.sku,
|
|
117
|
+
productID: product.productID,
|
|
118
|
+
brand: product.brand
|
|
119
|
+
? { "@type": "Brand", name: product.brand.name }
|
|
120
|
+
: undefined,
|
|
121
|
+
gtin: product.gtin,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (offer.price != null) {
|
|
125
|
+
data.offers = {
|
|
126
|
+
"@type": "Offer",
|
|
127
|
+
price: offer.price,
|
|
128
|
+
priceCurrency: offer.priceCurrency ?? "BRL",
|
|
129
|
+
availability: offer.availability ?? "https://schema.org/InStock",
|
|
130
|
+
seller: offer.seller
|
|
131
|
+
? { "@type": "Organization", name: offer.seller }
|
|
132
|
+
: undefined,
|
|
133
|
+
priceValidUntil: offer.priceValidUntil,
|
|
134
|
+
url: url ?? product.url,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (rating && rating.ratingValue) {
|
|
139
|
+
data.aggregateRating = {
|
|
140
|
+
"@type": "AggregateRating",
|
|
141
|
+
ratingValue: rating.ratingValue,
|
|
142
|
+
reviewCount: rating.reviewCount ?? rating.ratingCount ?? 0,
|
|
143
|
+
bestRating: rating.bestRating ?? 5,
|
|
144
|
+
worstRating: rating.worstRating ?? 1,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return <JsonLdScript data={data} />;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// -------------------------------------------------------------------------
|
|
152
|
+
// Product Listing Page (PLP)
|
|
153
|
+
// -------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
export interface PLPJsonLdProps {
|
|
156
|
+
page: ProductListingPage;
|
|
157
|
+
/** Override the canonical URL. */
|
|
158
|
+
url?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function PLPJsonLd({ page, url }: PLPJsonLdProps) {
|
|
162
|
+
const items = (page.products ?? []).map((product, index) => {
|
|
163
|
+
const offer = getBestOffer(product.offers as Offer[] | AggregateOffer | undefined);
|
|
164
|
+
return {
|
|
165
|
+
"@type": "ListItem" as const,
|
|
166
|
+
position: index + 1,
|
|
167
|
+
item: {
|
|
168
|
+
"@type": "Product" as const,
|
|
169
|
+
name: product.name,
|
|
170
|
+
url: product.url,
|
|
171
|
+
image: product.image?.[0]?.url,
|
|
172
|
+
offers: offer.price != null
|
|
173
|
+
? {
|
|
174
|
+
"@type": "Offer" as const,
|
|
175
|
+
price: offer.price,
|
|
176
|
+
priceCurrency: offer.priceCurrency ?? "BRL",
|
|
177
|
+
availability: offer.availability ?? "https://schema.org/InStock",
|
|
178
|
+
}
|
|
179
|
+
: undefined,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const data = {
|
|
185
|
+
"@context": "https://schema.org",
|
|
186
|
+
"@type": "ItemList",
|
|
187
|
+
url: url ?? page.seo?.canonical,
|
|
188
|
+
name: page.seo?.title,
|
|
189
|
+
description: page.seo?.description,
|
|
190
|
+
numberOfItems: page.products?.length ?? 0,
|
|
191
|
+
itemListElement: items,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return <JsonLdScript data={data} />;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// -------------------------------------------------------------------------
|
|
198
|
+
// Breadcrumb
|
|
199
|
+
// -------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
export interface BreadcrumbJsonLdProps {
|
|
202
|
+
breadcrumb: BreadcrumbList;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function BreadcrumbJsonLd({ breadcrumb }: BreadcrumbJsonLdProps) {
|
|
206
|
+
const items = (breadcrumb.itemListElement ?? []).map((item, index) => {
|
|
207
|
+
const listItem = item as ListItem;
|
|
208
|
+
return {
|
|
209
|
+
"@type": "ListItem" as const,
|
|
210
|
+
position: listItem.position ?? index + 1,
|
|
211
|
+
name: listItem.name,
|
|
212
|
+
item: listItem.item ?? listItem.url,
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const data = {
|
|
217
|
+
"@context": "https://schema.org",
|
|
218
|
+
"@type": "BreadcrumbList",
|
|
219
|
+
itemListElement: items,
|
|
220
|
+
numberOfItems: items.length,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return <JsonLdScript data={data} />;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// -------------------------------------------------------------------------
|
|
227
|
+
// Generic SEO Meta
|
|
228
|
+
// -------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
export interface SeoMetaProps {
|
|
231
|
+
title?: string;
|
|
232
|
+
description?: string;
|
|
233
|
+
canonical?: string;
|
|
234
|
+
image?: string;
|
|
235
|
+
noIndex?: boolean;
|
|
236
|
+
type?: "website" | "article" | "product";
|
|
237
|
+
siteName?: string;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Generates Open Graph and Twitter Card meta tags.
|
|
242
|
+
*
|
|
243
|
+
* Use this in combination with TanStack Router's `meta()` route option,
|
|
244
|
+
* or render directly in the component tree (tags will be hoisted to <head>
|
|
245
|
+
* by React's built-in behavior with TanStack Start).
|
|
246
|
+
*/
|
|
247
|
+
export function seoMetaTags(props: SeoMetaProps): Array<Record<string, string>> {
|
|
248
|
+
const tags: Array<Record<string, string>> = [];
|
|
249
|
+
|
|
250
|
+
if (props.title) {
|
|
251
|
+
tags.push({ title: props.title });
|
|
252
|
+
tags.push({ property: "og:title", content: props.title });
|
|
253
|
+
tags.push({ name: "twitter:title", content: props.title });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (props.description) {
|
|
257
|
+
tags.push({ name: "description", content: props.description });
|
|
258
|
+
tags.push({ property: "og:description", content: props.description });
|
|
259
|
+
tags.push({ name: "twitter:description", content: props.description });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (props.canonical) {
|
|
263
|
+
tags.push({ property: "og:url", content: props.canonical });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (props.image) {
|
|
267
|
+
tags.push({ property: "og:image", content: props.image });
|
|
268
|
+
tags.push({ name: "twitter:image", content: props.image });
|
|
269
|
+
tags.push({ name: "twitter:card", content: "summary_large_image" });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (props.type) {
|
|
273
|
+
tags.push({ property: "og:type", content: props.type });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (props.siteName) {
|
|
277
|
+
tags.push({ property: "og:site_name", content: props.siteName });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (props.noIndex) {
|
|
281
|
+
tags.push({ name: "robots", content: "noindex, nofollow" });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return tags;
|
|
285
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AnalyticsEvent } from "../types/commerce";
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
interface Window {
|
|
5
|
+
DECO: { events: { dispatch: (event: any) => void } };
|
|
6
|
+
DECO_SITES_STD: {
|
|
7
|
+
sendAnalyticsEvent: (args: AnalyticsEvent) => void;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const sendEvent = <E extends AnalyticsEvent>(event: E) => {
|
|
13
|
+
if (typeof globalThis.window?.DECO?.events?.dispatch === "function") {
|
|
14
|
+
globalThis.window.DECO.events.dispatch(event);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (typeof globalThis.window?.DECO_SITES_STD?.sendAnalyticsEvent === "function") {
|
|
19
|
+
globalThis.window.DECO_SITES_STD.sendAnalyticsEvent(event);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console.info("[analytics] No event dispatcher found. Event not sent:", event.name);
|
|
24
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const formatters = new Map<string, Intl.NumberFormat>();
|
|
2
|
+
|
|
3
|
+
const formatter = (currency: string, locale: string) => {
|
|
4
|
+
const key = `${currency}::${locale}`;
|
|
5
|
+
|
|
6
|
+
if (!formatters.has(key)) {
|
|
7
|
+
formatters.set(
|
|
8
|
+
key,
|
|
9
|
+
new Intl.NumberFormat(locale, {
|
|
10
|
+
style: "currency",
|
|
11
|
+
currency,
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return formatters.get(key)!;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const formatPrice = (
|
|
20
|
+
price: number | undefined,
|
|
21
|
+
currency = "BRL",
|
|
22
|
+
locale = "pt-BR",
|
|
23
|
+
) => price ? formatter(currency, locale).format(price) : null;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AggregateOffer,
|
|
3
|
+
UnitPriceSpecification,
|
|
4
|
+
} from "../types/commerce";
|
|
5
|
+
|
|
6
|
+
const bestInstallment = (
|
|
7
|
+
acc: UnitPriceSpecification | null,
|
|
8
|
+
curr: UnitPriceSpecification,
|
|
9
|
+
) => {
|
|
10
|
+
if (curr.priceComponentType !== "https://schema.org/Installment") {
|
|
11
|
+
return acc;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!acc) {
|
|
15
|
+
return curr;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (acc.price > curr.price) {
|
|
19
|
+
return curr;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (acc.price < curr.price) {
|
|
23
|
+
return acc;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (
|
|
27
|
+
acc.billingDuration && curr.billingDuration &&
|
|
28
|
+
acc.billingDuration < curr.billingDuration
|
|
29
|
+
) {
|
|
30
|
+
return curr;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return acc;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const installmentToString = (
|
|
37
|
+
installment: UnitPriceSpecification,
|
|
38
|
+
locale = "pt-BR",
|
|
39
|
+
currency = "BRL",
|
|
40
|
+
) => {
|
|
41
|
+
const { billingDuration, billingIncrement } = installment;
|
|
42
|
+
|
|
43
|
+
if (!billingDuration || !billingIncrement) {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const formatted = new Intl.NumberFormat(locale, {
|
|
48
|
+
style: "currency",
|
|
49
|
+
currency,
|
|
50
|
+
}).format(billingIncrement);
|
|
51
|
+
|
|
52
|
+
return `${billingDuration}x ${formatted}`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const useOffer = (aggregateOffer?: AggregateOffer) => {
|
|
56
|
+
const offer = aggregateOffer?.offers?.[0];
|
|
57
|
+
const listPrice = offer?.priceSpecification?.find((spec) =>
|
|
58
|
+
spec.priceType === "https://schema.org/ListPrice"
|
|
59
|
+
);
|
|
60
|
+
const installment = offer?.priceSpecification?.reduce(bestInstallment, null);
|
|
61
|
+
const seller = offer?.seller;
|
|
62
|
+
const price = offer?.price;
|
|
63
|
+
const availability = offer?.availability;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
price,
|
|
67
|
+
listPrice: listPrice?.price,
|
|
68
|
+
availability,
|
|
69
|
+
seller,
|
|
70
|
+
installments: installment && price
|
|
71
|
+
? installmentToString(installment)
|
|
72
|
+
: null,
|
|
73
|
+
installment,
|
|
74
|
+
};
|
|
75
|
+
};
|