@base44/app-plugin-commerce 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/base44/agents/commerce/StoreAdmin.jsonc +64 -0
- package/base44/entities/commerce.Cart.jsonc +73 -0
- package/base44/entities/commerce.Coupon.jsonc +113 -0
- package/base44/entities/commerce.Customer.jsonc +96 -0
- package/base44/entities/commerce.DownloadPermission.jsonc +54 -0
- package/base44/entities/commerce.EmailLog.jsonc +43 -0
- package/base44/entities/commerce.Order.jsonc +287 -0
- package/base44/entities/commerce.OrderNote.jsonc +31 -0
- package/base44/entities/commerce.OrderRefund.jsonc +64 -0
- package/base44/entities/commerce.PaymentGateway.jsonc +48 -0
- package/base44/entities/commerce.Product.jsonc +291 -0
- package/base44/entities/commerce.ProductAttribute.jsonc +39 -0
- package/base44/entities/commerce.ProductAttributeTerm.jsonc +38 -0
- package/base44/entities/commerce.ProductCategory.jsonc +51 -0
- package/base44/entities/commerce.ProductReview.jsonc +48 -0
- package/base44/entities/commerce.ProductTag.jsonc +30 -0
- package/base44/entities/commerce.ProductVariation.jsonc +167 -0
- package/base44/entities/commerce.ShippingClass.jsonc +30 -0
- package/base44/entities/commerce.ShippingZone.jsonc +41 -0
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +84 -0
- package/base44/entities/commerce.StoreSettings.jsonc +23 -0
- package/base44/entities/commerce.TaxClass.jsonc +23 -0
- package/base44/entities/commerce.TaxRate.jsonc +68 -0
- package/base44/entities/commerce.Webhook.jsonc +57 -0
- package/base44/entities/commerce.WebhookDelivery.jsonc +45 -0
- package/base44/functions/commerce/admin-coupons/entry.ts +100 -0
- package/base44/functions/commerce/admin-customers/entry.ts +141 -0
- package/base44/functions/commerce/admin-orders/entry.ts +396 -0
- package/base44/functions/commerce/admin-orders/helpers.ts +246 -0
- package/base44/functions/commerce/admin-products/entry.ts +506 -0
- package/base44/functions/commerce/admin-refunds/entry.ts +158 -0
- package/base44/functions/commerce/admin-reports/entry.ts +283 -0
- package/base44/functions/commerce/admin-reviews/entry.ts +66 -0
- package/base44/functions/commerce/admin-tools/entry.ts +261 -0
- package/base44/functions/commerce/admin-webhooks/entry.ts +52 -0
- package/base44/functions/commerce/payment-webhook/entry.ts +135 -0
- package/base44/functions/commerce/payments/entry.ts +238 -0
- package/base44/functions/commerce/seed-store/defaults.ts +162 -0
- package/base44/functions/commerce/seed-store/entry.ts +310 -0
- package/base44/functions/commerce/seed-store/sample-data.ts +349 -0
- package/base44/functions/commerce/storefront-account/entry.ts +207 -0
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-cart/entry.ts +283 -0
- package/base44/functions/commerce/storefront-catalog/entry.ts +459 -0
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-checkout/entry.ts +485 -0
- package/base44/shared/commerce/auth.ts +60 -0
- package/base44/shared/commerce/coupons.ts +257 -0
- package/base44/shared/commerce/data/continents.ts +75 -0
- package/base44/shared/commerce/data/countries.ts +307 -0
- package/base44/shared/commerce/data/currencies.ts +46 -0
- package/base44/shared/commerce/email-templates.ts +240 -0
- package/base44/shared/commerce/emails.ts +225 -0
- package/base44/shared/commerce/money.ts +66 -0
- package/base44/shared/commerce/orders.ts +251 -0
- package/base44/shared/commerce/payments.ts +495 -0
- package/base44/shared/commerce/reviews.ts +36 -0
- package/base44/shared/commerce/scan.ts +57 -0
- package/base44/shared/commerce/sequence.ts +35 -0
- package/base44/shared/commerce/settings.ts +57 -0
- package/base44/shared/commerce/shipping.ts +215 -0
- package/base44/shared/commerce/stock.ts +227 -0
- package/base44/shared/commerce/stripe.ts +463 -0
- package/base44/shared/commerce/tax.ts +136 -0
- package/base44/shared/commerce/totals.ts +314 -0
- package/base44/shared/commerce/webhooks.ts +116 -0
- package/package.json +37 -0
- package/scripts/install.js +156 -0
- package/skills/commerce/SKILL.md +62 -0
- package/skills/commerce/docs/api-admin.md +186 -0
- package/skills/commerce/docs/api-storefront.md +408 -0
- package/skills/commerce/installation-guidelines.md +91 -0
- package/skills/commerce/post-installation.md +157 -0
- package/skills/commerce/references/emails.md +13 -0
- package/skills/commerce/references/guest-access-security.md +18 -0
- package/skills/commerce/references/limits-and-performance.md +16 -0
- package/skills/commerce/references/media-and-downloads.md +4 -0
- package/skills/commerce/references/online-payments.md +201 -0
- package/skills/commerce/references/product-render.md +87 -0
- package/skills/commerce/references/scheduled-work.md +19 -0
- package/skills/commerce/references/storefront-product-page.md +83 -0
- package/skills/commerce/references/webhooks.md +8 -0
- package/src/commerce/admin/README.md +107 -0
- package/src/commerce/admin/bot/Markdown.jsx +138 -0
- package/src/commerce/admin/bot/StoreAdminBot.jsx +249 -0
- package/src/commerce/admin/bot/pipe-tables.js +116 -0
- package/src/commerce/admin/components/AddressForm.jsx +78 -0
- package/src/commerce/admin/components/ConfirmDialog.jsx +52 -0
- package/src/commerce/admin/components/CountrySelect.jsx +81 -0
- package/src/commerce/admin/components/DataTable.jsx +192 -0
- package/src/commerce/admin/components/DateRangePicker.jsx +91 -0
- package/src/commerce/admin/components/EmptyState.jsx +17 -0
- package/src/commerce/admin/components/MediaUploader.jsx +116 -0
- package/src/commerce/admin/components/MetaDataEditor.jsx +45 -0
- package/src/commerce/admin/components/MoneyInput.jsx +50 -0
- package/src/commerce/admin/components/PageHeader.jsx +29 -0
- package/src/commerce/admin/components/RichTextarea.jsx +21 -0
- package/src/commerce/admin/components/SearchSelect.jsx +142 -0
- package/src/commerce/admin/components/StatusBadge.jsx +17 -0
- package/src/commerce/admin/context/BasePathContext.jsx +26 -0
- package/src/commerce/admin/context/SettingsContext.jsx +207 -0
- package/src/commerce/admin/hooks/useAsync.js +46 -0
- package/src/commerce/admin/hooks/useDebounce.js +11 -0
- package/src/commerce/admin/hooks/useMoney.js +52 -0
- package/src/commerce/admin/hooks/usePagedList.js +83 -0
- package/src/commerce/admin/hooks/usePaymentProvider.js +27 -0
- package/src/commerce/admin/hooks/useRealtime.js +129 -0
- package/src/commerce/admin/index.jsx +34 -0
- package/src/commerce/admin/layout/AccessDenied.jsx +54 -0
- package/src/commerce/admin/layout/AdminLayout.jsx +33 -0
- package/src/commerce/admin/layout/AuthGuard.jsx +84 -0
- package/src/commerce/admin/layout/Sidebar.jsx +130 -0
- package/src/commerce/admin/layout/Topbar.jsx +94 -0
- package/src/commerce/admin/lib/api.js +55 -0
- package/src/commerce/admin/lib/constants.js +157 -0
- package/src/commerce/admin/lib/format.js +27 -0
- package/src/commerce/admin/lib/geo-data.js +125 -0
- package/src/commerce/admin/lib/order-utils.js +147 -0
- package/src/commerce/admin/lib/paths.js +35 -0
- package/src/commerce/admin/lib/product-utils.js +55 -0
- package/src/commerce/admin/pages/Dashboard.jsx +245 -0
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +565 -0
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +172 -0
- package/src/commerce/admin/pages/customers/CustomerEditor.jsx +318 -0
- package/src/commerce/admin/pages/customers/CustomersList.jsx +169 -0
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +952 -0
- package/src/commerce/admin/pages/orders/OrdersList.jsx +227 -0
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +149 -0
- package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +119 -0
- package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +208 -0
- package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +123 -0
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +199 -0
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +239 -0
- package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +52 -0
- package/src/commerce/admin/pages/products/AttributeTerms.jsx +180 -0
- package/src/commerce/admin/pages/products/Attributes.jsx +183 -0
- package/src/commerce/admin/pages/products/Categories.jsx +236 -0
- package/src/commerce/admin/pages/products/ProductEditor.jsx +267 -0
- package/src/commerce/admin/pages/products/ProductsList.jsx +391 -0
- package/src/commerce/admin/pages/products/Reviews.jsx +255 -0
- package/src/commerce/admin/pages/products/Tags.jsx +150 -0
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +132 -0
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +101 -0
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +243 -0
- package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +48 -0
- package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +208 -0
- package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +91 -0
- package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +41 -0
- package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +103 -0
- package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +93 -0
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +102 -0
- package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +86 -0
- package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +377 -0
- package/src/commerce/admin/pages/reports/Reports.jsx +416 -0
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +240 -0
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +232 -0
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +146 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +260 -0
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +118 -0
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +53 -0
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +304 -0
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +514 -0
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +231 -0
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +281 -0
- package/src/commerce/admin/pages/settings/useGroupForm.jsx +76 -0
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +296 -0
- package/src/commerce/admin/pages/status/Webhooks.jsx +53 -0
- package/src/commerce/admin/routes.jsx +151 -0
- package/src/commerce/utils/index.js +19 -0
- package/src/commerce/utils/shipping-promos.js +99 -0
- package/src/commerce/utils/variants.js +411 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commerce/storefront-catalog — public catalog browsing API.
|
|
3
|
+
*
|
|
4
|
+
* Actions: list-products | get-product | list-categories | list-tags |
|
|
5
|
+
* list-attributes | get-store-info | submit-review
|
|
6
|
+
*
|
|
7
|
+
* Visibility rules: only status=publish products; browse context hides
|
|
8
|
+
* catalog_visibility hidden/search; search context hides hidden/catalog;
|
|
9
|
+
* inventory.hide_out_of_stock removes out-of-stock products entirely.
|
|
10
|
+
*/
|
|
11
|
+
import { createClientFromRequest } from "npm:@base44/sdk";
|
|
12
|
+
import { getCallerUser, HttpError, requireUser } from "../../../shared/commerce/auth.ts";
|
|
13
|
+
import { getSettings, getSetting, storefrontSafeSettings } from "../../../shared/commerce/settings.ts";
|
|
14
|
+
import { isOnlineGateway, onlinePaymentStatus } from "../../../shared/commerce/payments.ts";
|
|
15
|
+
import { recalcProductRating } from "../../../shared/commerce/reviews.ts";
|
|
16
|
+
import { scanAll } from "../../../shared/commerce/scan.ts";
|
|
17
|
+
import { COUNTRIES } from "../../../shared/commerce/data/countries.ts";
|
|
18
|
+
import { CURRENCIES } from "../../../shared/commerce/data/currencies.ts";
|
|
19
|
+
|
|
20
|
+
function ok(data: unknown, status = 200): Response {
|
|
21
|
+
return Response.json({ success: true, data }, { status });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fail(e: unknown): Response {
|
|
25
|
+
if (e instanceof HttpError) {
|
|
26
|
+
return Response.json({ success: false, error: e.message, code: e.code ?? "error" }, { status: e.status });
|
|
27
|
+
}
|
|
28
|
+
console.error("commerce/storefront-catalog error:", e);
|
|
29
|
+
return Response.json(
|
|
30
|
+
{ success: false, error: (e as Error)?.message ?? "Internal error", code: "internal_error" },
|
|
31
|
+
{ status: 500 },
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
Deno.serve(async (req: Request) => {
|
|
36
|
+
try {
|
|
37
|
+
const base44 = createClientFromRequest(req);
|
|
38
|
+
const sr = base44.asServiceRole;
|
|
39
|
+
const { action, ...payload } = await req.json().catch(() => ({}));
|
|
40
|
+
if (!action) throw new HttpError(400, "action is required.", "action_required");
|
|
41
|
+
|
|
42
|
+
switch (action) {
|
|
43
|
+
case "list-products":
|
|
44
|
+
return ok(await listProducts(sr, payload));
|
|
45
|
+
case "get-product":
|
|
46
|
+
return ok(await getProduct(sr, payload));
|
|
47
|
+
case "list-categories":
|
|
48
|
+
return ok(await listCategories(sr));
|
|
49
|
+
case "list-tags":
|
|
50
|
+
return ok(await listTags(sr, payload));
|
|
51
|
+
case "list-attributes":
|
|
52
|
+
return ok(await listAttributes(sr));
|
|
53
|
+
case "get-store-info":
|
|
54
|
+
return ok(await getStoreInfo(sr));
|
|
55
|
+
case "submit-review":
|
|
56
|
+
return ok(await submitReview(sr, payload, await getCallerUser(base44)));
|
|
57
|
+
default:
|
|
58
|
+
throw new HttpError(400, `Unknown action: ${action}`, "unknown_action");
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return fail(e);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ── storefront serialization ─────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fields that must never reach a storefront client on a product/variation.
|
|
69
|
+
* `downloads[].file_url` is the paywalled file itself — access to it is gated by
|
|
70
|
+
* commerce.DownloadPermission (limits, expiry, signed URLs) via
|
|
71
|
+
* commerce/storefront-account `get-download`, so it can't ride along in the
|
|
72
|
+
* catalog. `purchase_note` is a post-purchase message, not pre-sale content.
|
|
73
|
+
* The `downloadable` flag is kept so a shopfront can still badge the product.
|
|
74
|
+
*/
|
|
75
|
+
function publicProduct<T extends Record<string, any>>(product: T): T {
|
|
76
|
+
if (!product || typeof product !== "object") return product;
|
|
77
|
+
const {
|
|
78
|
+
downloads: _downloads,
|
|
79
|
+
download_limit: _dlLimit,
|
|
80
|
+
download_expiry: _dlExpiry,
|
|
81
|
+
purchase_note: _note,
|
|
82
|
+
...safe
|
|
83
|
+
} = product as Record<string, any>;
|
|
84
|
+
return safe as T;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── list-products ────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
const SORTS: Record<string, (a: any, b: any) => number> = {
|
|
90
|
+
"menu_order": (a, b) => (a.menu_order ?? 0) - (b.menu_order ?? 0) || String(a.name).localeCompare(String(b.name)),
|
|
91
|
+
"price": (a, b) => (a.price ?? 0) - (b.price ?? 0),
|
|
92
|
+
"-price": (a, b) => (b.price ?? 0) - (a.price ?? 0),
|
|
93
|
+
"-created_date": (a, b) => String(b.created_date ?? "").localeCompare(String(a.created_date ?? "")),
|
|
94
|
+
"popularity": (a, b) => (b.total_sales ?? 0) - (a.total_sales ?? 0),
|
|
95
|
+
"rating": (a, b) => (b.average_rating ?? 0) - (a.average_rating ?? 0),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
async function listProducts(sr: any, p: any): Promise<any> {
|
|
99
|
+
const settings = await getSettings(sr, "inventory");
|
|
100
|
+
const hideOutOfStock = !!getSetting(settings, "inventory", "hide_out_of_stock", false);
|
|
101
|
+
const page = Math.max(1, Math.floor(Number(p.page) || 1));
|
|
102
|
+
const perPage = Math.min(100, Math.max(1, Math.floor(Number(p.per_page) || 12)));
|
|
103
|
+
const search = String(p.search ?? "").toLowerCase().trim();
|
|
104
|
+
const isSearchContext = !!search;
|
|
105
|
+
|
|
106
|
+
let products = await scanAll(sr.entities["commerce.Product"], { status: "publish" }, "-created_date", 5000);
|
|
107
|
+
|
|
108
|
+
// visibility per context
|
|
109
|
+
products = products.filter((prod: any) => {
|
|
110
|
+
const vis = prod.catalog_visibility ?? "visible";
|
|
111
|
+
if (vis === "hidden") return false;
|
|
112
|
+
if (isSearchContext && vis === "catalog") return false;
|
|
113
|
+
if (!isSearchContext && vis === "search") return false;
|
|
114
|
+
return true;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (hideOutOfStock || p.in_stock_only) {
|
|
118
|
+
products = products.filter((prod: any) => prod.stock_status !== "outofstock");
|
|
119
|
+
}
|
|
120
|
+
if (search) {
|
|
121
|
+
products = products.filter((prod: any) =>
|
|
122
|
+
[prod.name, prod.sku, prod.description, prod.short_description]
|
|
123
|
+
.some((f: any) => String(f ?? "").toLowerCase().includes(search))
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (p.category_id) {
|
|
127
|
+
const catIds = await categoryWithDescendants(sr, String(p.category_id));
|
|
128
|
+
products = products.filter((prod: any) => (prod.category_ids || []).some((c: string) => catIds.has(c)));
|
|
129
|
+
}
|
|
130
|
+
if (p.tag_id) {
|
|
131
|
+
products = products.filter((prod: any) => (prod.tag_ids || []).includes(String(p.tag_id)));
|
|
132
|
+
}
|
|
133
|
+
if (p.attribute_id && p.attribute_term) {
|
|
134
|
+
products = products.filter((prod: any) =>
|
|
135
|
+
(prod.attributes || []).some((a: any) =>
|
|
136
|
+
(a.attribute_id === p.attribute_id || a.name === p.attribute_id) &&
|
|
137
|
+
(a.options || []).includes(p.attribute_term)
|
|
138
|
+
)
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (p.min_price != null) products = products.filter((prod: any) => (prod.price ?? 0) >= Number(p.min_price));
|
|
142
|
+
if (p.max_price != null) products = products.filter((prod: any) => (prod.price ?? 0) <= Number(p.max_price));
|
|
143
|
+
if (p.featured != null) products = products.filter((prod: any) => !!prod.featured === !!p.featured);
|
|
144
|
+
if (p.on_sale != null) products = products.filter((prod: any) => !!prod.on_sale === !!p.on_sale);
|
|
145
|
+
|
|
146
|
+
const sortKey = String(p.sort ?? "menu_order");
|
|
147
|
+
products.sort(SORTS[sortKey] ?? SORTS["menu_order"]);
|
|
148
|
+
|
|
149
|
+
const start = (page - 1) * perPage;
|
|
150
|
+
const pageItems = await withTags(sr, products.slice(start, start + perPage).map(publicProduct));
|
|
151
|
+
return {
|
|
152
|
+
products: pageItems,
|
|
153
|
+
page,
|
|
154
|
+
per_page: perPage,
|
|
155
|
+
has_next: products.length > start + perPage,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Attach resolved `tags` to listing rows. A row otherwise carries only tag_ids,
|
|
161
|
+
* which is why cards end up with no tags at all — the shape a card needs has to
|
|
162
|
+
* be in the row it renders. One tag read per request, not per row; kept to
|
|
163
|
+
* {id, name, slug} so a listing payload stays small (use list-tags for
|
|
164
|
+
* descriptions and counts).
|
|
165
|
+
*/
|
|
166
|
+
async function withTags(sr: any, rows: any[]): Promise<any[]> {
|
|
167
|
+
if (!rows.some((r) => (r.tag_ids ?? []).length)) return rows;
|
|
168
|
+
const all = await scanAll(sr.entities["commerce.ProductTag"], {}, "name", 2000);
|
|
169
|
+
const byId = new Map(all.map((t: any) => [t.id, { id: t.id, name: t.name, slug: t.slug }]));
|
|
170
|
+
return rows.map((r) => ({
|
|
171
|
+
...r,
|
|
172
|
+
tags: (r.tag_ids ?? []).map((id: string) => byId.get(id)).filter(Boolean),
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function categoryWithDescendants(sr: any, rootId: string): Promise<Set<string>> {
|
|
177
|
+
const all = await scanAll(sr.entities["commerce.ProductCategory"], {}, undefined, 2000);
|
|
178
|
+
const byParent = new Map<string, any[]>();
|
|
179
|
+
for (const c of all) {
|
|
180
|
+
const key = c.parent_id || "";
|
|
181
|
+
byParent.set(key, [...(byParent.get(key) ?? []), c]);
|
|
182
|
+
}
|
|
183
|
+
const ids = new Set<string>([rootId]);
|
|
184
|
+
const queue = [rootId];
|
|
185
|
+
while (queue.length) {
|
|
186
|
+
const cur = queue.shift()!;
|
|
187
|
+
for (const child of byParent.get(cur) ?? []) {
|
|
188
|
+
if (!ids.has(child.id)) { ids.add(child.id); queue.push(child.id); }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return ids;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── get-product ──────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
async function getProduct(sr: any, p: any): Promise<any> {
|
|
197
|
+
let product: any = null;
|
|
198
|
+
if (p.id) {
|
|
199
|
+
try { product = await sr.entities["commerce.Product"].get(String(p.id)); } catch { product = null; }
|
|
200
|
+
} else if (p.slug) {
|
|
201
|
+
product = (await sr.entities["commerce.Product"].filter({ slug: String(p.slug) }, undefined, 1))?.[0] ?? null;
|
|
202
|
+
}
|
|
203
|
+
if (!product || product.status !== "publish" || product.catalog_visibility === "hidden") {
|
|
204
|
+
throw new HttpError(404, "Product not found.", "not_found");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const [variations, categories, tags] = await Promise.all([
|
|
208
|
+
product.type === "variable"
|
|
209
|
+
? sr.entities["commerce.ProductVariation"].filter({ product_id: product.id }, "menu_order", 500)
|
|
210
|
+
: Promise.resolve([]),
|
|
211
|
+
resolveMany(sr.entities["commerce.ProductCategory"], product.category_ids),
|
|
212
|
+
resolveMany(sr.entities["commerce.ProductTag"], product.tag_ids),
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
const purchasableVariations = (variations ?? []).filter(
|
|
216
|
+
(v: any) => !v.status || v.status === "publish",
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const reviewsPage = Math.max(1, Math.floor(Number(p.reviews_page) || 1));
|
|
220
|
+
const reviewsPerPage = Math.min(50, Math.max(1, Math.floor(Number(p.reviews_per_page) || 10)));
|
|
221
|
+
const reviews = (await sr.entities["commerce.ProductReview"].filter(
|
|
222
|
+
{ product_id: product.id, status: "approved" },
|
|
223
|
+
"-created_date",
|
|
224
|
+
reviewsPerPage + 1,
|
|
225
|
+
(reviewsPage - 1) * reviewsPerPage,
|
|
226
|
+
)) ?? [];
|
|
227
|
+
|
|
228
|
+
const [upsells, crossSells, grouped] = await Promise.all([
|
|
229
|
+
productSummaries(sr, product.upsell_ids),
|
|
230
|
+
productSummaries(sr, product.cross_sell_ids),
|
|
231
|
+
productSummaries(sr, product.grouped_products),
|
|
232
|
+
]);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
product: publicProduct(product),
|
|
236
|
+
variations: purchasableVariations.map(publicProduct),
|
|
237
|
+
categories,
|
|
238
|
+
tags,
|
|
239
|
+
reviews: {
|
|
240
|
+
items: reviews.slice(0, reviewsPerPage).map(publicReview),
|
|
241
|
+
page: reviewsPage,
|
|
242
|
+
per_page: reviewsPerPage,
|
|
243
|
+
has_next: reviews.length > reviewsPerPage,
|
|
244
|
+
average_rating: product.average_rating ?? 0,
|
|
245
|
+
rating_count: product.rating_count ?? 0,
|
|
246
|
+
},
|
|
247
|
+
upsells,
|
|
248
|
+
cross_sells: crossSells,
|
|
249
|
+
grouped_products: grouped,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function resolveMany(entityApi: any, ids: string[] | undefined): Promise<any[]> {
|
|
254
|
+
const out: any[] = [];
|
|
255
|
+
for (const id of ids ?? []) {
|
|
256
|
+
try {
|
|
257
|
+
const rec = await entityApi.get(id);
|
|
258
|
+
if (rec) out.push(rec);
|
|
259
|
+
} catch { /* skip vanished */ }
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function productSummaries(sr: any, ids: string[] | undefined): Promise<any[]> {
|
|
265
|
+
const out: any[] = [];
|
|
266
|
+
for (const id of (ids ?? []).slice(0, 20)) {
|
|
267
|
+
try {
|
|
268
|
+
const prod = await sr.entities["commerce.Product"].get(id);
|
|
269
|
+
if (prod && prod.status === "publish") {
|
|
270
|
+
out.push({
|
|
271
|
+
id: prod.id,
|
|
272
|
+
name: prod.name,
|
|
273
|
+
slug: prod.slug,
|
|
274
|
+
price: prod.price ?? null,
|
|
275
|
+
on_sale: !!prod.on_sale,
|
|
276
|
+
image: prod.images?.[0]?.src ?? "",
|
|
277
|
+
stock_status: prod.stock_status ?? "instock",
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
} catch { /* skip */ }
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function publicReview(r: any): any {
|
|
286
|
+
return {
|
|
287
|
+
id: r.id,
|
|
288
|
+
reviewer: r.reviewer,
|
|
289
|
+
review: r.review,
|
|
290
|
+
rating: r.rating,
|
|
291
|
+
verified: !!r.verified,
|
|
292
|
+
created_date: r.created_date,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── taxonomies ───────────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
async function listCategories(sr: any): Promise<any> {
|
|
299
|
+
const all = await scanAll(sr.entities["commerce.ProductCategory"], {}, "menu_order", 2000);
|
|
300
|
+
const byId = new Map<string, any>();
|
|
301
|
+
for (const c of all) byId.set(c.id, { ...c, children: [] });
|
|
302
|
+
const roots: any[] = [];
|
|
303
|
+
for (const node of byId.values()) {
|
|
304
|
+
if (node.parent_id && byId.has(node.parent_id)) {
|
|
305
|
+
byId.get(node.parent_id).children.push(node);
|
|
306
|
+
} else {
|
|
307
|
+
roots.push(node);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const sortTree = (nodes: any[]) => {
|
|
311
|
+
nodes.sort((a, b) => (a.menu_order ?? 0) - (b.menu_order ?? 0) || String(a.name).localeCompare(String(b.name)));
|
|
312
|
+
nodes.forEach((n) => sortTree(n.children));
|
|
313
|
+
};
|
|
314
|
+
sortTree(roots);
|
|
315
|
+
return { categories: roots };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Tags for a tag cloud / "shop by tag" nav. Entities are admin-only, so a
|
|
320
|
+
* storefront has no other way to enumerate them — and list-products `tag_id` is
|
|
321
|
+
* useless without the ids.
|
|
322
|
+
*
|
|
323
|
+
* `count` is tallied from the products this API would actually list, NOT from
|
|
324
|
+
* ProductTag.count: that field counts drafts and hidden products too, and drifts
|
|
325
|
+
* until admin-tools recount-terms runs — either way a nav built on it can show
|
|
326
|
+
* "Gift (7)" that lists nothing, or hide a tag that has stock.
|
|
327
|
+
* `with_products_only` (default true) then drops the empty ones.
|
|
328
|
+
*/
|
|
329
|
+
async function listTags(sr: any, p: any): Promise<any> {
|
|
330
|
+
const settings = await getSettings(sr, "inventory");
|
|
331
|
+
const hideOutOfStock = !!getSetting(settings, "inventory", "hide_out_of_stock", false);
|
|
332
|
+
|
|
333
|
+
const products = (await scanAll(sr.entities["commerce.Product"], { status: "publish" }, undefined, 5000))
|
|
334
|
+
.filter((prod: any) => {
|
|
335
|
+
const vis = prod.catalog_visibility ?? "visible";
|
|
336
|
+
if (vis === "hidden" || vis === "search") return false; // browse context
|
|
337
|
+
return !(hideOutOfStock && prod.stock_status === "outofstock");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const counts = new Map<string, number>();
|
|
341
|
+
for (const prod of products) {
|
|
342
|
+
for (const id of prod.tag_ids ?? []) counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const all = await scanAll(sr.entities["commerce.ProductTag"], {}, "name", 2000);
|
|
346
|
+
const tags = all
|
|
347
|
+
.map((t: any) => ({
|
|
348
|
+
id: t.id,
|
|
349
|
+
name: t.name,
|
|
350
|
+
slug: t.slug,
|
|
351
|
+
description: t.description ?? "",
|
|
352
|
+
count: counts.get(t.id) ?? 0,
|
|
353
|
+
}))
|
|
354
|
+
.filter((t: any) => p?.with_products_only === false || t.count > 0);
|
|
355
|
+
return { tags };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function listAttributes(sr: any): Promise<any> {
|
|
359
|
+
const attributes = await scanAll(sr.entities["commerce.ProductAttribute"], {}, undefined, 500);
|
|
360
|
+
const out: any[] = [];
|
|
361
|
+
for (const attr of attributes) {
|
|
362
|
+
const terms = (await sr.entities["commerce.ProductAttributeTerm"].filter({ attribute_id: attr.id }, "menu_order", 500)) ?? [];
|
|
363
|
+
out.push({ ...attr, terms });
|
|
364
|
+
}
|
|
365
|
+
return { attributes: out };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── store info ───────────────────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
async function getStoreInfo(sr: any): Promise<any> {
|
|
371
|
+
const settings = await getSettings(sr);
|
|
372
|
+
const gateways = (await sr.entities["commerce.PaymentGateway"].filter({ enabled: true }, "order", 50)) ?? [];
|
|
373
|
+
// An online gateway is only offerable while its provider is actually
|
|
374
|
+
// connected — otherwise the customer would pick "Card" and pay nothing. This
|
|
375
|
+
// is a live check, never a stored flag.
|
|
376
|
+
const online = await onlinePaymentStatus(sr);
|
|
377
|
+
const offerable = gateways.filter((g: any) => !isOnlineGateway(g.slug) || online.connected);
|
|
378
|
+
return {
|
|
379
|
+
settings: storefrontSafeSettings(settings),
|
|
380
|
+
payment_gateways: offerable.map((g: any) => ({
|
|
381
|
+
slug: g.slug,
|
|
382
|
+
title: g.title ?? g.slug,
|
|
383
|
+
description: g.description ?? "",
|
|
384
|
+
online: isOnlineGateway(g.slug),
|
|
385
|
+
})),
|
|
386
|
+
countries: COUNTRIES,
|
|
387
|
+
currencies: CURRENCIES,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── reviews ──────────────────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
/** Signed-in only: the reviewer's email is the session's, never the payload's. */
|
|
394
|
+
async function submitReview(sr: any, p: any, user: any): Promise<any> {
|
|
395
|
+
requireUser(user);
|
|
396
|
+
const settings = await getSettings(sr, "products");
|
|
397
|
+
if (!getSetting(settings, "products", "enable_reviews", true)) {
|
|
398
|
+
throw new HttpError(403, "Reviews are disabled for this store.", "reviews_disabled");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const productId = String(p.product_id || "");
|
|
402
|
+
let product: any = null;
|
|
403
|
+
try { product = productId ? await sr.entities["commerce.Product"].get(productId) : null; } catch { product = null; }
|
|
404
|
+
if (!product || product.status !== "publish") {
|
|
405
|
+
throw new HttpError(404, "Product not found.", "not_found");
|
|
406
|
+
}
|
|
407
|
+
if (product.reviews_allowed === false) {
|
|
408
|
+
throw new HttpError(403, "Reviews are disabled for this product.", "reviews_disabled");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const reviewerEmail = String(user.email ?? "").trim().toLowerCase();
|
|
412
|
+
if (!reviewerEmail) {
|
|
413
|
+
throw new HttpError(403, "Your account has no email address to review under.", "forbidden");
|
|
414
|
+
}
|
|
415
|
+
const reviewer = String(p.reviewer ?? user.full_name ?? "").trim();
|
|
416
|
+
const review = String(p.review ?? "").trim();
|
|
417
|
+
const rating = p.rating == null ? null : Math.floor(Number(p.rating));
|
|
418
|
+
|
|
419
|
+
if (!reviewer || !review) {
|
|
420
|
+
throw new HttpError(400, "reviewer and review are required.", "review_incomplete");
|
|
421
|
+
}
|
|
422
|
+
if (getSetting(settings, "products", "review_rating_required", true) && (rating == null || rating < 1)) {
|
|
423
|
+
throw new HttpError(400, "A star rating is required.", "rating_required");
|
|
424
|
+
}
|
|
425
|
+
if (rating != null && (rating < 0 || rating > 5)) {
|
|
426
|
+
throw new HttpError(400, "Rating must be between 0 and 5.", "invalid_rating");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const verified = await hasPurchased(sr, reviewerEmail, product.id);
|
|
430
|
+
if (getSetting(settings, "products", "only_verified_reviews", false) && !verified) {
|
|
431
|
+
throw new HttpError(403, "Only customers who purchased this product can review it.", "verified_only");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const autoApprove = !!getSetting(settings, "products", "auto_approve_reviews", false);
|
|
435
|
+
const created = await sr.entities["commerce.ProductReview"].create({
|
|
436
|
+
product_id: product.id,
|
|
437
|
+
status: autoApprove ? "approved" : "hold",
|
|
438
|
+
reviewer,
|
|
439
|
+
reviewer_email: reviewerEmail,
|
|
440
|
+
review,
|
|
441
|
+
rating: rating ?? 0,
|
|
442
|
+
verified,
|
|
443
|
+
});
|
|
444
|
+
if (autoApprove) {
|
|
445
|
+
await recalcProductRating(sr, product.id);
|
|
446
|
+
}
|
|
447
|
+
return { review_id: created.id, status: created.status, verified };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Did this email complete (or at least pay for) an order containing the product? */
|
|
451
|
+
async function hasPurchased(sr: any, email: string, productId: string): Promise<boolean> {
|
|
452
|
+
const customer = (await sr.entities["commerce.Customer"].filter({ email }, undefined, 1))?.[0];
|
|
453
|
+
if (!customer) return false;
|
|
454
|
+
const orders = await scanAll(sr.entities["commerce.Order"], { customer_id: customer.id }, "-created_date", 1000);
|
|
455
|
+
return orders.some((o: any) =>
|
|
456
|
+
["completed", "processing"].includes(o.status) &&
|
|
457
|
+
(o.line_items || []).some((l: any) => l.product_id === productId)
|
|
458
|
+
);
|
|
459
|
+
}
|