@decocms/apps-nuvemshop 7.74.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/package.json +48 -0
- package/src/__fixtures__/browse-category.json +813 -0
- package/src/__fixtures__/categories.json +60 -0
- package/src/__fixtures__/checkout.json +3 -0
- package/src/__fixtures__/product-custom-fields.json +199 -0
- package/src/__fixtures__/product.json +313 -0
- package/src/__fixtures__/search.json +636 -0
- package/src/__fixtures__/shipping-options.json +9 -0
- package/src/__tests__/client.test.ts +118 -0
- package/src/__tests__/loaders.test.ts +220 -0
- package/src/__tests__/mod.test.ts +51 -0
- package/src/actions/createCheckout.ts +36 -0
- package/src/client.ts +165 -0
- package/src/hooks/__tests__/useCart.test.ts +81 -0
- package/src/hooks/useCart.ts +115 -0
- package/src/index.ts +23 -0
- package/src/loaders/categories.ts +27 -0
- package/src/loaders/productDetailsPage.ts +30 -0
- package/src/loaders/productList.ts +49 -0
- package/src/loaders/productListingPage.ts +94 -0
- package/src/loaders/relatedProducts.ts +41 -0
- package/src/loaders/shippingOptions.ts +29 -0
- package/src/loaders/suggestions.ts +24 -0
- package/src/manifest.gen.ts +31 -0
- package/src/mod.ts +42 -0
- package/src/registry.ts +9 -0
- package/src/utils/__tests__/listing.test.ts +167 -0
- package/src/utils/__tests__/transform.test.ts +212 -0
- package/src/utils/instrumentedFetch.ts +40 -0
- package/src/utils/listing.ts +183 -0
- package/src/utils/operationRouter.ts +21 -0
- package/src/utils/request.ts +17 -0
- package/src/utils/transform.ts +231 -0
- package/src/utils/types.ts +141 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BreadcrumbList,
|
|
3
|
+
ImageObject,
|
|
4
|
+
Product,
|
|
5
|
+
ProductDetailsPage,
|
|
6
|
+
PropertyValue,
|
|
7
|
+
UnitPriceSpecification,
|
|
8
|
+
} from "@decocms/apps-commerce/types";
|
|
9
|
+
import type { NuvemshopCategory, NuvemshopProduct, NuvemshopVariant } from "./types";
|
|
10
|
+
|
|
11
|
+
export interface TransformOptions {
|
|
12
|
+
/** Storefront origin used to build absolute URLs. */
|
|
13
|
+
origin: string;
|
|
14
|
+
/** The API carries no currency; it's a store setting. Default "BRL". */
|
|
15
|
+
currency?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const IN_STOCK = "https://schema.org/InStock";
|
|
19
|
+
const OUT_OF_STOCK = "https://schema.org/OutOfStock";
|
|
20
|
+
|
|
21
|
+
// Nuvemshop's URL scheme without the trailing slash: createDecoRouter strips it
|
|
22
|
+
// (TanStack's default), so `/produtos/x/` would answer with a redirect and a
|
|
23
|
+
// canonical pointing at it would never be a 200. Old slashed links still land
|
|
24
|
+
// via that redirect, and the loaders accept both forms.
|
|
25
|
+
export const productPath = (handle: string) => `/produtos/${handle}`;
|
|
26
|
+
|
|
27
|
+
const money = (v: string | null | undefined) => (v == null || v === "" ? undefined : Number(v));
|
|
28
|
+
|
|
29
|
+
/** Sale price = promotional_price ?? price; list price = max(compare_at, price). */
|
|
30
|
+
export function variantPrices(v: NuvemshopVariant) {
|
|
31
|
+
const base = money(v.price) ?? 0;
|
|
32
|
+
const price = money(v.promotional_price) ?? base;
|
|
33
|
+
const listPrice = Math.max(money(v.compare_at_price) ?? base, base);
|
|
34
|
+
return { price, listPrice };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `stock: null` means unlimited stock in Nuvemshop. */
|
|
38
|
+
export const isAvailable = (v: NuvemshopVariant) =>
|
|
39
|
+
!v.stock_management || v.stock == null || v.stock > 0;
|
|
40
|
+
|
|
41
|
+
/** `?variant=` id when present, else the first available variant, else the first. */
|
|
42
|
+
export function pickVariant(
|
|
43
|
+
product: NuvemshopProduct,
|
|
44
|
+
variantId?: string | null,
|
|
45
|
+
): NuvemshopVariant {
|
|
46
|
+
const variants = product.variants ?? [];
|
|
47
|
+
if (!variants.length)
|
|
48
|
+
throw new Error(`Nuvemshop product ${product.id} has no variants (missing "variants" field?)`);
|
|
49
|
+
return (
|
|
50
|
+
(variantId && variants.find((v) => String(v.id) === variantId)) ||
|
|
51
|
+
variants.find(isAvailable) ||
|
|
52
|
+
variants[0]
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const prop = (name: string, value: string, valueReference: string): PropertyValue => ({
|
|
57
|
+
"@type": "PropertyValue",
|
|
58
|
+
name,
|
|
59
|
+
value,
|
|
60
|
+
valueReference,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function toImages(product: NuvemshopProduct, variant?: NuvemshopVariant): ImageObject[] {
|
|
64
|
+
const images = [...(product.images ?? [])];
|
|
65
|
+
const own = images.findIndex((i) => i.id === variant?.image_id);
|
|
66
|
+
if (own > 0) images.unshift(...images.splice(own, 1));
|
|
67
|
+
return images.map((i) => ({
|
|
68
|
+
"@type": "ImageObject",
|
|
69
|
+
url: i.src,
|
|
70
|
+
alternateName: i.alt || product.name,
|
|
71
|
+
encodingFormat: "image",
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const stripHtml = (html = "") =>
|
|
76
|
+
html
|
|
77
|
+
.replace(/<[^>]*>/g, " ")
|
|
78
|
+
.replace(/ /g, " ")
|
|
79
|
+
.replace(/\s+/g, " ")
|
|
80
|
+
.trim();
|
|
81
|
+
|
|
82
|
+
export function toProduct(
|
|
83
|
+
product: NuvemshopProduct,
|
|
84
|
+
variant: NuvemshopVariant,
|
|
85
|
+
opts: TransformOptions,
|
|
86
|
+
level = 0,
|
|
87
|
+
): Product {
|
|
88
|
+
const { origin, currency = "BRL" } = opts;
|
|
89
|
+
const attributes = product.attributes ?? [];
|
|
90
|
+
const groupUrl = `${origin}${productPath(product.handle)}`;
|
|
91
|
+
const { price, listPrice } = variantPrices(variant);
|
|
92
|
+
const priceSpecification: UnitPriceSpecification[] = [
|
|
93
|
+
{
|
|
94
|
+
"@type": "UnitPriceSpecification",
|
|
95
|
+
priceType: "https://schema.org/ListPrice",
|
|
96
|
+
price: listPrice,
|
|
97
|
+
},
|
|
98
|
+
{ "@type": "UnitPriceSpecification", priceType: "https://schema.org/SalePrice", price },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"@type": "Product",
|
|
103
|
+
productID: String(variant.id),
|
|
104
|
+
sku: String(variant.id),
|
|
105
|
+
name: variant.values.join(" / ") || product.name,
|
|
106
|
+
url: `${groupUrl}?variant=${variant.id}`,
|
|
107
|
+
description: product.description,
|
|
108
|
+
gtin: variant.barcode ?? undefined,
|
|
109
|
+
brand: product.brand ? { "@type": "Brand", name: product.brand } : undefined,
|
|
110
|
+
category: product.categories?.map((c) => c.name).join(">") || undefined,
|
|
111
|
+
additionalProperty: variant.values.map((value, i) =>
|
|
112
|
+
prop(attributes[i] ?? `option${i + 1}`, value, "SPECIFICATION"),
|
|
113
|
+
),
|
|
114
|
+
image: toImages(product, variant),
|
|
115
|
+
video: product.video_url
|
|
116
|
+
? [
|
|
117
|
+
{
|
|
118
|
+
"@type": "VideoObject",
|
|
119
|
+
contentUrl: product.video_url,
|
|
120
|
+
name: product.name,
|
|
121
|
+
description: product.name,
|
|
122
|
+
uploadDate: "",
|
|
123
|
+
thumbnailUrl: "",
|
|
124
|
+
},
|
|
125
|
+
]
|
|
126
|
+
: undefined,
|
|
127
|
+
isVariantOf: {
|
|
128
|
+
"@type": "ProductGroup",
|
|
129
|
+
productGroupID: String(product.id),
|
|
130
|
+
name: product.name,
|
|
131
|
+
url: groupUrl,
|
|
132
|
+
model: variant.sku ?? undefined,
|
|
133
|
+
image: toImages(product),
|
|
134
|
+
hasVariant:
|
|
135
|
+
level === 0 ? (product.variants ?? []).map((v) => toProduct(product, v, opts, 1)) : [],
|
|
136
|
+
additionalProperty: [
|
|
137
|
+
...(product.tags ?? "")
|
|
138
|
+
.split(",")
|
|
139
|
+
.map((t) => t.trim())
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.map((t) => prop("TAG", t, "TAG")),
|
|
142
|
+
...(product.custom_fields ?? []).map((f) => prop(f.key, f.value, "CUSTOM_FIELD")),
|
|
143
|
+
...(product.free_shipping ? [prop("freeShipping", "true", "SHIPPING")] : []),
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
offers: {
|
|
147
|
+
"@type": "AggregateOffer",
|
|
148
|
+
priceCurrency: currency,
|
|
149
|
+
lowPrice: price,
|
|
150
|
+
highPrice: listPrice,
|
|
151
|
+
offerCount: 1,
|
|
152
|
+
offers: [
|
|
153
|
+
{
|
|
154
|
+
"@type": "Offer",
|
|
155
|
+
sku: variant.sku ?? undefined,
|
|
156
|
+
gtin: variant.barcode ?? undefined,
|
|
157
|
+
price,
|
|
158
|
+
availability: isAvailable(variant) ? IN_STOCK : OUT_OF_STOCK,
|
|
159
|
+
inventoryLevel: { value: variant.stock ?? undefined },
|
|
160
|
+
priceSpecification,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Root→leaf chain of a category, following `parent` ids through `all`. */
|
|
168
|
+
export function categoryChain(
|
|
169
|
+
leaf: NuvemshopCategory,
|
|
170
|
+
all: NuvemshopCategory[],
|
|
171
|
+
): NuvemshopCategory[] {
|
|
172
|
+
const byId = new Map(all.map((c) => [c.id, c]));
|
|
173
|
+
const chain = [leaf];
|
|
174
|
+
for (let c = leaf; c.parent && byId.has(c.parent) && chain.length < 10; ) {
|
|
175
|
+
c = byId.get(c.parent)!;
|
|
176
|
+
chain.unshift(c);
|
|
177
|
+
}
|
|
178
|
+
return chain;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const categoryPath = (chain: NuvemshopCategory[]) =>
|
|
182
|
+
`/${chain.map((c) => c.handle).join("/")}`;
|
|
183
|
+
|
|
184
|
+
export function toBreadcrumbList(
|
|
185
|
+
leaf: NuvemshopCategory | undefined,
|
|
186
|
+
all: NuvemshopCategory[],
|
|
187
|
+
origin: string,
|
|
188
|
+
tail: { name: string; url: string }[] = [],
|
|
189
|
+
): BreadcrumbList {
|
|
190
|
+
const chain = leaf ? categoryChain(leaf, all) : [];
|
|
191
|
+
const items = [
|
|
192
|
+
...chain.map((_, i) => ({
|
|
193
|
+
name: chain[i].name,
|
|
194
|
+
url: `${origin}${categoryPath(chain.slice(0, i + 1))}`,
|
|
195
|
+
})),
|
|
196
|
+
...tail,
|
|
197
|
+
];
|
|
198
|
+
return {
|
|
199
|
+
"@type": "BreadcrumbList",
|
|
200
|
+
numberOfItems: items.length,
|
|
201
|
+
itemListElement: items.map(({ name, url }, i) => ({
|
|
202
|
+
"@type": "ListItem",
|
|
203
|
+
name,
|
|
204
|
+
item: url,
|
|
205
|
+
position: i + 1,
|
|
206
|
+
})),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function toProductPage(
|
|
211
|
+
product: NuvemshopProduct,
|
|
212
|
+
opts: TransformOptions & { variantId?: string | null },
|
|
213
|
+
): ProductDetailsPage {
|
|
214
|
+
const variant = pickVariant(product, opts.variantId);
|
|
215
|
+
const url = `${opts.origin}${productPath(product.handle)}`;
|
|
216
|
+
// The product only carries its category refs; the deepest one wins.
|
|
217
|
+
const refs = (product.categories ?? []).map((c) => ({ ...c, parent: c.parent ?? null }));
|
|
218
|
+
const leaf = [...refs].sort(
|
|
219
|
+
(a, b) => categoryChain(b, refs).length - categoryChain(a, refs).length,
|
|
220
|
+
)[0];
|
|
221
|
+
return {
|
|
222
|
+
"@type": "ProductDetailsPage",
|
|
223
|
+
product: toProduct(product, variant, opts),
|
|
224
|
+
breadcrumbList: toBreadcrumbList(leaf, refs, opts.origin, [{ name: product.name, url }]),
|
|
225
|
+
seo: {
|
|
226
|
+
title: product.seo_title || product.name,
|
|
227
|
+
description: product.seo_description || stripHtml(product.description).slice(0, 160),
|
|
228
|
+
canonical: url,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nuvemshop (Tiendanube) Storefront API `v2026-11` response shapes.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth: the OpenAPI 0.5.0 document plus real responses captured in
|
|
5
|
+
* `src/__fixtures__` — the fixtures are assigned to these types in tests, so a
|
|
6
|
+
* drift between the API and these declarations fails `tsc`.
|
|
7
|
+
*
|
|
8
|
+
* Money fields arrive as decimal strings ("189.90"). `compare_at_price` equals
|
|
9
|
+
* `price` when there's no discount; a sale shows up as `promotional_price`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface NuvemshopImage {
|
|
13
|
+
id: number;
|
|
14
|
+
src: string;
|
|
15
|
+
alt: string | null;
|
|
16
|
+
height?: number | null;
|
|
17
|
+
width?: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NuvemshopInventoryLevel {
|
|
21
|
+
id: number;
|
|
22
|
+
variant_id: number;
|
|
23
|
+
location_id: string;
|
|
24
|
+
stock: number | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NuvemshopVariant {
|
|
28
|
+
id: number;
|
|
29
|
+
product_id: number;
|
|
30
|
+
image_id: number | null;
|
|
31
|
+
position: number;
|
|
32
|
+
price: string | null;
|
|
33
|
+
price_without_taxes?: string | null;
|
|
34
|
+
compare_at_price: string | null;
|
|
35
|
+
promotional_price: string | null;
|
|
36
|
+
stock_management: boolean;
|
|
37
|
+
stock: number | null;
|
|
38
|
+
sku: string | null;
|
|
39
|
+
/** Aligned by index with the product's `attributes` (e.g. ["P", "Azul"] ↔ ["Tamanho", "Cor"]). */
|
|
40
|
+
values: string[];
|
|
41
|
+
barcode: string | null;
|
|
42
|
+
weight?: string | null;
|
|
43
|
+
visible?: boolean;
|
|
44
|
+
created_at?: string;
|
|
45
|
+
updated_at?: string;
|
|
46
|
+
/** Returned by the public API — never copy it into commerce types (cost price). */
|
|
47
|
+
cost?: string | null;
|
|
48
|
+
mpn?: string | null;
|
|
49
|
+
age_group?: string | null;
|
|
50
|
+
gender?: string | null;
|
|
51
|
+
width?: string | null;
|
|
52
|
+
height?: string | null;
|
|
53
|
+
depth?: string | null;
|
|
54
|
+
inventory_levels?: NuvemshopInventoryLevel[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface NuvemshopCategoryRef {
|
|
58
|
+
id: number;
|
|
59
|
+
name: string;
|
|
60
|
+
handle: string;
|
|
61
|
+
parent: number | null;
|
|
62
|
+
google_shopping_category?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface NuvemshopCustomField {
|
|
66
|
+
key: string;
|
|
67
|
+
value: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface NuvemshopProduct {
|
|
71
|
+
id: number;
|
|
72
|
+
name: string;
|
|
73
|
+
handle: string;
|
|
74
|
+
description?: string;
|
|
75
|
+
images?: NuvemshopImage[];
|
|
76
|
+
variants?: NuvemshopVariant[];
|
|
77
|
+
brand?: string | null;
|
|
78
|
+
/** Comma-separated. */
|
|
79
|
+
tags?: string;
|
|
80
|
+
categories?: NuvemshopCategoryRef[];
|
|
81
|
+
attributes?: string[];
|
|
82
|
+
seo_title?: string;
|
|
83
|
+
seo_description?: string;
|
|
84
|
+
published?: boolean;
|
|
85
|
+
free_shipping?: boolean;
|
|
86
|
+
video_url?: string | null;
|
|
87
|
+
custom_fields?: NuvemshopCustomField[];
|
|
88
|
+
created_at?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface NuvemshopCategory {
|
|
92
|
+
id: number;
|
|
93
|
+
name: string;
|
|
94
|
+
handle: string;
|
|
95
|
+
parent?: number | null;
|
|
96
|
+
subcategories?: number[];
|
|
97
|
+
description?: string;
|
|
98
|
+
seo_title?: string;
|
|
99
|
+
seo_description?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface NuvemshopPagination {
|
|
103
|
+
page: number;
|
|
104
|
+
per_page: number;
|
|
105
|
+
total: number;
|
|
106
|
+
total_pages: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface NuvemshopList<T> {
|
|
110
|
+
data: T[];
|
|
111
|
+
pagination: NuvemshopPagination;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type NuvemshopSort = "best-selling" | "price-ascending" | "price-descending";
|
|
115
|
+
|
|
116
|
+
export interface NuvemshopShippingOption {
|
|
117
|
+
code: string;
|
|
118
|
+
name: string;
|
|
119
|
+
price: string;
|
|
120
|
+
detail?: string;
|
|
121
|
+
date?: string;
|
|
122
|
+
days_until?: { start: number; end: number; working_start: number; working_end: number };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface NuvemshopShippingOptions {
|
|
126
|
+
shipping_options: NuvemshopShippingOption[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface NuvemshopLineItem {
|
|
130
|
+
product_id: number;
|
|
131
|
+
variant_id: number;
|
|
132
|
+
quantity: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface NuvemshopCheckout {
|
|
136
|
+
checkout_url: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface NuvemshopError {
|
|
140
|
+
error: { code: string; message: string; details?: unknown };
|
|
141
|
+
}
|