@86d-app/products 0.0.4 → 0.0.13
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/.turbo/turbo-build.log +1 -0
- package/AGENTS.md +41 -41
- package/README.md +266 -5
- package/dist/__tests__/controllers.test.d.ts +2 -0
- package/dist/__tests__/controllers.test.d.ts.map +1 -0
- package/dist/__tests__/endpoint-security.test.d.ts +2 -0
- package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
- package/dist/__tests__/service-impl.test.d.ts +2 -0
- package/dist/__tests__/service-impl.test.d.ts.map +1 -0
- package/dist/__tests__/state.test.d.ts +2 -0
- package/dist/__tests__/state.test.d.ts.map +1 -0
- package/dist/admin/components/categories-admin.d.ts +2 -0
- package/dist/admin/components/categories-admin.d.ts.map +1 -0
- package/dist/admin/components/category-form.d.ts +7 -0
- package/dist/admin/components/category-form.d.ts.map +1 -0
- package/dist/admin/components/category-list.d.ts +7 -0
- package/dist/admin/components/category-list.d.ts.map +1 -0
- package/dist/admin/components/collections-admin.d.ts +2 -0
- package/dist/admin/components/collections-admin.d.ts.map +1 -0
- package/dist/admin/components/index.d.ts +9 -0
- package/dist/admin/components/index.d.ts.map +1 -0
- package/dist/admin/components/product-detail.d.ts +7 -0
- package/dist/admin/components/product-detail.d.ts.map +1 -0
- package/dist/admin/components/product-edit.d.ts +6 -0
- package/dist/admin/components/product-edit.d.ts.map +1 -0
- package/dist/admin/components/product-form.d.ts +7 -0
- package/dist/admin/components/product-form.d.ts.map +1 -0
- package/dist/admin/components/product-list.d.ts +2 -0
- package/dist/admin/components/product-list.d.ts.map +1 -0
- package/dist/admin/components/product-new.d.ts +2 -0
- package/dist/admin/components/product-new.d.ts.map +1 -0
- package/dist/admin/endpoints/add-collection-product.d.ts +15 -0
- package/dist/admin/endpoints/add-collection-product.d.ts.map +1 -0
- package/dist/admin/endpoints/bulk-action.d.ts +17 -0
- package/dist/admin/endpoints/bulk-action.d.ts.map +1 -0
- package/dist/admin/endpoints/create-category.d.ts +23 -0
- package/dist/admin/endpoints/create-category.d.ts.map +1 -0
- package/dist/admin/endpoints/create-collection.d.ts +22 -0
- package/dist/admin/endpoints/create-collection.d.ts.map +1 -0
- package/dist/admin/endpoints/create-product.d.ts +44 -0
- package/dist/admin/endpoints/create-product.d.ts.map +1 -0
- package/dist/admin/endpoints/create-variant.d.ts +35 -0
- package/dist/admin/endpoints/create-variant.d.ts.map +1 -0
- package/dist/admin/endpoints/delete-category.d.ts +18 -0
- package/dist/admin/endpoints/delete-category.d.ts.map +1 -0
- package/dist/admin/endpoints/delete-collection.d.ts +8 -0
- package/dist/admin/endpoints/delete-collection.d.ts.map +1 -0
- package/dist/admin/endpoints/delete-product.d.ts +18 -0
- package/dist/admin/endpoints/delete-product.d.ts.map +1 -0
- package/dist/admin/endpoints/delete-variant.d.ts +18 -0
- package/dist/admin/endpoints/delete-variant.d.ts.map +1 -0
- package/dist/admin/endpoints/get-product.d.ts +16 -0
- package/dist/admin/endpoints/get-product.d.ts.map +1 -0
- package/dist/admin/endpoints/import-products.d.ts +36 -0
- package/dist/admin/endpoints/import-products.d.ts.map +1 -0
- package/dist/admin/endpoints/index.d.ts +418 -0
- package/dist/admin/endpoints/index.d.ts.map +1 -0
- package/dist/admin/endpoints/list-categories.d.ts +11 -0
- package/dist/admin/endpoints/list-categories.d.ts.map +1 -0
- package/dist/admin/endpoints/list-collections.d.ts +11 -0
- package/dist/admin/endpoints/list-collections.d.ts.map +1 -0
- package/dist/admin/endpoints/list-products.d.ts +27 -0
- package/dist/admin/endpoints/list-products.d.ts.map +1 -0
- package/dist/admin/endpoints/remove-collection-product.d.ts +9 -0
- package/dist/admin/endpoints/remove-collection-product.d.ts.map +1 -0
- package/dist/admin/endpoints/update-category.d.ts +26 -0
- package/dist/admin/endpoints/update-category.d.ts.map +1 -0
- package/dist/admin/endpoints/update-collection.d.ts +19 -0
- package/dist/admin/endpoints/update-collection.d.ts.map +1 -0
- package/dist/admin/endpoints/update-product.d.ts +47 -0
- package/dist/admin/endpoints/update-product.d.ts.map +1 -0
- package/dist/admin/endpoints/update-variant.d.ts +35 -0
- package/dist/admin/endpoints/update-variant.d.ts.map +1 -0
- package/dist/controllers.d.ts +130 -0
- package/dist/controllers.d.ts.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/markdown.d.ts +6 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/schema.d.ts +351 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/service-impl.d.ts +4 -0
- package/dist/service-impl.d.ts.map +1 -0
- package/dist/service.d.ts +280 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/state.d.ts +38 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/store/components/_hooks.d.ts +88 -0
- package/dist/store/components/_hooks.d.ts.map +1 -0
- package/dist/store/components/_types.d.ts +70 -0
- package/dist/store/components/_types.d.ts.map +1 -0
- package/dist/store/components/_utils.d.ts +5 -0
- package/dist/store/components/_utils.d.ts.map +1 -0
- package/dist/store/components/back-in-stock-notify.d.ts +8 -0
- package/dist/store/components/back-in-stock-notify.d.ts.map +1 -0
- package/dist/store/components/collection-card.d.ts +6 -0
- package/dist/store/components/collection-card.d.ts.map +1 -0
- package/dist/store/components/collection-detail.d.ts +6 -0
- package/dist/store/components/collection-detail.d.ts.map +1 -0
- package/dist/store/components/collection-grid.d.ts +6 -0
- package/dist/store/components/collection-grid.d.ts.map +1 -0
- package/dist/store/components/featured-products.d.ts +6 -0
- package/dist/store/components/featured-products.d.ts.map +1 -0
- package/dist/store/components/filter-chip.d.ts +6 -0
- package/dist/store/components/filter-chip.d.ts.map +1 -0
- package/dist/store/components/index.d.ts +37 -0
- package/dist/store/components/index.d.ts.map +1 -0
- package/dist/store/components/product-card.d.ts +7 -0
- package/dist/store/components/product-card.d.ts.map +1 -0
- package/dist/store/components/product-detail.d.ts +6 -0
- package/dist/store/components/product-detail.d.ts.map +1 -0
- package/dist/store/components/product-listing.d.ts +7 -0
- package/dist/store/components/product-listing.d.ts.map +1 -0
- package/dist/store/components/product-qa-section.d.ts +5 -0
- package/dist/store/components/product-qa-section.d.ts.map +1 -0
- package/dist/store/components/product-reviews-section.d.ts +5 -0
- package/dist/store/components/product-reviews-section.d.ts.map +1 -0
- package/dist/store/components/recently-viewed.d.ts +12 -0
- package/dist/store/components/recently-viewed.d.ts.map +1 -0
- package/dist/store/components/recommended-products.d.ts +7 -0
- package/dist/store/components/recommended-products.d.ts.map +1 -0
- package/dist/store/components/related-products.d.ts +7 -0
- package/dist/store/components/related-products.d.ts.map +1 -0
- package/dist/store/components/star-display.d.ts +6 -0
- package/dist/store/components/star-display.d.ts.map +1 -0
- package/dist/store/components/star-picker.d.ts +6 -0
- package/dist/store/components/star-picker.d.ts.map +1 -0
- package/dist/store/components/stock-badge.d.ts +5 -0
- package/dist/store/components/stock-badge.d.ts.map +1 -0
- package/dist/store/endpoints/get-category.d.ts +22 -0
- package/dist/store/endpoints/get-category.d.ts.map +1 -0
- package/dist/store/endpoints/get-collection.d.ts +17 -0
- package/dist/store/endpoints/get-collection.d.ts.map +1 -0
- package/dist/store/endpoints/get-featured.d.ts +10 -0
- package/dist/store/endpoints/get-featured.d.ts.map +1 -0
- package/dist/store/endpoints/get-product.d.ts +17 -0
- package/dist/store/endpoints/get-product.d.ts.map +1 -0
- package/dist/store/endpoints/get-related.d.ts +11 -0
- package/dist/store/endpoints/get-related.d.ts.map +1 -0
- package/dist/store/endpoints/index.d.ts +129 -0
- package/dist/store/endpoints/index.d.ts.map +1 -0
- package/dist/store/endpoints/list-categories.d.ts +6 -0
- package/dist/store/endpoints/list-categories.d.ts.map +1 -0
- package/dist/store/endpoints/list-collections.d.ts +10 -0
- package/dist/store/endpoints/list-collections.d.ts.map +1 -0
- package/dist/store/endpoints/list-products.d.ts +26 -0
- package/dist/store/endpoints/list-products.d.ts.map +1 -0
- package/dist/store/endpoints/search-products.d.ts +11 -0
- package/dist/store/endpoints/search-products.d.ts.map +1 -0
- package/dist/store/endpoints/store-search.d.ts +18 -0
- package/dist/store/endpoints/store-search.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/endpoint-security.test.ts +457 -0
- package/src/__tests__/service-impl.test.ts +1745 -0
- package/src/admin/endpoints/create-category.ts +5 -2
- package/src/admin/endpoints/create-collection.ts +1 -1
- package/src/admin/endpoints/create-product.ts +5 -2
- package/src/admin/endpoints/delete-category.ts +1 -1
- package/src/admin/endpoints/delete-collection.ts +1 -1
- package/src/admin/endpoints/delete-product.ts +1 -1
- package/src/admin/endpoints/delete-variant.ts +1 -1
- package/src/admin/endpoints/list-categories.ts +1 -1
- package/src/admin/endpoints/list-collections.ts +1 -1
- package/src/admin/endpoints/list-products.ts +1 -1
- package/src/admin/endpoints/remove-collection-product.ts +1 -1
- package/src/admin/endpoints/update-category.ts +5 -2
- package/src/admin/endpoints/update-collection.ts +1 -1
- package/src/admin/endpoints/update-product.ts +5 -2
- package/src/admin/endpoints/update-variant.ts +1 -1
- package/src/service-impl.ts +1139 -0
- package/src/service.ts +312 -0
- package/src/store/components/_hooks.ts +81 -0
- package/src/store/components/_utils.ts +8 -0
- package/src/store/components/collection-detail.tsx +21 -1
- package/src/store/components/collection-grid.tsx +5 -1
- package/src/store/components/featured-products.tsx +5 -1
- package/src/store/components/index.tsx +2 -0
- package/src/store/components/product-card.mdx +1 -1
- package/src/store/components/product-card.tsx +25 -5
- package/src/store/components/product-detail.mdx +2 -0
- package/src/store/components/product-detail.tsx +55 -8
- package/src/store/components/product-listing.tsx +25 -4
- package/src/store/components/product-qa-section.mdx +21 -0
- package/src/store/components/product-qa-section.tsx +503 -0
- package/src/store/components/recommended-products.mdx +6 -0
- package/src/store/components/recommended-products.tsx +119 -0
- package/src/store/endpoints/get-category.ts +2 -2
- package/src/store/endpoints/get-collection.ts +1 -1
- package/src/store/endpoints/get-featured.ts +1 -1
- package/src/store/endpoints/get-product.ts +1 -1
- package/src/store/endpoints/get-related.ts +2 -2
- package/src/store/endpoints/list-collections.ts +3 -3
- package/src/store/endpoints/list-products.ts +9 -9
- package/src/store/endpoints/search-products.ts +4 -6
- package/src/store/endpoints/store-search.ts +1 -1
- package/COMPONENTS.md +0 -231
|
@@ -0,0 +1,1139 @@
|
|
|
1
|
+
import type { ModuleDataService } from "@86d-app/core";
|
|
2
|
+
import type {
|
|
3
|
+
Category,
|
|
4
|
+
CategoryWithChildren,
|
|
5
|
+
Collection,
|
|
6
|
+
CollectionProduct,
|
|
7
|
+
CollectionWithProducts,
|
|
8
|
+
CreateCategoryParams,
|
|
9
|
+
CreateCollectionParams,
|
|
10
|
+
CreateProductParams,
|
|
11
|
+
CreateVariantParams,
|
|
12
|
+
ImportError,
|
|
13
|
+
ImportProductRow,
|
|
14
|
+
ImportResult,
|
|
15
|
+
ListProductsParams,
|
|
16
|
+
Product,
|
|
17
|
+
ProductController,
|
|
18
|
+
ProductVariant,
|
|
19
|
+
ProductWithVariants,
|
|
20
|
+
} from "./service";
|
|
21
|
+
|
|
22
|
+
function generateSlug(name: string): string {
|
|
23
|
+
return name
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
26
|
+
.replace(/^-+|-+$/g, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createProductController(
|
|
30
|
+
data: ModuleDataService,
|
|
31
|
+
): ProductController {
|
|
32
|
+
return {
|
|
33
|
+
// ── Products ──────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
async createProduct(params: CreateProductParams): Promise<Product> {
|
|
36
|
+
const now = new Date();
|
|
37
|
+
const id = `prod_${crypto.randomUUID()}`;
|
|
38
|
+
|
|
39
|
+
const product: Product = {
|
|
40
|
+
id,
|
|
41
|
+
name: params.name,
|
|
42
|
+
slug: params.slug,
|
|
43
|
+
description: params.description,
|
|
44
|
+
shortDescription: params.shortDescription,
|
|
45
|
+
price: params.price,
|
|
46
|
+
compareAtPrice: params.compareAtPrice,
|
|
47
|
+
costPrice: params.costPrice,
|
|
48
|
+
sku: params.sku,
|
|
49
|
+
barcode: params.barcode,
|
|
50
|
+
inventory: params.inventory ?? 0,
|
|
51
|
+
trackInventory: params.trackInventory ?? true,
|
|
52
|
+
allowBackorder: params.allowBackorder ?? false,
|
|
53
|
+
status: params.status ?? "draft",
|
|
54
|
+
categoryId: params.categoryId,
|
|
55
|
+
images: params.images ?? [],
|
|
56
|
+
tags: params.tags ?? [],
|
|
57
|
+
metadata: params.metadata ?? {},
|
|
58
|
+
weight: params.weight,
|
|
59
|
+
weightUnit: params.weightUnit ?? "kg",
|
|
60
|
+
isFeatured: params.isFeatured ?? false,
|
|
61
|
+
createdAt: now,
|
|
62
|
+
updatedAt: now,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
await data.upsert(
|
|
66
|
+
"product",
|
|
67
|
+
id,
|
|
68
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
69
|
+
product as unknown as Record<string, any>,
|
|
70
|
+
);
|
|
71
|
+
return product;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async getProduct(id: string): Promise<Product | null> {
|
|
75
|
+
return (await data.get("product", id)) as Product | null;
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async getProductBySlug(slug: string): Promise<Product | null> {
|
|
79
|
+
const products = (await data.findMany("product", {
|
|
80
|
+
where: { slug },
|
|
81
|
+
})) as Product[];
|
|
82
|
+
return products[0] ?? null;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async getProductWithVariants(
|
|
86
|
+
id: string,
|
|
87
|
+
): Promise<ProductWithVariants | null> {
|
|
88
|
+
const product = (await data.get("product", id)) as Product | null;
|
|
89
|
+
if (!product) return null;
|
|
90
|
+
|
|
91
|
+
const variants = (await data.findMany("productVariant", {
|
|
92
|
+
where: { productId: id },
|
|
93
|
+
})) as ProductVariant[];
|
|
94
|
+
|
|
95
|
+
let category: Category | undefined;
|
|
96
|
+
if (product.categoryId) {
|
|
97
|
+
category = (await data.get("category", product.categoryId)) as
|
|
98
|
+
| Category
|
|
99
|
+
| undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { ...product, variants, category };
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async listProducts(params?: ListProductsParams) {
|
|
106
|
+
const page = params?.page ?? 1;
|
|
107
|
+
const limit = params?.limit ?? 20;
|
|
108
|
+
|
|
109
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
110
|
+
const where: Record<string, any> = {};
|
|
111
|
+
if (params?.category) where.categoryId = params.category;
|
|
112
|
+
if (params?.status) where.status = params.status;
|
|
113
|
+
if (params?.featured) where.isFeatured = true;
|
|
114
|
+
|
|
115
|
+
let allProducts = (await data.findMany("product", {
|
|
116
|
+
where,
|
|
117
|
+
orderBy: params?.sort
|
|
118
|
+
? { [params.sort]: (params.order ?? "desc") as "asc" | "desc" }
|
|
119
|
+
: { createdAt: "desc" as const },
|
|
120
|
+
})) as Product[];
|
|
121
|
+
|
|
122
|
+
if (params?.minPrice !== undefined) {
|
|
123
|
+
const min = params.minPrice;
|
|
124
|
+
allProducts = allProducts.filter((p) => p.price >= min);
|
|
125
|
+
}
|
|
126
|
+
if (params?.maxPrice !== undefined) {
|
|
127
|
+
const max = params.maxPrice;
|
|
128
|
+
allProducts = allProducts.filter((p) => p.price <= max);
|
|
129
|
+
}
|
|
130
|
+
if (params?.inStock) {
|
|
131
|
+
allProducts = allProducts.filter((p) => p.inventory > 0);
|
|
132
|
+
}
|
|
133
|
+
if (params?.tag) {
|
|
134
|
+
const tagLower = params.tag.toLowerCase();
|
|
135
|
+
allProducts = allProducts.filter((p) =>
|
|
136
|
+
p.tags.some((t) => t.toLowerCase() === tagLower),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (params?.search) {
|
|
140
|
+
const searchLower = params.search.toLowerCase();
|
|
141
|
+
allProducts = allProducts.filter(
|
|
142
|
+
(p) =>
|
|
143
|
+
p.name.toLowerCase().includes(searchLower) ||
|
|
144
|
+
p.description?.toLowerCase().includes(searchLower) ||
|
|
145
|
+
p.tags.some((t) => t.toLowerCase().includes(searchLower)),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const total = allProducts.length;
|
|
150
|
+
const paged = allProducts.slice((page - 1) * limit, page * limit);
|
|
151
|
+
|
|
152
|
+
const productsWithVariants: ProductWithVariants[] = await Promise.all(
|
|
153
|
+
paged.map(async (product) => {
|
|
154
|
+
const variants = (await data.findMany("productVariant", {
|
|
155
|
+
where: { productId: product.id },
|
|
156
|
+
})) as ProductVariant[];
|
|
157
|
+
|
|
158
|
+
let category: Category | undefined;
|
|
159
|
+
if (product.categoryId) {
|
|
160
|
+
category = (await data.get("category", product.categoryId)) as
|
|
161
|
+
| Category
|
|
162
|
+
| undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { ...product, variants, category };
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return { products: productsWithVariants, total, page, limit };
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
async searchProducts(q: string, limit?: number): Promise<Product[]> {
|
|
173
|
+
const products = (await data.findMany("product", {
|
|
174
|
+
where: { status: "active" },
|
|
175
|
+
})) as Product[];
|
|
176
|
+
|
|
177
|
+
const queryLower = q.toLowerCase();
|
|
178
|
+
const results = products.filter(
|
|
179
|
+
(p) =>
|
|
180
|
+
p.name.toLowerCase().includes(queryLower) ||
|
|
181
|
+
p.description?.toLowerCase().includes(queryLower) ||
|
|
182
|
+
p.tags.some((t) => t.toLowerCase().includes(queryLower)),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
return results.slice(0, limit ?? 20);
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
async getFeaturedProducts(limit?: number): Promise<Product[]> {
|
|
189
|
+
return (await data.findMany("product", {
|
|
190
|
+
where: { isFeatured: true, status: "active" },
|
|
191
|
+
take: limit ?? 10,
|
|
192
|
+
})) as Product[];
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
async getProductsByCategory(categoryId: string): Promise<Product[]> {
|
|
196
|
+
return (await data.findMany("product", {
|
|
197
|
+
where: { categoryId, status: "active" },
|
|
198
|
+
})) as Product[];
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async getRelatedProducts(
|
|
202
|
+
id: string,
|
|
203
|
+
limit?: number,
|
|
204
|
+
): Promise<{ products: Product[] }> {
|
|
205
|
+
const maxResults = limit ?? 4;
|
|
206
|
+
const product = (await data.get("product", id)) as Product | null;
|
|
207
|
+
if (!product) return { products: [] };
|
|
208
|
+
|
|
209
|
+
const all = (
|
|
210
|
+
(await data.findMany("product", {
|
|
211
|
+
where: { status: "active" },
|
|
212
|
+
})) as Product[]
|
|
213
|
+
).filter((p) => p.id !== id);
|
|
214
|
+
|
|
215
|
+
const scored = all.map((p) => {
|
|
216
|
+
let score = 0;
|
|
217
|
+
if (product.categoryId && p.categoryId === product.categoryId) {
|
|
218
|
+
score += 10;
|
|
219
|
+
}
|
|
220
|
+
const sharedTags = p.tags.filter((t) => product.tags.includes(t));
|
|
221
|
+
score += sharedTags.length;
|
|
222
|
+
return { product: p, score };
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
scored.sort((a, b) => b.score - a.score);
|
|
226
|
+
return { products: scored.slice(0, maxResults).map((s) => s.product) };
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
async updateProduct(
|
|
230
|
+
id: string,
|
|
231
|
+
params: Partial<Product>,
|
|
232
|
+
): Promise<Product> {
|
|
233
|
+
const existing = (await data.get("product", id)) as Product | null;
|
|
234
|
+
if (!existing) throw new Error(`Product ${id} not found`);
|
|
235
|
+
|
|
236
|
+
const updated: Product = {
|
|
237
|
+
...existing,
|
|
238
|
+
...params,
|
|
239
|
+
updatedAt: new Date(),
|
|
240
|
+
};
|
|
241
|
+
await data.upsert(
|
|
242
|
+
"product",
|
|
243
|
+
id,
|
|
244
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
245
|
+
updated as unknown as Record<string, any>,
|
|
246
|
+
);
|
|
247
|
+
return updated;
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
async deleteProduct(id: string): Promise<{ success: boolean }> {
|
|
251
|
+
const variants = (await data.findMany("productVariant", {
|
|
252
|
+
where: { productId: id },
|
|
253
|
+
})) as ProductVariant[];
|
|
254
|
+
|
|
255
|
+
for (const variant of variants) {
|
|
256
|
+
await data.delete("productVariant", variant.id);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await data.delete("product", id);
|
|
260
|
+
return { success: true };
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
// ── Inventory ─────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
async checkAvailability(
|
|
266
|
+
productId: string,
|
|
267
|
+
variantId?: string,
|
|
268
|
+
quantity?: number,
|
|
269
|
+
) {
|
|
270
|
+
const qty = quantity ?? 1;
|
|
271
|
+
|
|
272
|
+
if (variantId) {
|
|
273
|
+
const variant = (await data.get(
|
|
274
|
+
"productVariant",
|
|
275
|
+
variantId,
|
|
276
|
+
)) as ProductVariant | null;
|
|
277
|
+
if (!variant)
|
|
278
|
+
return { available: false, inventory: 0, allowBackorder: false };
|
|
279
|
+
|
|
280
|
+
const product = (await data.get(
|
|
281
|
+
"product",
|
|
282
|
+
productId,
|
|
283
|
+
)) as Product | null;
|
|
284
|
+
const allowBackorder = product?.allowBackorder ?? false;
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
available: variant.inventory >= qty || allowBackorder,
|
|
288
|
+
inventory: variant.inventory,
|
|
289
|
+
allowBackorder,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const product = (await data.get("product", productId)) as Product | null;
|
|
294
|
+
if (!product)
|
|
295
|
+
return { available: false, inventory: 0, allowBackorder: false };
|
|
296
|
+
|
|
297
|
+
if (!product.trackInventory) {
|
|
298
|
+
return {
|
|
299
|
+
available: true,
|
|
300
|
+
inventory: product.inventory,
|
|
301
|
+
allowBackorder: product.allowBackorder,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
available: product.inventory >= qty || product.allowBackorder,
|
|
307
|
+
inventory: product.inventory,
|
|
308
|
+
allowBackorder: product.allowBackorder,
|
|
309
|
+
};
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
async decrementInventory(
|
|
313
|
+
productId: string,
|
|
314
|
+
quantity: number,
|
|
315
|
+
variantId?: string,
|
|
316
|
+
) {
|
|
317
|
+
if (variantId) {
|
|
318
|
+
const variant = (await data.get(
|
|
319
|
+
"productVariant",
|
|
320
|
+
variantId,
|
|
321
|
+
)) as ProductVariant | null;
|
|
322
|
+
if (variant) {
|
|
323
|
+
await data.upsert("productVariant", variantId, {
|
|
324
|
+
...variant,
|
|
325
|
+
inventory: variant.inventory - quantity,
|
|
326
|
+
updatedAt: new Date(),
|
|
327
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
328
|
+
} as Record<string, any>);
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
const product = (await data.get(
|
|
332
|
+
"product",
|
|
333
|
+
productId,
|
|
334
|
+
)) as Product | null;
|
|
335
|
+
if (product?.trackInventory) {
|
|
336
|
+
await data.upsert("product", productId, {
|
|
337
|
+
...product,
|
|
338
|
+
inventory: product.inventory - quantity,
|
|
339
|
+
updatedAt: new Date(),
|
|
340
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
341
|
+
} as Record<string, any>);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return { success: true };
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
async incrementInventory(
|
|
348
|
+
productId: string,
|
|
349
|
+
quantity: number,
|
|
350
|
+
variantId?: string,
|
|
351
|
+
) {
|
|
352
|
+
if (variantId) {
|
|
353
|
+
const variant = (await data.get(
|
|
354
|
+
"productVariant",
|
|
355
|
+
variantId,
|
|
356
|
+
)) as ProductVariant | null;
|
|
357
|
+
if (variant) {
|
|
358
|
+
await data.upsert("productVariant", variantId, {
|
|
359
|
+
...variant,
|
|
360
|
+
inventory: variant.inventory + quantity,
|
|
361
|
+
updatedAt: new Date(),
|
|
362
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
363
|
+
} as Record<string, any>);
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
const product = (await data.get(
|
|
367
|
+
"product",
|
|
368
|
+
productId,
|
|
369
|
+
)) as Product | null;
|
|
370
|
+
if (product?.trackInventory) {
|
|
371
|
+
await data.upsert("product", productId, {
|
|
372
|
+
...product,
|
|
373
|
+
inventory: product.inventory + quantity,
|
|
374
|
+
updatedAt: new Date(),
|
|
375
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
376
|
+
} as Record<string, any>);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return { success: true };
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
// ── Variants ──────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
async getVariant(id: string): Promise<ProductVariant | null> {
|
|
385
|
+
return (await data.get("productVariant", id)) as ProductVariant | null;
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
async getVariantsByProduct(productId: string): Promise<ProductVariant[]> {
|
|
389
|
+
const variants = (await data.findMany("productVariant", {
|
|
390
|
+
where: { productId },
|
|
391
|
+
})) as ProductVariant[];
|
|
392
|
+
return variants.sort((a, b) => a.position - b.position);
|
|
393
|
+
},
|
|
394
|
+
|
|
395
|
+
async createVariant(params: CreateVariantParams): Promise<ProductVariant> {
|
|
396
|
+
const now = new Date();
|
|
397
|
+
const id = `var_${crypto.randomUUID()}`;
|
|
398
|
+
|
|
399
|
+
const variant: ProductVariant = {
|
|
400
|
+
id,
|
|
401
|
+
productId: params.productId,
|
|
402
|
+
name: params.name,
|
|
403
|
+
sku: params.sku,
|
|
404
|
+
barcode: params.barcode,
|
|
405
|
+
price: params.price,
|
|
406
|
+
compareAtPrice: params.compareAtPrice,
|
|
407
|
+
costPrice: params.costPrice,
|
|
408
|
+
inventory: params.inventory ?? 0,
|
|
409
|
+
options: params.options,
|
|
410
|
+
images: params.images ?? [],
|
|
411
|
+
weight: params.weight,
|
|
412
|
+
weightUnit: params.weightUnit,
|
|
413
|
+
position: params.position ?? 0,
|
|
414
|
+
createdAt: now,
|
|
415
|
+
updatedAt: now,
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
await data.upsert(
|
|
419
|
+
"productVariant",
|
|
420
|
+
id,
|
|
421
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
422
|
+
variant as unknown as Record<string, any>,
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
// Update product timestamp
|
|
426
|
+
const product = (await data.get(
|
|
427
|
+
"product",
|
|
428
|
+
params.productId,
|
|
429
|
+
)) as Product | null;
|
|
430
|
+
if (product) {
|
|
431
|
+
await data.upsert("product", params.productId, {
|
|
432
|
+
...product,
|
|
433
|
+
updatedAt: now,
|
|
434
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
435
|
+
} as unknown as Record<string, any>);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return variant;
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
async updateVariant(
|
|
442
|
+
id: string,
|
|
443
|
+
params: Partial<ProductVariant>,
|
|
444
|
+
): Promise<ProductVariant> {
|
|
445
|
+
const existing = (await data.get(
|
|
446
|
+
"productVariant",
|
|
447
|
+
id,
|
|
448
|
+
)) as ProductVariant | null;
|
|
449
|
+
if (!existing) throw new Error(`Variant ${id} not found`);
|
|
450
|
+
|
|
451
|
+
const now = new Date();
|
|
452
|
+
const updated: ProductVariant = {
|
|
453
|
+
...existing,
|
|
454
|
+
...params,
|
|
455
|
+
updatedAt: now,
|
|
456
|
+
};
|
|
457
|
+
await data.upsert(
|
|
458
|
+
"productVariant",
|
|
459
|
+
id,
|
|
460
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
461
|
+
updated as unknown as Record<string, any>,
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
// Update product timestamp
|
|
465
|
+
const product = (await data.get(
|
|
466
|
+
"product",
|
|
467
|
+
existing.productId,
|
|
468
|
+
)) as Product | null;
|
|
469
|
+
if (product) {
|
|
470
|
+
await data.upsert("product", existing.productId, {
|
|
471
|
+
...product,
|
|
472
|
+
updatedAt: now,
|
|
473
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
474
|
+
} as unknown as Record<string, any>);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return updated;
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
async deleteVariant(id: string): Promise<{ success: boolean }> {
|
|
481
|
+
const variant = (await data.get(
|
|
482
|
+
"productVariant",
|
|
483
|
+
id,
|
|
484
|
+
)) as ProductVariant | null;
|
|
485
|
+
if (variant) {
|
|
486
|
+
await data.delete("productVariant", id);
|
|
487
|
+
|
|
488
|
+
const product = (await data.get(
|
|
489
|
+
"product",
|
|
490
|
+
variant.productId,
|
|
491
|
+
)) as Product | null;
|
|
492
|
+
if (product) {
|
|
493
|
+
await data.upsert("product", variant.productId, {
|
|
494
|
+
...product,
|
|
495
|
+
updatedAt: new Date(),
|
|
496
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
497
|
+
} as unknown as Record<string, any>);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return { success: true };
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
// ── Categories ────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
async getCategory(id: string): Promise<Category | null> {
|
|
507
|
+
return (await data.get("category", id)) as Category | null;
|
|
508
|
+
},
|
|
509
|
+
|
|
510
|
+
async getCategoryBySlug(slug: string): Promise<Category | null> {
|
|
511
|
+
const categories = (await data.findMany("category", {
|
|
512
|
+
where: { slug },
|
|
513
|
+
})) as Category[];
|
|
514
|
+
return categories[0] ?? null;
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
async listCategories(params?: {
|
|
518
|
+
page?: number;
|
|
519
|
+
limit?: number;
|
|
520
|
+
parentId?: string;
|
|
521
|
+
visible?: boolean;
|
|
522
|
+
}) {
|
|
523
|
+
const page = params?.page ?? 1;
|
|
524
|
+
const limit = params?.limit ?? 50;
|
|
525
|
+
|
|
526
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
527
|
+
const where: Record<string, any> = {};
|
|
528
|
+
if (params?.parentId) where.parentId = params.parentId;
|
|
529
|
+
if (params?.visible) where.isVisible = true;
|
|
530
|
+
|
|
531
|
+
const categories = (await data.findMany("category", {
|
|
532
|
+
where,
|
|
533
|
+
take: limit,
|
|
534
|
+
skip: (page - 1) * limit,
|
|
535
|
+
})) as Category[];
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
categories: categories.sort((a, b) => a.position - b.position),
|
|
539
|
+
page,
|
|
540
|
+
limit,
|
|
541
|
+
};
|
|
542
|
+
},
|
|
543
|
+
|
|
544
|
+
async getCategoryTree(): Promise<CategoryWithChildren[]> {
|
|
545
|
+
const allCategories = (await data.findMany("category", {
|
|
546
|
+
where: { isVisible: true },
|
|
547
|
+
})) as Category[];
|
|
548
|
+
|
|
549
|
+
const rootCategories: CategoryWithChildren[] = [];
|
|
550
|
+
const categoryMap = new Map<string, CategoryWithChildren>();
|
|
551
|
+
|
|
552
|
+
for (const cat of allCategories) {
|
|
553
|
+
categoryMap.set(cat.id, { ...cat, children: [] });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
for (const cat of allCategories) {
|
|
557
|
+
// biome-ignore lint/style/noNonNullAssertion: categoryMap is populated from allCategories
|
|
558
|
+
const catWithChildren = categoryMap.get(cat.id)!;
|
|
559
|
+
if (cat.parentId) {
|
|
560
|
+
const parent = categoryMap.get(cat.parentId);
|
|
561
|
+
if (parent) {
|
|
562
|
+
parent.children.push(catWithChildren);
|
|
563
|
+
} else {
|
|
564
|
+
rootCategories.push(catWithChildren);
|
|
565
|
+
}
|
|
566
|
+
} else {
|
|
567
|
+
rootCategories.push(catWithChildren);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
rootCategories.sort((a, b) => a.position - b.position);
|
|
572
|
+
for (const cat of categoryMap.values()) {
|
|
573
|
+
cat.children.sort((a, b) => a.position - b.position);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return rootCategories;
|
|
577
|
+
},
|
|
578
|
+
|
|
579
|
+
async createCategory(params: CreateCategoryParams): Promise<Category> {
|
|
580
|
+
const now = new Date();
|
|
581
|
+
const id = `cat_${crypto.randomUUID()}`;
|
|
582
|
+
|
|
583
|
+
const category: Category = {
|
|
584
|
+
id,
|
|
585
|
+
name: params.name,
|
|
586
|
+
slug: params.slug,
|
|
587
|
+
description: params.description,
|
|
588
|
+
parentId: params.parentId,
|
|
589
|
+
image: params.image,
|
|
590
|
+
position: params.position ?? 0,
|
|
591
|
+
isVisible: params.isVisible ?? true,
|
|
592
|
+
metadata: params.metadata ?? {},
|
|
593
|
+
createdAt: now,
|
|
594
|
+
updatedAt: now,
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
await data.upsert(
|
|
598
|
+
"category",
|
|
599
|
+
id,
|
|
600
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
601
|
+
category as unknown as Record<string, any>,
|
|
602
|
+
);
|
|
603
|
+
return category;
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
async updateCategory(
|
|
607
|
+
id: string,
|
|
608
|
+
params: Partial<Category>,
|
|
609
|
+
): Promise<Category> {
|
|
610
|
+
const existing = (await data.get("category", id)) as Category | null;
|
|
611
|
+
if (!existing) throw new Error(`Category ${id} not found`);
|
|
612
|
+
|
|
613
|
+
const updated: Category = {
|
|
614
|
+
...existing,
|
|
615
|
+
...params,
|
|
616
|
+
updatedAt: new Date(),
|
|
617
|
+
};
|
|
618
|
+
await data.upsert(
|
|
619
|
+
"category",
|
|
620
|
+
id,
|
|
621
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
622
|
+
updated as unknown as Record<string, any>,
|
|
623
|
+
);
|
|
624
|
+
return updated;
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
async deleteCategory(id: string): Promise<{ success: boolean }> {
|
|
628
|
+
// Orphan products
|
|
629
|
+
const products = (await data.findMany("product", {
|
|
630
|
+
where: { categoryId: id },
|
|
631
|
+
})) as Product[];
|
|
632
|
+
|
|
633
|
+
for (const product of products) {
|
|
634
|
+
await data.upsert("product", product.id, {
|
|
635
|
+
...product,
|
|
636
|
+
categoryId: undefined,
|
|
637
|
+
updatedAt: new Date(),
|
|
638
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
639
|
+
} as unknown as Record<string, any>);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Orphan subcategories
|
|
643
|
+
const subcategories = (await data.findMany("category", {
|
|
644
|
+
where: { parentId: id },
|
|
645
|
+
})) as Category[];
|
|
646
|
+
|
|
647
|
+
for (const subcat of subcategories) {
|
|
648
|
+
await data.upsert("category", subcat.id, {
|
|
649
|
+
...subcat,
|
|
650
|
+
parentId: undefined,
|
|
651
|
+
updatedAt: new Date(),
|
|
652
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
653
|
+
} as unknown as Record<string, any>);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
await data.delete("category", id);
|
|
657
|
+
return { success: true };
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
// ── Bulk ──────────────────────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
async bulkUpdateStatus(
|
|
663
|
+
ids: string[],
|
|
664
|
+
status: "draft" | "active" | "archived",
|
|
665
|
+
) {
|
|
666
|
+
if (!ids.length) return { updated: 0 };
|
|
667
|
+
|
|
668
|
+
const now = new Date();
|
|
669
|
+
let updated = 0;
|
|
670
|
+
|
|
671
|
+
for (const id of ids) {
|
|
672
|
+
const product = (await data.get("product", id)) as Product | null;
|
|
673
|
+
if (product) {
|
|
674
|
+
await data.upsert("product", id, {
|
|
675
|
+
...product,
|
|
676
|
+
status,
|
|
677
|
+
updatedAt: now,
|
|
678
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
679
|
+
} as unknown as Record<string, any>);
|
|
680
|
+
updated++;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return { updated };
|
|
685
|
+
},
|
|
686
|
+
|
|
687
|
+
async bulkDelete(ids: string[]) {
|
|
688
|
+
if (!ids.length) return { deleted: 0 };
|
|
689
|
+
|
|
690
|
+
let deleted = 0;
|
|
691
|
+
|
|
692
|
+
for (const id of ids) {
|
|
693
|
+
const product = (await data.get("product", id)) as Product | null;
|
|
694
|
+
if (!product) continue;
|
|
695
|
+
|
|
696
|
+
const variants = (await data.findMany("productVariant", {
|
|
697
|
+
where: { productId: id },
|
|
698
|
+
})) as ProductVariant[];
|
|
699
|
+
|
|
700
|
+
for (const variant of variants) {
|
|
701
|
+
await data.delete("productVariant", variant.id);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
await data.delete("product", id);
|
|
705
|
+
deleted++;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return { deleted };
|
|
709
|
+
},
|
|
710
|
+
|
|
711
|
+
// ── Import ────────────────────────────────────────────────────────
|
|
712
|
+
|
|
713
|
+
async importProducts(rows: ImportProductRow[]): Promise<ImportResult> {
|
|
714
|
+
const created: string[] = [];
|
|
715
|
+
const updated: string[] = [];
|
|
716
|
+
const errors: ImportError[] = [];
|
|
717
|
+
|
|
718
|
+
// Pre-fetch categories for name→id resolution
|
|
719
|
+
const allCategories = (await data.findMany("category", {
|
|
720
|
+
where: {},
|
|
721
|
+
})) as Category[];
|
|
722
|
+
const categoryByName = new Map<string, string>();
|
|
723
|
+
for (const cat of allCategories) {
|
|
724
|
+
categoryByName.set(cat.name.toLowerCase(), cat.id);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Pre-fetch SKUs for update-by-SKU matching
|
|
728
|
+
const allProducts = (await data.findMany("product", {
|
|
729
|
+
where: {},
|
|
730
|
+
})) as Product[];
|
|
731
|
+
const productBySku = new Map<string, Product>();
|
|
732
|
+
const slugSet = new Set<string>();
|
|
733
|
+
for (const p of allProducts) {
|
|
734
|
+
if (p.sku) productBySku.set(p.sku, p);
|
|
735
|
+
slugSet.add(p.slug);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
for (let i = 0; i < rows.length; i++) {
|
|
739
|
+
const row = rows[i];
|
|
740
|
+
try {
|
|
741
|
+
if (!row.name || row.name.trim() === "") {
|
|
742
|
+
errors.push({
|
|
743
|
+
row: i + 1,
|
|
744
|
+
field: "name",
|
|
745
|
+
message: "Name is required",
|
|
746
|
+
});
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if (row.price === undefined || row.price === null) {
|
|
750
|
+
errors.push({
|
|
751
|
+
row: i + 1,
|
|
752
|
+
field: "price",
|
|
753
|
+
message: "Price is required",
|
|
754
|
+
});
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
const price = Math.round(Number(row.price) * 100);
|
|
758
|
+
if (Number.isNaN(price) || price <= 0) {
|
|
759
|
+
errors.push({
|
|
760
|
+
row: i + 1,
|
|
761
|
+
field: "price",
|
|
762
|
+
message: "Price must be a positive number",
|
|
763
|
+
});
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const existingBySku = row.sku ? productBySku.get(row.sku) : undefined;
|
|
768
|
+
if (existingBySku) {
|
|
769
|
+
const updateFields: Partial<Product> = {
|
|
770
|
+
name: row.name,
|
|
771
|
+
price,
|
|
772
|
+
updatedAt: new Date(),
|
|
773
|
+
};
|
|
774
|
+
if (row.description !== undefined)
|
|
775
|
+
updateFields.description = row.description;
|
|
776
|
+
if (row.shortDescription !== undefined)
|
|
777
|
+
updateFields.shortDescription = row.shortDescription;
|
|
778
|
+
if (row.compareAtPrice !== undefined)
|
|
779
|
+
updateFields.compareAtPrice = Math.round(
|
|
780
|
+
Number(row.compareAtPrice) * 100,
|
|
781
|
+
);
|
|
782
|
+
if (row.costPrice !== undefined)
|
|
783
|
+
updateFields.costPrice = Math.round(Number(row.costPrice) * 100);
|
|
784
|
+
if (row.inventory !== undefined)
|
|
785
|
+
updateFields.inventory = Number(row.inventory);
|
|
786
|
+
if (row.status !== undefined)
|
|
787
|
+
updateFields.status = row.status as
|
|
788
|
+
| "draft"
|
|
789
|
+
| "active"
|
|
790
|
+
| "archived";
|
|
791
|
+
if (row.category) {
|
|
792
|
+
const catId = categoryByName.get(row.category.toLowerCase());
|
|
793
|
+
if (catId) updateFields.categoryId = catId;
|
|
794
|
+
}
|
|
795
|
+
if (row.tags !== undefined) updateFields.tags = row.tags;
|
|
796
|
+
if (row.weight !== undefined)
|
|
797
|
+
updateFields.weight = Number(row.weight);
|
|
798
|
+
if (row.weightUnit !== undefined)
|
|
799
|
+
updateFields.weightUnit = row.weightUnit as
|
|
800
|
+
| "kg"
|
|
801
|
+
| "lb"
|
|
802
|
+
| "oz"
|
|
803
|
+
| "g";
|
|
804
|
+
if (row.featured !== undefined)
|
|
805
|
+
updateFields.isFeatured = row.featured;
|
|
806
|
+
if (row.trackInventory !== undefined)
|
|
807
|
+
updateFields.trackInventory = row.trackInventory;
|
|
808
|
+
if (row.allowBackorder !== undefined)
|
|
809
|
+
updateFields.allowBackorder = row.allowBackorder;
|
|
810
|
+
|
|
811
|
+
const updatedProduct = { ...existingBySku, ...updateFields };
|
|
812
|
+
await data.upsert(
|
|
813
|
+
"product",
|
|
814
|
+
existingBySku.id,
|
|
815
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
816
|
+
updatedProduct as unknown as Record<string, any>,
|
|
817
|
+
);
|
|
818
|
+
updated.push(existingBySku.id);
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
let slug = row.slug || generateSlug(row.name);
|
|
823
|
+
let slugAttempt = 0;
|
|
824
|
+
const baseSlug = slug;
|
|
825
|
+
while (slugSet.has(slug)) {
|
|
826
|
+
slugAttempt++;
|
|
827
|
+
slug = `${baseSlug}-${slugAttempt}`;
|
|
828
|
+
}
|
|
829
|
+
slugSet.add(slug);
|
|
830
|
+
|
|
831
|
+
let categoryId: string | undefined;
|
|
832
|
+
if (row.category) {
|
|
833
|
+
categoryId = categoryByName.get(row.category.toLowerCase());
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const now = new Date();
|
|
837
|
+
const id = `prod_${crypto.randomUUID()}`;
|
|
838
|
+
|
|
839
|
+
const product: Product = {
|
|
840
|
+
id,
|
|
841
|
+
name: row.name.trim(),
|
|
842
|
+
slug,
|
|
843
|
+
description: row.description,
|
|
844
|
+
shortDescription: row.shortDescription,
|
|
845
|
+
price,
|
|
846
|
+
compareAtPrice: row.compareAtPrice
|
|
847
|
+
? Math.round(Number(row.compareAtPrice) * 100)
|
|
848
|
+
: undefined,
|
|
849
|
+
costPrice: row.costPrice
|
|
850
|
+
? Math.round(Number(row.costPrice) * 100)
|
|
851
|
+
: undefined,
|
|
852
|
+
sku: row.sku,
|
|
853
|
+
barcode: row.barcode,
|
|
854
|
+
inventory: row.inventory !== undefined ? Number(row.inventory) : 0,
|
|
855
|
+
trackInventory: row.trackInventory ?? true,
|
|
856
|
+
allowBackorder: row.allowBackorder ?? false,
|
|
857
|
+
status: (row.status as "draft" | "active" | "archived") || "draft",
|
|
858
|
+
categoryId,
|
|
859
|
+
images: [],
|
|
860
|
+
tags: row.tags ?? [],
|
|
861
|
+
metadata: {},
|
|
862
|
+
weight: row.weight !== undefined ? Number(row.weight) : undefined,
|
|
863
|
+
weightUnit: (row.weightUnit as "kg" | "lb" | "oz" | "g") || "kg",
|
|
864
|
+
isFeatured: row.featured ?? false,
|
|
865
|
+
createdAt: now,
|
|
866
|
+
updatedAt: now,
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
await data.upsert(
|
|
870
|
+
"product",
|
|
871
|
+
id,
|
|
872
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
873
|
+
product as unknown as Record<string, any>,
|
|
874
|
+
);
|
|
875
|
+
created.push(id);
|
|
876
|
+
} catch (err) {
|
|
877
|
+
errors.push({
|
|
878
|
+
row: i + 1,
|
|
879
|
+
field: "unknown",
|
|
880
|
+
message: err instanceof Error ? err.message : "Unknown error",
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
return { created: created.length, updated: updated.length, errors };
|
|
886
|
+
},
|
|
887
|
+
|
|
888
|
+
// ── Collections ───────────────────────────────────────────────────
|
|
889
|
+
|
|
890
|
+
async getCollection(id: string): Promise<Collection | null> {
|
|
891
|
+
return (await data.get("collection", id)) as Collection | null;
|
|
892
|
+
},
|
|
893
|
+
|
|
894
|
+
async getCollectionBySlug(slug: string): Promise<Collection | null> {
|
|
895
|
+
const collections = (await data.findMany("collection", {
|
|
896
|
+
where: { slug },
|
|
897
|
+
})) as Collection[];
|
|
898
|
+
return collections[0] ?? null;
|
|
899
|
+
},
|
|
900
|
+
|
|
901
|
+
async listCollections(params?: {
|
|
902
|
+
page?: number;
|
|
903
|
+
limit?: number;
|
|
904
|
+
featured?: boolean;
|
|
905
|
+
visible?: boolean;
|
|
906
|
+
}) {
|
|
907
|
+
const page = params?.page ?? 1;
|
|
908
|
+
const limit = params?.limit ?? 50;
|
|
909
|
+
|
|
910
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
911
|
+
const where: Record<string, any> = {};
|
|
912
|
+
if (params?.featured) where.isFeatured = true;
|
|
913
|
+
if (params?.visible) where.isVisible = true;
|
|
914
|
+
|
|
915
|
+
const collections = (await data.findMany("collection", {
|
|
916
|
+
where,
|
|
917
|
+
take: limit,
|
|
918
|
+
skip: (page - 1) * limit,
|
|
919
|
+
})) as Collection[];
|
|
920
|
+
|
|
921
|
+
return {
|
|
922
|
+
collections: collections.sort((a, b) => a.position - b.position),
|
|
923
|
+
page,
|
|
924
|
+
limit,
|
|
925
|
+
};
|
|
926
|
+
},
|
|
927
|
+
|
|
928
|
+
async searchCollections(q: string, limit?: number): Promise<Collection[]> {
|
|
929
|
+
const collections = (await data.findMany("collection", {
|
|
930
|
+
where: { isVisible: true },
|
|
931
|
+
})) as Collection[];
|
|
932
|
+
|
|
933
|
+
const queryLower = q.toLowerCase();
|
|
934
|
+
const results = collections.filter(
|
|
935
|
+
(c) =>
|
|
936
|
+
c.name.toLowerCase().includes(queryLower) ||
|
|
937
|
+
c.slug.toLowerCase().includes(queryLower) ||
|
|
938
|
+
c.description?.toLowerCase().includes(queryLower),
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
return results
|
|
942
|
+
.sort((a, b) => a.position - b.position)
|
|
943
|
+
.slice(0, limit ?? 10);
|
|
944
|
+
},
|
|
945
|
+
|
|
946
|
+
async getCollectionWithProducts(
|
|
947
|
+
id: string,
|
|
948
|
+
): Promise<CollectionWithProducts | null> {
|
|
949
|
+
const collection = (await data.get(
|
|
950
|
+
"collection",
|
|
951
|
+
id,
|
|
952
|
+
)) as Collection | null;
|
|
953
|
+
if (!collection) return null;
|
|
954
|
+
|
|
955
|
+
const links = (await data.findMany("collectionProduct", {
|
|
956
|
+
where: { collectionId: id },
|
|
957
|
+
})) as CollectionProduct[];
|
|
958
|
+
|
|
959
|
+
links.sort((a, b) => a.position - b.position);
|
|
960
|
+
|
|
961
|
+
const products: Product[] = [];
|
|
962
|
+
for (const link of links) {
|
|
963
|
+
const product = (await data.get(
|
|
964
|
+
"product",
|
|
965
|
+
link.productId,
|
|
966
|
+
)) as Product | null;
|
|
967
|
+
if (product && product.status === "active") {
|
|
968
|
+
products.push(product);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return { ...collection, products };
|
|
973
|
+
},
|
|
974
|
+
|
|
975
|
+
async createCollection(
|
|
976
|
+
params: CreateCollectionParams,
|
|
977
|
+
): Promise<Collection> {
|
|
978
|
+
const now = new Date();
|
|
979
|
+
const id = `col_${crypto.randomUUID()}`;
|
|
980
|
+
|
|
981
|
+
const collection: Collection = {
|
|
982
|
+
id,
|
|
983
|
+
name: params.name,
|
|
984
|
+
slug: params.slug,
|
|
985
|
+
description: params.description,
|
|
986
|
+
image: params.image,
|
|
987
|
+
isFeatured: params.isFeatured ?? false,
|
|
988
|
+
isVisible: params.isVisible ?? true,
|
|
989
|
+
position: params.position ?? 0,
|
|
990
|
+
metadata: params.metadata ?? {},
|
|
991
|
+
createdAt: now,
|
|
992
|
+
updatedAt: now,
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
await data.upsert(
|
|
996
|
+
"collection",
|
|
997
|
+
id,
|
|
998
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
999
|
+
collection as unknown as Record<string, any>,
|
|
1000
|
+
);
|
|
1001
|
+
return collection;
|
|
1002
|
+
},
|
|
1003
|
+
|
|
1004
|
+
async updateCollection(
|
|
1005
|
+
id: string,
|
|
1006
|
+
params: Partial<Collection>,
|
|
1007
|
+
): Promise<Collection> {
|
|
1008
|
+
const existing = (await data.get("collection", id)) as Collection | null;
|
|
1009
|
+
if (!existing) throw new Error(`Collection ${id} not found`);
|
|
1010
|
+
|
|
1011
|
+
const updated: Collection = {
|
|
1012
|
+
...existing,
|
|
1013
|
+
...params,
|
|
1014
|
+
updatedAt: new Date(),
|
|
1015
|
+
};
|
|
1016
|
+
await data.upsert(
|
|
1017
|
+
"collection",
|
|
1018
|
+
id,
|
|
1019
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
1020
|
+
updated as unknown as Record<string, any>,
|
|
1021
|
+
);
|
|
1022
|
+
return updated;
|
|
1023
|
+
},
|
|
1024
|
+
|
|
1025
|
+
async deleteCollection(id: string): Promise<{ success: boolean }> {
|
|
1026
|
+
const links = (await data.findMany("collectionProduct", {
|
|
1027
|
+
where: { collectionId: id },
|
|
1028
|
+
})) as CollectionProduct[];
|
|
1029
|
+
|
|
1030
|
+
for (const link of links) {
|
|
1031
|
+
await data.delete("collectionProduct", link.id);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
await data.delete("collection", id);
|
|
1035
|
+
return { success: true };
|
|
1036
|
+
},
|
|
1037
|
+
|
|
1038
|
+
async addProductToCollection(
|
|
1039
|
+
collectionId: string,
|
|
1040
|
+
productId: string,
|
|
1041
|
+
position?: number,
|
|
1042
|
+
): Promise<CollectionProduct> {
|
|
1043
|
+
const collection = (await data.get(
|
|
1044
|
+
"collection",
|
|
1045
|
+
collectionId,
|
|
1046
|
+
)) as Collection | null;
|
|
1047
|
+
if (!collection) throw new Error(`Collection ${collectionId} not found`);
|
|
1048
|
+
|
|
1049
|
+
// Check duplicate
|
|
1050
|
+
const existing = (await data.findMany("collectionProduct", {
|
|
1051
|
+
where: { collectionId, productId },
|
|
1052
|
+
})) as CollectionProduct[];
|
|
1053
|
+
if (existing.length > 0) {
|
|
1054
|
+
return existing[0];
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const linkId = `cp_${crypto.randomUUID()}`;
|
|
1058
|
+
const link: CollectionProduct = {
|
|
1059
|
+
id: linkId,
|
|
1060
|
+
collectionId,
|
|
1061
|
+
productId,
|
|
1062
|
+
position: position ?? 0,
|
|
1063
|
+
createdAt: new Date(),
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
await data.upsert(
|
|
1067
|
+
"collectionProduct",
|
|
1068
|
+
linkId,
|
|
1069
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
1070
|
+
link as unknown as Record<string, any>,
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
// Update collection timestamp
|
|
1074
|
+
await data.upsert("collection", collectionId, {
|
|
1075
|
+
...collection,
|
|
1076
|
+
updatedAt: new Date(),
|
|
1077
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
1078
|
+
} as unknown as Record<string, any>);
|
|
1079
|
+
|
|
1080
|
+
return link;
|
|
1081
|
+
},
|
|
1082
|
+
|
|
1083
|
+
async removeProductFromCollection(
|
|
1084
|
+
collectionId: string,
|
|
1085
|
+
productId: string,
|
|
1086
|
+
): Promise<{ success: boolean }> {
|
|
1087
|
+
const links = (await data.findMany("collectionProduct", {
|
|
1088
|
+
where: { collectionId, productId },
|
|
1089
|
+
})) as CollectionProduct[];
|
|
1090
|
+
|
|
1091
|
+
for (const link of links) {
|
|
1092
|
+
await data.delete("collectionProduct", link.id);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const collection = (await data.get(
|
|
1096
|
+
"collection",
|
|
1097
|
+
collectionId,
|
|
1098
|
+
)) as Collection | null;
|
|
1099
|
+
if (collection) {
|
|
1100
|
+
await data.upsert("collection", collectionId, {
|
|
1101
|
+
...collection,
|
|
1102
|
+
updatedAt: new Date(),
|
|
1103
|
+
// biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
|
|
1104
|
+
} as unknown as Record<string, any>);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
return { success: true };
|
|
1108
|
+
},
|
|
1109
|
+
|
|
1110
|
+
async listCollectionProducts(
|
|
1111
|
+
collectionId: string,
|
|
1112
|
+
): Promise<{ products: Product[] }> {
|
|
1113
|
+
const collection = (await data.get(
|
|
1114
|
+
"collection",
|
|
1115
|
+
collectionId,
|
|
1116
|
+
)) as Collection | null;
|
|
1117
|
+
if (!collection) return { products: [] };
|
|
1118
|
+
|
|
1119
|
+
const links = (await data.findMany("collectionProduct", {
|
|
1120
|
+
where: { collectionId },
|
|
1121
|
+
})) as CollectionProduct[];
|
|
1122
|
+
|
|
1123
|
+
links.sort((a, b) => a.position - b.position);
|
|
1124
|
+
|
|
1125
|
+
const products: Product[] = [];
|
|
1126
|
+
for (const link of links) {
|
|
1127
|
+
const product = (await data.get(
|
|
1128
|
+
"product",
|
|
1129
|
+
link.productId,
|
|
1130
|
+
)) as Product | null;
|
|
1131
|
+
if (product) {
|
|
1132
|
+
products.push(product);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
return { products };
|
|
1137
|
+
},
|
|
1138
|
+
};
|
|
1139
|
+
}
|