@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,46 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run an async function on mount / when deps change.
|
|
5
|
+
* Returns { data, loading, error, refetch, refetchQuiet, setData }.
|
|
6
|
+
*
|
|
7
|
+
* `refetchQuiet` re-runs without flipping `loading`, so a background refresh
|
|
8
|
+
* (e.g. a realtime update) updates the numbers in place instead of flashing
|
|
9
|
+
* skeletons over content the user is already reading.
|
|
10
|
+
*/
|
|
11
|
+
export default function useAsync(fn, deps = []) {
|
|
12
|
+
const [state, setState] = useState({ data: null, loading: true, error: null });
|
|
13
|
+
const fnRef = useRef(fn);
|
|
14
|
+
fnRef.current = fn;
|
|
15
|
+
|
|
16
|
+
const run = useCallback(async ({ quiet = false } = {}) => {
|
|
17
|
+
if (!quiet) setState((s) => ({ ...s, loading: true, error: null }));
|
|
18
|
+
try {
|
|
19
|
+
const data = await fnRef.current();
|
|
20
|
+
setState({ data, loading: false, error: null });
|
|
21
|
+
return data;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
setState((s) => ({ ...s, loading: false, error }));
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
run();
|
|
30
|
+
// eslint-disable-next-line -- deps intentionally partial
|
|
31
|
+
}, deps);
|
|
32
|
+
|
|
33
|
+
// Note: `refetch` deliberately takes no arguments, so it stays safe to pass
|
|
34
|
+
// straight to an onClick handler without the event being read as options.
|
|
35
|
+
const refetch = useCallback(() => run(), [run]);
|
|
36
|
+
const refetchQuiet = useCallback(() => run({ quiet: true }), [run]);
|
|
37
|
+
|
|
38
|
+
const setData = useCallback((updater) => {
|
|
39
|
+
setState((s) => ({
|
|
40
|
+
...s,
|
|
41
|
+
data: typeof updater === "function" ? updater(s.data) : updater,
|
|
42
|
+
}));
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
return { ...state, refetch, refetchQuiet, setData };
|
|
46
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/** Debounce a changing value (default 300ms). */
|
|
4
|
+
export default function useDebounce(value, delay = 300) {
|
|
5
|
+
const [debounced, setDebounced] = useState(value);
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const t = setTimeout(() => setDebounced(value), delay);
|
|
8
|
+
return () => clearTimeout(t);
|
|
9
|
+
}, [value, delay]);
|
|
10
|
+
return debounced;
|
|
11
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import { useSettings } from "../context/SettingsContext";
|
|
3
|
+
import { CURRENCIES } from "../lib/constants";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Currency formatter driven by the `general` settings group.
|
|
7
|
+
* Returns { format(n), symbol, code, decimals }.
|
|
8
|
+
*/
|
|
9
|
+
export default function useMoney() {
|
|
10
|
+
const settings = useSettings();
|
|
11
|
+
|
|
12
|
+
const cfg = useMemo(() => {
|
|
13
|
+
const get = settings?.get || (() => undefined);
|
|
14
|
+
const code = get("general", "currency", "USD");
|
|
15
|
+
const currency = CURRENCIES.find((cc) => cc.code === code);
|
|
16
|
+
return {
|
|
17
|
+
code,
|
|
18
|
+
symbol: currency?.symbol || code,
|
|
19
|
+
position: get("general", "currency_position", "left"),
|
|
20
|
+
thousandSep: get("general", "thousand_sep", ","),
|
|
21
|
+
decimalSep: get("general", "decimal_sep", "."),
|
|
22
|
+
decimals: get("general", "num_decimals", currency?.decimals ?? 2),
|
|
23
|
+
};
|
|
24
|
+
}, [settings]);
|
|
25
|
+
|
|
26
|
+
const format = useCallback(
|
|
27
|
+
(n) => {
|
|
28
|
+
const num = Number(n);
|
|
29
|
+
const val = isNaN(num) ? 0 : num;
|
|
30
|
+
const negative = val < 0;
|
|
31
|
+
const fixed = Math.abs(val).toFixed(cfg.decimals);
|
|
32
|
+
const [intPart, decPart] = fixed.split(".");
|
|
33
|
+
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, cfg.thousandSep);
|
|
34
|
+
const amount = decPart ? grouped + cfg.decimalSep + decPart : grouped;
|
|
35
|
+
const sign = negative ? "-" : "";
|
|
36
|
+
switch (cfg.position) {
|
|
37
|
+
case "right":
|
|
38
|
+
return `${sign}${amount}${cfg.symbol}`;
|
|
39
|
+
case "left_space":
|
|
40
|
+
return `${sign}${cfg.symbol} ${amount}`;
|
|
41
|
+
case "right_space":
|
|
42
|
+
return `${sign}${amount} ${cfg.symbol}`;
|
|
43
|
+
case "left":
|
|
44
|
+
default:
|
|
45
|
+
return `${sign}${cfg.symbol}${amount}`;
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
[cfg]
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return { format, symbol: cfg.symbol, code: cfg.code, decimals: cfg.decimals };
|
|
52
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Paged listing over a fetcher(limit, skip, sortString) that returns an array.
|
|
5
|
+
* Uses the limit+1 probe to detect a next page (the SDK has no total count).
|
|
6
|
+
*
|
|
7
|
+
* Returns:
|
|
8
|
+
* { rows, loading, error, page (1-based), hasNext, next, prev,
|
|
9
|
+
* sort, setSort, refetch, refetchQuiet }
|
|
10
|
+
*
|
|
11
|
+
* `refetchQuiet` reloads the current page without flipping `loading` and keeps
|
|
12
|
+
* the existing rows if the request fails — so a background refresh (e.g. a
|
|
13
|
+
* realtime update) never blanks or flickers a table the user is reading.
|
|
14
|
+
*
|
|
15
|
+
* `deps`: when any dep changes (e.g. a filter), the list resets to page 1.
|
|
16
|
+
*/
|
|
17
|
+
export default function usePagedList(fetcher, { pageSize = 20, initialSort = "-created_date", deps = [] } = {}) {
|
|
18
|
+
const [page, setPage] = useState(0); // 0-based internally
|
|
19
|
+
const [sort, setSortState] = useState(initialSort);
|
|
20
|
+
const [state, setState] = useState({ rows: [], hasNext: false, loading: true, error: null });
|
|
21
|
+
|
|
22
|
+
const fetcherRef = useRef(fetcher);
|
|
23
|
+
fetcherRef.current = fetcher;
|
|
24
|
+
|
|
25
|
+
const load = useCallback(
|
|
26
|
+
async (p, s, { quiet = false } = {}) => {
|
|
27
|
+
if (!quiet) setState((st) => ({ ...st, loading: true }));
|
|
28
|
+
try {
|
|
29
|
+
const rows = (await fetcherRef.current(pageSize + 1, p * pageSize, s)) || [];
|
|
30
|
+
setState({
|
|
31
|
+
rows: rows.slice(0, pageSize),
|
|
32
|
+
hasNext: rows.length > pageSize,
|
|
33
|
+
loading: false,
|
|
34
|
+
error: null,
|
|
35
|
+
});
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// A failed background refresh must not destroy what is on screen.
|
|
38
|
+
setState((st) =>
|
|
39
|
+
quiet
|
|
40
|
+
? { ...st, loading: false, error }
|
|
41
|
+
: { rows: [], hasNext: false, loading: false, error }
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
[pageSize]
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Reset to first page when external deps (filters/search) change.
|
|
49
|
+
const depsKey = JSON.stringify(deps);
|
|
50
|
+
const mounted = useRef(false);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!mounted.current) {
|
|
53
|
+
mounted.current = true;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (page !== 0) setPage(0);
|
|
57
|
+
else load(0, sort);
|
|
58
|
+
// eslint-disable-next-line -- deps intentionally partial
|
|
59
|
+
}, [depsKey]);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
load(page, sort);
|
|
63
|
+
}, [page, sort, load]);
|
|
64
|
+
|
|
65
|
+
const setSort = useCallback((s) => {
|
|
66
|
+
setPage(0);
|
|
67
|
+
setSortState(s);
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
rows: state.rows,
|
|
72
|
+
loading: state.loading,
|
|
73
|
+
error: state.error,
|
|
74
|
+
hasNext: state.hasNext,
|
|
75
|
+
page: page + 1,
|
|
76
|
+
next: () => state.hasNext && setPage((p) => p + 1),
|
|
77
|
+
prev: () => setPage((p) => Math.max(0, p - 1)),
|
|
78
|
+
sort,
|
|
79
|
+
setSort,
|
|
80
|
+
refetch: () => load(page, sort),
|
|
81
|
+
refetchQuiet: () => load(page, sort, { quiet: true }),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import useAsync from "./useAsync";
|
|
2
|
+
import { call } from "../lib/api";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether the store can take an online payment right now.
|
|
6
|
+
*
|
|
7
|
+
* Availability is a **live fact about the connected provider**, never a stored
|
|
8
|
+
* flag, so every payment affordance in the admin derives from this instead of
|
|
9
|
+
* hardcoding a "not wired" state. Which provider is wired is a backend concern
|
|
10
|
+
* (`base44/shared/commerce/payments.ts`); the UI only needs `connected`, and
|
|
11
|
+
* shows "No payment provider connected" rather than naming one.
|
|
12
|
+
*
|
|
13
|
+
* @returns {{connected: boolean, gatewaySlug: string, provider: string,
|
|
14
|
+
* providerLabel: string, loading: boolean, refetch: Function}}
|
|
15
|
+
*/
|
|
16
|
+
export default function usePaymentProvider() {
|
|
17
|
+
const status = useAsync(() => call("admin-tools", "payment-connector-status", {}, { silent: true }), []);
|
|
18
|
+
return {
|
|
19
|
+
connected: !!status.data?.connected,
|
|
20
|
+
// "" until the backend answers — no provider name is hardcoded here.
|
|
21
|
+
gatewaySlug: status.data?.gateway_slug ?? "",
|
|
22
|
+
provider: status.data?.provider ?? "",
|
|
23
|
+
providerLabel: status.data?.provider_label ?? "",
|
|
24
|
+
loading: status.loading,
|
|
25
|
+
refetch: status.refetch,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { base44 } from "../lib/api";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Keep an admin view live: run `onChange` whenever the given entities change on
|
|
6
|
+
* the server, so new orders/refunds/stock appear without a manual refresh.
|
|
7
|
+
*
|
|
8
|
+
* Two mechanisms, deliberately belt-and-braces:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Realtime push (primary).** `base44.entities[name].subscribe(cb)` joins a
|
|
11
|
+
* socket room and receives create/update/delete events. The SDK sends the
|
|
12
|
+
* session token on the socket handshake and the backend verifies room
|
|
13
|
+
* access, so this only delivers for a signed-in admin — which is exactly who
|
|
14
|
+
* sees these pages.
|
|
15
|
+
* 2. **Polling fallback (safety net).** A slow interval that **switches itself
|
|
16
|
+
* off permanently the first time a realtime event arrives**. It exists
|
|
17
|
+
* because push cannot be relied on for every write path: most commerce
|
|
18
|
+
* writes happen inside backend functions under the service role, and if
|
|
19
|
+
* those do not emit socket events, push alone would never fire. When push
|
|
20
|
+
* works you pay one wasted poll at most; when it doesn't, the page is still
|
|
21
|
+
* reactive.
|
|
22
|
+
*
|
|
23
|
+
* Other deliberate behaviours:
|
|
24
|
+
* - **Coalesces bursts** — one order transition can touch several entities, so
|
|
25
|
+
* events are debounced into a single refresh.
|
|
26
|
+
* - **Respects a hidden tab** — nothing refreshes in the background; a pending
|
|
27
|
+
* change triggers exactly one refresh when the tab is shown again. Returning
|
|
28
|
+
* to the tab also refreshes, which is the case most users notice.
|
|
29
|
+
* - **Never breaks the page** — a missing `.subscribe` (older SDK) or a throwing
|
|
30
|
+
* subscribe just leaves the fallback doing the work.
|
|
31
|
+
*
|
|
32
|
+
* @param {string|string[]} entities - entity name(s), e.g. "commerce.Order"
|
|
33
|
+
* @param {() => void} onChange - called (debounced) when a refresh is due
|
|
34
|
+
* @param {{enabled?: boolean, debounceMs?: number, fallbackPollMs?: number}} [options]
|
|
35
|
+
* `fallbackPollMs` — poll cadence before push is proven; use a slower value
|
|
36
|
+
* for scan-heavy endpoints (reports/dashboard) than for plain entity queries.
|
|
37
|
+
* Set to 0 to disable the fallback entirely.
|
|
38
|
+
*/
|
|
39
|
+
export default function useRealtime(
|
|
40
|
+
entities,
|
|
41
|
+
onChange,
|
|
42
|
+
{ enabled = true, debounceMs = 400, fallbackPollMs = 30000 } = {},
|
|
43
|
+
) {
|
|
44
|
+
// Kept in a ref so callers can pass an inline closure without resubscribing.
|
|
45
|
+
const onChangeRef = useRef(onChange);
|
|
46
|
+
onChangeRef.current = onChange;
|
|
47
|
+
|
|
48
|
+
const names = Array.isArray(entities) ? entities : [entities];
|
|
49
|
+
const namesKey = names.filter(Boolean).join("|");
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!enabled || !namesKey) return undefined;
|
|
53
|
+
|
|
54
|
+
let debounceTimer = null;
|
|
55
|
+
let pollTimer = null;
|
|
56
|
+
let pendingWhileHidden = false;
|
|
57
|
+
let sawPushEvent = false;
|
|
58
|
+
let disposed = false;
|
|
59
|
+
|
|
60
|
+
const refresh = () => {
|
|
61
|
+
if (disposed) return;
|
|
62
|
+
if (typeof document !== "undefined" && document.hidden) {
|
|
63
|
+
pendingWhileHidden = true;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
onChangeRef.current?.();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const schedule = () => {
|
|
70
|
+
if (disposed) return;
|
|
71
|
+
clearTimeout(debounceTimer);
|
|
72
|
+
debounceTimer = setTimeout(refresh, debounceMs);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// A push event proves realtime works for this deployment — stop polling.
|
|
76
|
+
const onPushEvent = () => {
|
|
77
|
+
if (!sawPushEvent) {
|
|
78
|
+
sawPushEvent = true;
|
|
79
|
+
clearInterval(pollTimer);
|
|
80
|
+
pollTimer = null;
|
|
81
|
+
}
|
|
82
|
+
schedule();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const unsubscribes = [];
|
|
86
|
+
for (const name of namesKey.split("|")) {
|
|
87
|
+
const entity = base44?.entities?.[name];
|
|
88
|
+
if (typeof entity?.subscribe !== "function") continue;
|
|
89
|
+
try {
|
|
90
|
+
const off = entity.subscribe(onPushEvent);
|
|
91
|
+
if (typeof off === "function") unsubscribes.push(off);
|
|
92
|
+
} catch {
|
|
93
|
+
/* fall back to polling */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (fallbackPollMs > 0) {
|
|
98
|
+
pollTimer = setInterval(() => {
|
|
99
|
+
if (disposed || sawPushEvent) return;
|
|
100
|
+
if (typeof document !== "undefined" && document.hidden) return;
|
|
101
|
+
onChangeRef.current?.();
|
|
102
|
+
}, fallbackPollMs);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const onVisibilityChange = () => {
|
|
106
|
+
if (document.hidden) return;
|
|
107
|
+
// Coming back to the tab is the moment stale data is most obvious.
|
|
108
|
+
if (pendingWhileHidden || !sawPushEvent) {
|
|
109
|
+
pendingWhileHidden = false;
|
|
110
|
+
schedule();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
114
|
+
|
|
115
|
+
return () => {
|
|
116
|
+
disposed = true;
|
|
117
|
+
clearTimeout(debounceTimer);
|
|
118
|
+
clearInterval(pollTimer);
|
|
119
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
120
|
+
for (const off of unsubscribes) {
|
|
121
|
+
try {
|
|
122
|
+
off();
|
|
123
|
+
} catch {
|
|
124
|
+
/* ignore */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}, [namesKey, enabled, debounceMs, fallbackPollMs]);
|
|
129
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Toaster } from "sonner";
|
|
3
|
+
import AuthGuard from "./layout/AuthGuard";
|
|
4
|
+
import AdminLayout from "./layout/AdminLayout";
|
|
5
|
+
import AdminRoutes from "./routes";
|
|
6
|
+
import { SettingsProvider } from "./context/SettingsContext";
|
|
7
|
+
import { BasePathProvider } from "./context/BasePathContext";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The store admin application.
|
|
11
|
+
*
|
|
12
|
+
* Mount inside your app's router:
|
|
13
|
+
* <Route path="/admin/*" element={<AdminApp />} />
|
|
14
|
+
*
|
|
15
|
+
* If mounted somewhere other than /admin, pass the prefix:
|
|
16
|
+
* <Route path="/backoffice/*" element={<AdminApp basePath="/backoffice" />} />
|
|
17
|
+
*
|
|
18
|
+
* Requires an authenticated user with role "admin" (enforced by AuthGuard,
|
|
19
|
+
* and independently by entity RLS + requireAdmin() in backend functions).
|
|
20
|
+
*/
|
|
21
|
+
export default function AdminApp({ basePath = "/admin" }) {
|
|
22
|
+
return (
|
|
23
|
+
<BasePathProvider value={basePath}>
|
|
24
|
+
<Toaster richColors position="top-right" />
|
|
25
|
+
<AuthGuard>
|
|
26
|
+
<SettingsProvider>
|
|
27
|
+
<AdminLayout>
|
|
28
|
+
<AdminRoutes />
|
|
29
|
+
</AdminLayout>
|
|
30
|
+
</SettingsProvider>
|
|
31
|
+
</AuthGuard>
|
|
32
|
+
</BasePathProvider>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Lock, ShieldAlert } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Access screens for the admin.
|
|
8
|
+
* variant="unauthenticated" → sign-in prompt (props: onLogin, timedOut)
|
|
9
|
+
* variant="forbidden" → admin-role required (props: email, onLogout)
|
|
10
|
+
*/
|
|
11
|
+
export default function AccessDenied({ variant = "forbidden", email, onLogin, onLogout, timedOut = false }) {
|
|
12
|
+
const unauth = variant === "unauthenticated";
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
|
15
|
+
<Card className="w-full max-w-md text-center">
|
|
16
|
+
<CardHeader>
|
|
17
|
+
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
|
18
|
+
{unauth ? (
|
|
19
|
+
<Lock className="h-6 w-6 text-muted-foreground" />
|
|
20
|
+
) : (
|
|
21
|
+
<ShieldAlert className="h-6 w-6 text-destructive" />
|
|
22
|
+
)}
|
|
23
|
+
</div>
|
|
24
|
+
<CardTitle>{unauth ? "Sign in required" : "Admin access required"}</CardTitle>
|
|
25
|
+
<CardDescription>
|
|
26
|
+
{unauth
|
|
27
|
+
? timedOut
|
|
28
|
+
? "We couldn't confirm who you are. Sign in to continue — if this page is embedded in a preview pane, open it in its own browser tab first, since sign-in can't complete inside a frame."
|
|
29
|
+
: "You must sign in to access the store admin."
|
|
30
|
+
: "Your account does not have the admin role. Ask a store administrator to grant you access."}
|
|
31
|
+
</CardDescription>
|
|
32
|
+
</CardHeader>
|
|
33
|
+
<CardContent className="space-y-3">
|
|
34
|
+
{unauth ? (
|
|
35
|
+
<Button onClick={onLogin} className="w-full">
|
|
36
|
+
Sign in
|
|
37
|
+
</Button>
|
|
38
|
+
) : (
|
|
39
|
+
<>
|
|
40
|
+
{email && (
|
|
41
|
+
<p className="text-sm text-muted-foreground">
|
|
42
|
+
Signed in as <span className="font-medium">{email}</span>
|
|
43
|
+
</p>
|
|
44
|
+
)}
|
|
45
|
+
<Button variant="outline" onClick={onLogout} className="w-full">
|
|
46
|
+
Sign out
|
|
47
|
+
</Button>
|
|
48
|
+
</>
|
|
49
|
+
)}
|
|
50
|
+
</CardContent>
|
|
51
|
+
</Card>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
|
3
|
+
import Sidebar from "./Sidebar";
|
|
4
|
+
import Topbar from "./Topbar";
|
|
5
|
+
import StoreAdminBot from "../bot/StoreAdminBot";
|
|
6
|
+
|
|
7
|
+
/** Shell: fixed sidebar (desktop) / sheet (mobile), topbar, scrollable main. */
|
|
8
|
+
export default function AdminLayout({ children }) {
|
|
9
|
+
const [mobileOpen, setMobileOpen] = useState(false);
|
|
10
|
+
const [botOpen, setBotOpen] = useState(false);
|
|
11
|
+
const openBot = () => {
|
|
12
|
+
setMobileOpen(false);
|
|
13
|
+
setBotOpen(true);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="min-h-screen bg-muted/30">
|
|
18
|
+
<aside className="fixed inset-y-0 left-0 z-20 hidden w-60 border-r bg-background lg:block">
|
|
19
|
+
<Sidebar onOpenBot={openBot} />
|
|
20
|
+
</aside>
|
|
21
|
+
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
|
22
|
+
<SheetContent side="left" className="w-60 p-0">
|
|
23
|
+
<Sidebar onNavigate={() => setMobileOpen(false)} onOpenBot={openBot} />
|
|
24
|
+
</SheetContent>
|
|
25
|
+
</Sheet>
|
|
26
|
+
<StoreAdminBot open={botOpen} onOpenChange={setBotOpen} />
|
|
27
|
+
<div className="flex min-h-screen flex-col lg:pl-60">
|
|
28
|
+
<Topbar onMenuClick={() => setMobileOpen(true)} />
|
|
29
|
+
<main className="flex-1 p-4 lg:p-6">{children}</main>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useState } from "react";
|
|
2
|
+
import { Loader2 } from "lucide-react";
|
|
3
|
+
import { base44 } from "../lib/api";
|
|
4
|
+
import AccessDenied from "./AccessDenied";
|
|
5
|
+
|
|
6
|
+
const AuthContext = createContext(null);
|
|
7
|
+
|
|
8
|
+
/** Current admin user (only available inside AuthGuard). */
|
|
9
|
+
export function useAuth() {
|
|
10
|
+
return useContext(AuthContext);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How long to wait for the identity check before assuming "not signed in".
|
|
15
|
+
*
|
|
16
|
+
* `auth.me()` does not always settle: with no session the SDK may navigate to a
|
|
17
|
+
* login page instead of rejecting, and when the app is rendered inside an iframe
|
|
18
|
+
* (the Base44 editor's preview pane) that navigation is blocked — leaving the
|
|
19
|
+
* promise pending forever. Without this the whole admin sat on a spinner that
|
|
20
|
+
* looked like a broken install; now it falls through to the sign-in screen,
|
|
21
|
+
* which is both honest and actionable.
|
|
22
|
+
*/
|
|
23
|
+
const AUTH_CHECK_TIMEOUT_MS = 8000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Blocks the admin UI unless the caller is an authenticated user with
|
|
27
|
+
* role === "admin". Do NOT weaken this check — see skills/commerce/post-installation.md.
|
|
28
|
+
* (Server-side RLS + requireAdmin() in functions enforce this independently.)
|
|
29
|
+
*
|
|
30
|
+
* Timing out resolves to **no user**, i.e. the sign-in screen — it never grants
|
|
31
|
+
* access, so the guard cannot be weakened by a slow or hanging identity check.
|
|
32
|
+
*/
|
|
33
|
+
export default function AuthGuard({ children }) {
|
|
34
|
+
const [state, setState] = useState({ loading: true, user: null, timedOut: false });
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
let settled = false;
|
|
38
|
+
const finish = (next) => {
|
|
39
|
+
if (settled) return;
|
|
40
|
+
settled = true;
|
|
41
|
+
setState({ loading: false, ...next });
|
|
42
|
+
};
|
|
43
|
+
const timer = setTimeout(() => finish({ user: null, timedOut: true }), AUTH_CHECK_TIMEOUT_MS);
|
|
44
|
+
|
|
45
|
+
base44.auth
|
|
46
|
+
.me()
|
|
47
|
+
.then((user) => finish({ user, timedOut: false }))
|
|
48
|
+
.catch(() => finish({ user: null, timedOut: false }))
|
|
49
|
+
.finally(() => clearTimeout(timer));
|
|
50
|
+
|
|
51
|
+
return () => clearTimeout(timer);
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
if (state.loading) {
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex h-screen flex-col items-center justify-center gap-3">
|
|
57
|
+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
58
|
+
<p className="text-xs text-muted-foreground">Checking your access…</p>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!state.user) {
|
|
64
|
+
return (
|
|
65
|
+
<AccessDenied
|
|
66
|
+
variant="unauthenticated"
|
|
67
|
+
timedOut={state.timedOut}
|
|
68
|
+
onLogin={() => base44.auth.loginWithProvider("google", window.location.pathname)}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (state.user.role !== "admin") {
|
|
74
|
+
return (
|
|
75
|
+
<AccessDenied
|
|
76
|
+
variant="forbidden"
|
|
77
|
+
email={state.user.email}
|
|
78
|
+
onLogout={() => base44.auth.logout(window.location.pathname)}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return <AuthContext.Provider value={state.user}>{children}</AuthContext.Provider>;
|
|
84
|
+
}
|