@base44/app-plugin-commerce 0.8.4 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/package.json +1 -1
- package/scripts/install.js +16 -11
- package/skills/commerce/SKILL.md +20 -21
- package/skills/commerce/docs/api-admin.md +3 -3
- package/skills/commerce/docs/api-storefront.md +2 -2
- package/skills/commerce/docs/entities.md +2 -2
- package/skills/commerce/{install/02-storefront.md → installation/install.md} +248 -6
- package/skills/commerce/references/catalog-rendering.md +2 -2
- package/skills/commerce/references/guest-access-security.md +1 -1
- package/skills/commerce/references/online-payments.md +2 -2
- package/skills/commerce/references/shipping-and-tax.md +3 -3
- package/skills/commerce/references/store-settings.md +1 -1
- package/src/commerce/admin/README.md +54 -6
- package/src/commerce/admin/context/ThemeContext.jsx +62 -0
- package/src/commerce/admin/i18n/locales/de.js +5 -0
- package/src/commerce/admin/i18n/locales/en.js +5 -0
- package/src/commerce/admin/i18n/locales/es.js +5 -0
- package/src/commerce/admin/i18n/locales/fr.js +5 -0
- package/src/commerce/admin/i18n/locales/ja.js +5 -0
- package/src/commerce/admin/i18n/locales/pt.js +5 -0
- package/src/commerce/admin/index.jsx +15 -8
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/layout/ThemeToggle.jsx +35 -0
- package/src/commerce/admin/layout/Topbar.jsx +3 -1
- package/src/commerce/admin/lib/theme.js +212 -0
- package/skills/commerce/install/01-install.md +0 -96
- package/skills/commerce/install/03-data.md +0 -144
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
stage: reference
|
|
3
3
|
read_when: "You are asking which fields a card or a product page can actually show, or hit a variant edge case (unavailable combinations, attributes with no variations, price ranges, linkable selections)."
|
|
4
|
-
skip_when: "The listing and product page render correctly from useProductList / useProduct and the render-model helpers — the quick start in ../install
|
|
4
|
+
skip_when: "The listing and product page render correctly from useProductList / useProduct and the render-model helpers — the quick start in ../installation/install.md covers the happy path."
|
|
5
5
|
forget_when: "Cards and the product page render the fields you intended, variant selection resolves to a variation, and add-to-cart succeeds."
|
|
6
6
|
carry_forward:
|
|
7
7
|
- "There is no product `type` field: a non-empty `attributes[]` is what makes a product sell variants, and such a product is only sellable via a `variation_id`."
|
|
@@ -95,7 +95,7 @@ Both lists are inventories of what the data supports — **not a layout and not
|
|
|
95
95
|
|
|
96
96
|
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, upsells/cross-sells. All but the markup is handed to you: `productImages(product)` + your own active index, `variantAxes(view, pick)`, `useAddToCart`, `productSpecs(product)`, `p.reviews` (+ `useStorefront().submitReview`), and `p.upsells`/`p.crossSells` — added with `useCart().addItem`, matching "already in cart" by product id.
|
|
97
97
|
|
|
98
|
-
**Attributes and modifiers are individually designable.** `productSpecs(product)` rows are `{key, label, titleLabel, value}` plus an inferred `type` — `"numeric"` / `"duration"` (with `number` and `unit` split out), `"location"`, `"list"` (with `items`), `"text"` — so the rendering branch is already made for you: a weight can be a figure, a composition bars, a provenance a place. ⚑ **Never `.map()` the whole list into one grey label/value table**; design the two or three rows this catalog is judged on and let the rest fall through to a plain row. ⚑ **Look a row up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`: meta keys are free text typed per product (`care`, `Care`, `Care Instructions`), so an equality test on `label` — or on one spelling of `key` — silently never fires and the feature renders its fallback forever; `titleLabel` is the display-cased form, for a heading. What a row *looks* like is a per-catalog design decision — not one uniform chip row per axis and one grey table ([`../install
|
|
98
|
+
**Attributes and modifiers are individually designable.** `productSpecs(product)` rows are `{key, label, titleLabel, value}` plus an inferred `type` — `"numeric"` / `"duration"` (with `number` and `unit` split out), `"location"`, `"list"` (with `items`), `"text"` — so the rendering branch is already made for you: a weight can be a figure, a composition bars, a provenance a place. ⚑ **Never `.map()` the whole list into one grey label/value table**; design the two or three rows this catalog is judged on and let the rest fall through to a plain row. ⚑ **Look a row up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`: meta keys are free text typed per product (`care`, `Care`, `Care Instructions`), so an equality test on `label` — or on one spelling of `key` — silently never fires and the feature renders its fallback forever; `titleLabel` is the display-cased form, for a heading. What a row *looks* like is a per-catalog design decision — not one uniform chip row per axis and one grey table ([`../installation/install.md`](../installation/install.md)); §5's rules govern selector *behaviour*, never its form.
|
|
99
99
|
|
|
100
100
|
## 4. Ribbons — in **both** views
|
|
101
101
|
|
|
@@ -18,7 +18,7 @@ carry_forward:
|
|
|
18
18
|
- The storefront catalog function strips the paywalled fields (`downloads`, `download_limit`, `download_expiry`) from every product/variation it returns. Downloadable files are reached only through `commerce/storefront-account` `get-download`, which enforces ownership, download limits, expiry and short-lived signed URLs — keep that the only path to a `file_url`.
|
|
19
19
|
- `cart_token` and `order_key` are **bearer credentials** — possession grants access to that cart/order. Always serve over HTTPS; don't log them; treat them like secrets.
|
|
20
20
|
- Carts and orders have admin-only RLS; customers never touch those entities directly — all access is mediated by `commerce/storefront-*` functions using the service role after verifying the caller.
|
|
21
|
-
- The admin side has three enforcement layers; never weaken them ([`../
|
|
21
|
+
- The admin side has three enforcement layers; never weaken them ([`../installation/install.md`](../installation/install.md)).
|
|
22
22
|
|
|
23
23
|
## Rules for anything you add
|
|
24
24
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
stage: reference
|
|
3
3
|
read_when: "The store opted into card payments and you are wiring the provider NOW."
|
|
4
|
-
skip_when: "The store takes offline payments (the default) — nothing here applies; the decision itself lives in ../install
|
|
4
|
+
skip_when: "The store takes offline payments (the default) — nothing here applies; the decision itself lives in ../installation/install.md."
|
|
5
5
|
forget_when: "A test payment reaches /order-received as paid, or the provider file is copied and the `card` gateway is enabled."
|
|
6
6
|
carry_forward:
|
|
7
7
|
- "Wiring a provider = replace shared/commerce/card-payment.ts WHOLE (copy the shipped card-payment.<provider>.ts over it) — a partial edit breaks every commerce function's deploy with duplicate exports."
|
|
@@ -15,7 +15,7 @@ The kit ships a **Credit card** checkout option (`commerce.PaymentGateway` slug
|
|
|
15
15
|
|
|
16
16
|
What it does **not** ship is a live provider. Wiring one means **one file** — `base44/shared/commerce/card-payment.ts`, four functions — and nothing else: no entity is touched, no caller or UI needs editing, and the payment webhook *function* is premade (it calls this file's `parseWebhook`).
|
|
17
17
|
|
|
18
|
-
Card payments are **off by default** and are the last thing to add, never the first — the rule, the timing and the decision table live in [`../install
|
|
18
|
+
Card payments are **off by default** and are the last thing to add, never the first — the rule, the timing and the decision table live in [`../installation/install.md`](../installation/install.md). Everything below assumes that decision is made.
|
|
19
19
|
|
|
20
20
|
## Enabling the gateway — one seed call, at any time
|
|
21
21
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
stage: reference
|
|
3
|
-
read_when: "You need shipping or tax beyond the one recipe inline in ../install
|
|
4
|
-
skip_when: "One flat rate is enough, or the '€X region / €Y worldwide' recipe in ../install
|
|
3
|
+
read_when: "You need shipping or tax beyond the one recipe inline in ../installation/install.md — several priced tiers, VAT, tax on shipping, or editing locations after the seed."
|
|
4
|
+
skip_when: "One flat rate is enough, or the '€X region / €Y worldwide' recipe in ../installation/install.md already expresses the requirement."
|
|
5
5
|
forget_when: "The seed response came back with no `no_catchall_location` warning and checkout prices shipping for both a domestic and a foreign test address."
|
|
6
6
|
carry_forward:
|
|
7
7
|
- "One matched commerce.ShippingTaxLocation supplies BOTH the shipping rates and the tax for an address."
|
|
@@ -13,7 +13,7 @@ carry_forward:
|
|
|
13
13
|
|
|
14
14
|
One entity does both jobs. A **`commerce.ShippingTaxLocation`** is *a set of regions plus everything charged there*: the shipping options offered and the tax applied. There is no zone/method/class model and no separate tax-rate table.
|
|
15
15
|
|
|
16
|
-
Declaring `locations` in the `commerce/seed-store` call is **the** way to set shipping up ([`../install
|
|
16
|
+
Declaring `locations` in the `commerce/seed-store` call is **the** way to set shipping up ([`../installation/install.md`](../installation/install.md)); editing the entity afterwards is the day-2 route (last section).
|
|
17
17
|
|
|
18
18
|
## The model
|
|
19
19
|
|
|
@@ -31,7 +31,7 @@ Six records, one per group: `general`, `products`, `inventory`, `tax`, `shipping
|
|
|
31
31
|
| **`shipping`** | | |
|
|
32
32
|
| `enable_shipping` | `true` | `false` = no shipping line, no rates, no address requirement anywhere. The digital-only-store switch. |
|
|
33
33
|
| **`emails`** | | |
|
|
34
|
-
| `store_name` | `""` (set from the seed call) | Subject/heading `{store_name}`, the `SendEmail` sender name, **and** the public shop name on `get-store-info`. Blank = Base44 sends as the app's name; semantics in [`emails.md`](./emails.md). Required on a first seed ([`../install
|
|
34
|
+
| `store_name` | `""` (set from the seed call) | Subject/heading `{store_name}`, the `SendEmail` sender name, **and** the public shop name on `get-store-info`. Blank = Base44 sends as the app's name; semantics in [`emails.md`](./emails.md). Required on a first seed ([`../installation/install.md`](../installation/install.md)). |
|
|
35
35
|
| `admin_recipients` | `[]` | Fallback notification addresses; empty falls back to the app's `admin`-role users. |
|
|
36
36
|
| `<type>.enabled` / `.subject` / `.heading` / `.recipient` | enabled, blanks | Per-type overrides at the **top level** of the group (`emails.new_order.enabled`) — [`emails.md`](./emails.md). |
|
|
37
37
|
|
|
@@ -6,7 +6,7 @@ Tailwind + shadcn/ui + React Router) to get a full store back office.
|
|
|
6
6
|
|
|
7
7
|
> Docs live in the commerce skill, and every path below is **relative to that
|
|
8
8
|
> skill folder** (`.agents/skills/commerce/` once installed): install, mounting
|
|
9
|
-
> and role setup are `
|
|
9
|
+
> and role setup are `installation/install.md`; the map for everything else is
|
|
10
10
|
> `SKILL.md`; the API references are `docs/api-admin.md` and
|
|
11
11
|
> `docs/api-storefront.md`.
|
|
12
12
|
|
|
@@ -28,7 +28,7 @@ Tailwind + shadcn/ui + React Router) to get a full store back office.
|
|
|
28
28
|
4. Mount it as a layout route in the app's own `src/App.jsx` — that is the file
|
|
29
29
|
Base44 discovers an app's pages from, so the screens that should be listed as
|
|
30
30
|
pages are declared there literally, and the rest go to `<AdminRoutes />` on a
|
|
31
|
-
splat. The full step is the skill's `
|
|
31
|
+
splat. The full step is the skill's `installation/install.md`.
|
|
32
32
|
|
|
33
33
|
```jsx
|
|
34
34
|
import AdminApp, { AdminRoutes } from "@/commerce/admin";
|
|
@@ -50,7 +50,7 @@ Tailwind + shadcn/ui + React Router) to get a full store back office.
|
|
|
50
50
|
sample data when the store has no products yet. If `commerce/seed-store` was
|
|
51
51
|
already run during installation — including when an agent generated a real
|
|
52
52
|
catalog — the store counts as ready and this screen never shows; see
|
|
53
|
-
the commerce skill's install
|
|
53
|
+
the commerce skill's installation/install.md.
|
|
54
54
|
|
|
55
55
|
## External touchpoints
|
|
56
56
|
|
|
@@ -85,16 +85,64 @@ npx shadcn@latest add <component>
|
|
|
85
85
|
```
|
|
86
86
|
index.jsx AdminApp: providers → auth guard → layout → <Outlet/>
|
|
87
87
|
routes.jsx Route table + <AdminRoutes/> (the splat handler App.jsx delegates to)
|
|
88
|
-
layout/ AdminLayout, Sidebar, Topbar, AuthGuard (admin-role gate), AccessDenied
|
|
88
|
+
layout/ AdminLayout, Sidebar, Topbar, ThemeToggle, AuthGuard (admin-role gate), AccessDenied
|
|
89
89
|
bot/ StoreAdminBot (chat panel over the commerce/StoreAdmin agent), Markdown (GFM renderer)
|
|
90
|
-
context/ SettingsContext (store settings + first-run seeding), BasePathContext
|
|
90
|
+
context/ SettingsContext (store settings + first-run seeding), BasePathContext, ThemeContext
|
|
91
91
|
hooks/ useAsync, usePagedList, useRealtime (live updates), useMoney, useDebounce
|
|
92
92
|
i18n/ t() + locale; locales/ = en (default), de, es, fr, ja, pt
|
|
93
|
-
lib/ api (function calls), constants, format, geo-data, order/product utils
|
|
93
|
+
lib/ api (function calls), constants, format, theme, geo-data, order/product utils
|
|
94
94
|
components/ DataTable, SearchSelect, MoneyInput, DateRangePicker, AddressForm, …
|
|
95
95
|
pages/ All admin pages (orders, products, coupons, customers, reports, settings, webhooks)
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
+
## Theme: the store's, or Base44's
|
|
99
|
+
|
|
100
|
+
The admin renders in the host app's theme — it styles everything with the
|
|
101
|
+
shadcn semantic classes (`bg-background`, `text-muted-foreground`, `bg-primary`),
|
|
102
|
+
so it picks up the store's tokens for free. That is right for most stores and
|
|
103
|
+
wrong for some: a display font, a saturated brand color on every primary
|
|
104
|
+
button, or a dark low-contrast surface reads as personality on a storefront and
|
|
105
|
+
as noise in a back office someone works in all day.
|
|
106
|
+
|
|
107
|
+
So the topbar carries a **switch with both ends named — Site ⇄ Base44** — next
|
|
108
|
+
to the signed-in email. Off is *Site*, the store's own theme and the default;
|
|
109
|
+
on is *Base44*, a fixed neutral palette and font stack in the shape of the
|
|
110
|
+
Base44 dashboard, with the side in effect shown in full-contrast text. Both
|
|
111
|
+
ends are named because a control that shows one theme cannot say whether that
|
|
112
|
+
is the state you are in or the one a click would give you. The choice is per browser
|
|
113
|
+
(`localStorage`, key `commerce.admin.theme`) — it is a viewing preference of
|
|
114
|
+
whoever is working, not a store setting, so it is not saved to the backend and
|
|
115
|
+
not shared between admins.
|
|
116
|
+
|
|
117
|
+
**Only colors and fonts change.** No spacing, radii, sizes or layout, and
|
|
118
|
+
nothing outside the admin: `lib/theme.js` writes shadcn tokens as inline custom
|
|
119
|
+
properties on `<html>` and removes them when the admin unmounts or the switch
|
|
120
|
+
goes back to *Site*.
|
|
121
|
+
|
|
122
|
+
Four things worth knowing before editing `lib/theme.js`:
|
|
123
|
+
|
|
124
|
+
- **It writes to `<html>`, not to an admin wrapper.** The shadcn primitives the
|
|
125
|
+
admin is built from — dropdowns, dialogs, sheets, selects, toasts — render
|
|
126
|
+
through portals into `document.body`, and a wrapper-scoped theme would leave
|
|
127
|
+
every one of them in the site's colors while the page behind them changed.
|
|
128
|
+
- **It matches the app's token convention at runtime.** A Tailwind v3 shadcn app
|
|
129
|
+
stores bare triples (`0 0% 100%`) and wraps at the point of use
|
|
130
|
+
(`hsl(var(--background))`); a v4 app stores finished colors and uses the var
|
|
131
|
+
directly. Writing the wrong shape paints nothing at all, so the palette is
|
|
132
|
+
authored once as triples and emitted in whichever form the app already uses.
|
|
133
|
+
- **The Base44 theme is a light theme** and suspends the app's `dark` class
|
|
134
|
+
while it is on, restoring it on the way out. Without that, `dark:` utilities
|
|
135
|
+
and native controls stay dark over light surfaces.
|
|
136
|
+
- **It repaints `<body>` as well as the tokens.** Plenty of stores paint the
|
|
137
|
+
page directly — `body { background-color: var(--brand-ink); color: … }` next
|
|
138
|
+
to their tokens rather than through them. Tokens alone would then put white
|
|
139
|
+
cards and near-white inherited text on a dark page; the same rules that set
|
|
140
|
+
the font set the root's background and text color from the palette.
|
|
141
|
+
|
|
142
|
+
Restyling the admin to match your store instead is the other direction, and it
|
|
143
|
+
is just the tokens: change them in your app's CSS and both the storefront and
|
|
144
|
+
the admin's *Site theme* follow.
|
|
145
|
+
|
|
98
146
|
## Localization
|
|
99
147
|
|
|
100
148
|
Every user-visible string renders through `t()` from `i18n/`; the wording
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useState,
|
|
8
|
+
} from "react";
|
|
9
|
+
import {
|
|
10
|
+
BASE44_THEME,
|
|
11
|
+
SITE_THEME,
|
|
12
|
+
applyAdminTheme,
|
|
13
|
+
readStoredTheme,
|
|
14
|
+
storeTheme,
|
|
15
|
+
} from "../lib/theme";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Which theme the admin renders in — the host app's ("site") or the built-in
|
|
19
|
+
* Base44 palette and fonts ("base44"). See `lib/theme.js` for what each one
|
|
20
|
+
* changes; the picker is `layout/ThemeToggle.jsx`.
|
|
21
|
+
*
|
|
22
|
+
* The default value keeps `useAdminTheme()` safe for anything rendered outside
|
|
23
|
+
* the provider (a hand-mounted Topbar, a test): the toggle then reports the
|
|
24
|
+
* site theme and does nothing, rather than throwing.
|
|
25
|
+
*/
|
|
26
|
+
const ThemeCtx = createContext({ theme: SITE_THEME, setTheme: () => {}, toggle: () => {} });
|
|
27
|
+
|
|
28
|
+
export function useAdminTheme() {
|
|
29
|
+
return useContext(ThemeCtx);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Before paint where there is a DOM: a stored Base44 theme applied in a passive
|
|
33
|
+
// effect would show one frame of the site theme on every admin load — the exact
|
|
34
|
+
// flash of loud colors the toggle is there to avoid.
|
|
35
|
+
const useThemeEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
|
|
36
|
+
|
|
37
|
+
export function AdminThemeProvider({ children }) {
|
|
38
|
+
const [theme, setThemeState] = useState(readStoredTheme);
|
|
39
|
+
|
|
40
|
+
// Applies to <html> (portals, see lib/theme.js) and clears on unmount, so
|
|
41
|
+
// leaving the admin for the storefront restores the site's own theme.
|
|
42
|
+
useThemeEffect(() => {
|
|
43
|
+
applyAdminTheme(theme);
|
|
44
|
+
return () => applyAdminTheme(SITE_THEME);
|
|
45
|
+
}, [theme]);
|
|
46
|
+
|
|
47
|
+
const setTheme = useCallback((next) => {
|
|
48
|
+
const value = next === BASE44_THEME ? BASE44_THEME : SITE_THEME;
|
|
49
|
+
setThemeState(value);
|
|
50
|
+
storeTheme(value);
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const toggle = useCallback(() => {
|
|
54
|
+
setThemeState((current) => {
|
|
55
|
+
const next = current === BASE44_THEME ? SITE_THEME : BASE44_THEME;
|
|
56
|
+
storeTheme(next);
|
|
57
|
+
return next;
|
|
58
|
+
});
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
return <ThemeCtx.Provider value={{ theme, setTheme, toggle }}>{children}</ThemeCtx.Provider>;
|
|
62
|
+
}
|
|
@@ -94,6 +94,11 @@ export default {
|
|
|
94
94
|
"auth.sign_out": "Abmelden",
|
|
95
95
|
"auth.signed_in_as": "Angemeldet als {email}",
|
|
96
96
|
|
|
97
|
+
// ── theme toggle ────────────────────────────────────────────────
|
|
98
|
+
"theme.base44": "Base44",
|
|
99
|
+
"theme.hint": "Neutrale Base44-Farben und -Schriften statt des Website-Designs verwenden",
|
|
100
|
+
"theme.site": "Website",
|
|
101
|
+
|
|
97
102
|
// ── address form ────────────────────────────────────────────────
|
|
98
103
|
"address.city": "Stadt",
|
|
99
104
|
"address.company": "Firma",
|
|
@@ -98,6 +98,11 @@ export default {
|
|
|
98
98
|
"auth.sign_out": "Sign out",
|
|
99
99
|
"auth.signed_in_as": "Signed in as {email}",
|
|
100
100
|
|
|
101
|
+
// ── theme toggle ────────────────────────────────────────────────
|
|
102
|
+
"theme.base44": "Base44",
|
|
103
|
+
"theme.hint": "Use neutral Base44 dashboard colors and fonts instead of the store's theme",
|
|
104
|
+
"theme.site": "Site",
|
|
105
|
+
|
|
101
106
|
// ── address form ────────────────────────────────────────────────
|
|
102
107
|
"address.city": "City",
|
|
103
108
|
"address.company": "Company",
|
|
@@ -94,6 +94,11 @@ export default {
|
|
|
94
94
|
"auth.sign_out": "Cerrar sesión",
|
|
95
95
|
"auth.signed_in_as": "Sesión iniciada como {email}",
|
|
96
96
|
|
|
97
|
+
// ── theme toggle ────────────────────────────────────────────────
|
|
98
|
+
"theme.base44": "Base44",
|
|
99
|
+
"theme.hint": "Usar los colores y las fuentes neutros de Base44 en lugar del tema del sitio",
|
|
100
|
+
"theme.site": "Sitio",
|
|
101
|
+
|
|
97
102
|
// ── address form ────────────────────────────────────────────────
|
|
98
103
|
"address.city": "Ciudad",
|
|
99
104
|
"address.company": "Empresa",
|
|
@@ -94,6 +94,11 @@ export default {
|
|
|
94
94
|
"auth.sign_out": "Se déconnecter",
|
|
95
95
|
"auth.signed_in_as": "Connecté en tant que {email}",
|
|
96
96
|
|
|
97
|
+
// ── theme toggle ────────────────────────────────────────────────
|
|
98
|
+
"theme.base44": "Base44",
|
|
99
|
+
"theme.hint": "Utiliser les couleurs et polices neutres de Base44 au lieu du thème du site",
|
|
100
|
+
"theme.site": "Site",
|
|
101
|
+
|
|
97
102
|
// ── address form ────────────────────────────────────────────────
|
|
98
103
|
"address.city": "Ville",
|
|
99
104
|
"address.company": "Société",
|
|
@@ -94,6 +94,11 @@ export default {
|
|
|
94
94
|
"auth.sign_out": "サインアウト",
|
|
95
95
|
"auth.signed_in_as": "{email} でサインイン中",
|
|
96
96
|
|
|
97
|
+
// ── theme toggle ────────────────────────────────────────────────
|
|
98
|
+
"theme.base44": "Base44",
|
|
99
|
+
"theme.hint": "サイトのテーマではなく Base44 の中立的な配色とフォントを使用します",
|
|
100
|
+
"theme.site": "サイト",
|
|
101
|
+
|
|
97
102
|
// ── address form ────────────────────────────────────────────────
|
|
98
103
|
"address.city": "市区町村",
|
|
99
104
|
"address.company": "会社名",
|
|
@@ -94,6 +94,11 @@ export default {
|
|
|
94
94
|
"auth.sign_out": "Terminar sessão",
|
|
95
95
|
"auth.signed_in_as": "Sessão iniciada como {email}",
|
|
96
96
|
|
|
97
|
+
// ── theme toggle ────────────────────────────────────────────────
|
|
98
|
+
"theme.base44": "Base44",
|
|
99
|
+
"theme.hint": "Usar as cores e tipos de letra neutros do Base44 em vez do tema do site",
|
|
100
|
+
"theme.site": "Site",
|
|
101
|
+
|
|
97
102
|
// ── address form ────────────────────────────────────────────────
|
|
98
103
|
"address.city": "Cidade",
|
|
99
104
|
"address.company": "Empresa",
|
|
@@ -6,6 +6,7 @@ import AdminLayout from "./layout/AdminLayout";
|
|
|
6
6
|
import AdminRoutes from "./routes";
|
|
7
7
|
import { SettingsProvider } from "./context/SettingsContext";
|
|
8
8
|
import { BasePathProvider } from "./context/BasePathContext";
|
|
9
|
+
import { AdminThemeProvider } from "./context/ThemeContext";
|
|
9
10
|
|
|
10
11
|
export { default as AdminRoutes } from "./routes";
|
|
11
12
|
|
|
@@ -37,6 +38,10 @@ export { default as AdminRoutes } from "./routes";
|
|
|
37
38
|
*
|
|
38
39
|
* Requires an authenticated user with role "admin" (enforced by AuthGuard,
|
|
39
40
|
* and independently by entity RLS + requireAdmin() in backend functions).
|
|
41
|
+
*
|
|
42
|
+
* AdminThemeProvider is outermost so the topbar's site/Base44 theme choice also
|
|
43
|
+
* covers the screens rendered before the admin proper — sign-in, access-denied,
|
|
44
|
+
* first-run setup — and the toasts, which portal out of the layout.
|
|
40
45
|
*/
|
|
41
46
|
export default function AdminApp({ basePath = "/store-admin" }) {
|
|
42
47
|
// Installs from before the routes moved into App.jsx mount the whole admin
|
|
@@ -46,13 +51,15 @@ export default function AdminApp({ basePath = "/store-admin" }) {
|
|
|
46
51
|
// sidebar; those apps keep working until an agent rewrites their App.jsx.
|
|
47
52
|
const outlet = useOutlet();
|
|
48
53
|
return (
|
|
49
|
-
<
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
<
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
<AdminThemeProvider>
|
|
55
|
+
<BasePathProvider value={basePath}>
|
|
56
|
+
<Toaster richColors position="top-right" />
|
|
57
|
+
<AuthGuard>
|
|
58
|
+
<SettingsProvider>
|
|
59
|
+
<AdminLayout>{outlet ?? <AdminRoutes />}</AdminLayout>
|
|
60
|
+
</SettingsProvider>
|
|
61
|
+
</AuthGuard>
|
|
62
|
+
</BasePathProvider>
|
|
63
|
+
</AdminThemeProvider>
|
|
57
64
|
);
|
|
58
65
|
}
|
|
@@ -25,7 +25,7 @@ const AUTH_CHECK_TIMEOUT_MS = 8000;
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Blocks the admin UI unless the caller is an authenticated user with
|
|
28
|
-
* role === "admin". Do NOT weaken this check — see the commerce skill's
|
|
28
|
+
* role === "admin". Do NOT weaken this check — see the commerce skill's installation/install.md.
|
|
29
29
|
* (Server-side RLS + requireAdmin() in functions enforce this independently.)
|
|
30
30
|
*
|
|
31
31
|
* Timing out resolves to **no user**, i.e. the sign-in screen — it never grants
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Switch } from "@/components/ui/switch";
|
|
3
|
+
import { Palette } from "lucide-react";
|
|
4
|
+
import { useAdminTheme } from "../context/ThemeContext";
|
|
5
|
+
import { BASE44_THEME, SITE_THEME } from "../lib/theme";
|
|
6
|
+
import { t } from "../i18n";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Topbar switch between the host site's theme and the built-in Base44 one
|
|
10
|
+
* (colors and fonts only — `lib/theme.js`).
|
|
11
|
+
*
|
|
12
|
+
* Both ends are named — Site ⇄ Base44, off being Site — because a control that
|
|
13
|
+
* shows one theme cannot say whether that is the state you are in or the one a
|
|
14
|
+
* click would give you. The side in effect is the one in full-contrast text.
|
|
15
|
+
* The words are labels, not buttons: one control, one tab stop.
|
|
16
|
+
*/
|
|
17
|
+
export default function ThemeToggle() {
|
|
18
|
+
const { theme, setTheme } = useAdminTheme();
|
|
19
|
+
const isBase44 = theme === BASE44_THEME;
|
|
20
|
+
const side = (active) =>
|
|
21
|
+
`hidden text-sm sm:inline ${active ? "font-medium text-foreground" : "text-muted-foreground"}`;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="flex shrink-0 items-center gap-2" title={t("theme.hint")}>
|
|
25
|
+
<Palette className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
26
|
+
<span className={side(!isBase44)}>{t("theme.site")}</span>
|
|
27
|
+
<Switch
|
|
28
|
+
checked={isBase44}
|
|
29
|
+
onCheckedChange={(on) => setTheme(on ? BASE44_THEME : SITE_THEME)}
|
|
30
|
+
aria-label={t("theme.hint")}
|
|
31
|
+
/>
|
|
32
|
+
<span className={side(isBase44)}>{t("theme.base44")}</span>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -14,6 +14,7 @@ import { base44 } from "../lib/api";
|
|
|
14
14
|
import { t } from "../i18n";
|
|
15
15
|
import { useAuth } from "./AuthGuard";
|
|
16
16
|
import { useBasePath, useAdminHref } from "../context/BasePathContext";
|
|
17
|
+
import ThemeToggle from "./ThemeToggle";
|
|
17
18
|
import { ADMIN_ROUTES } from "../routes";
|
|
18
19
|
|
|
19
20
|
/** Match an internal path (no leading slash) against ADMIN_ROUTES patterns. */
|
|
@@ -44,7 +45,7 @@ function useBreadcrumbs() {
|
|
|
44
45
|
return crumbs;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
/** Top bar: mobile menu button, breadcrumbs, user menu. */
|
|
48
|
+
/** Top bar: mobile menu button, breadcrumbs, theme toggle, user menu. */
|
|
48
49
|
export default function Topbar({ onMenuClick }) {
|
|
49
50
|
const user = useAuth();
|
|
50
51
|
const href = useAdminHref();
|
|
@@ -72,6 +73,7 @@ export default function Topbar({ onMenuClick }) {
|
|
|
72
73
|
</React.Fragment>
|
|
73
74
|
))}
|
|
74
75
|
</nav>
|
|
76
|
+
<ThemeToggle />
|
|
75
77
|
<DropdownMenu>
|
|
76
78
|
<DropdownMenuTrigger asChild>
|
|
77
79
|
<Button variant="ghost" size="sm" className="gap-2">
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The admin's two color/typography themes, applied as CSS custom properties.
|
|
3
|
+
*
|
|
4
|
+
* "site" — inherit the host app's shadcn tokens (default; nothing applied)
|
|
5
|
+
* "base44" — a fixed, neutral palette and font stack that reads like the
|
|
6
|
+
* Base44 dashboard, for stores whose site theme makes the back
|
|
7
|
+
* office hard to work in (display fonts, saturated brand colors,
|
|
8
|
+
* low-contrast surfaces)
|
|
9
|
+
*
|
|
10
|
+
* Only colors and fonts change: no spacing, radii, sizes or layout. The admin
|
|
11
|
+
* screens keep using semantic classes (`bg-background`, `text-muted-foreground`,
|
|
12
|
+
* `bg-primary`), so both themes go through the same tokens and no screen needs
|
|
13
|
+
* to know which one is on.
|
|
14
|
+
*
|
|
15
|
+
* Framework-free on purpose — `context/ThemeContext.jsx` is the React wrapper.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Persisted per browser: which theme the admin last chose. */
|
|
19
|
+
export const THEME_STORAGE_KEY = "commerce.admin.theme";
|
|
20
|
+
|
|
21
|
+
/** Set on <html> while a non-default theme is active; the scoped rules key off it. */
|
|
22
|
+
export const THEME_ATTR = "data-commerce-admin-theme";
|
|
23
|
+
|
|
24
|
+
export const SITE_THEME = "site";
|
|
25
|
+
export const BASE44_THEME = "base44";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Base44-dashboard palette as shadcn token → HSL triple ("H S% L%").
|
|
29
|
+
*
|
|
30
|
+
* Triples, not finished colors, because that is the format shadcn itself
|
|
31
|
+
* authors in and a triple can always be wrapped for the apps that need a
|
|
32
|
+
* complete color. Values are a warm near-neutral: white surfaces, near-black
|
|
33
|
+
* primary, low-chroma grays — the light theme the dashboard ships. There is
|
|
34
|
+
* deliberately no dark variant; `applyAdminTheme()` suspends the app's `dark`
|
|
35
|
+
* class instead of guessing at one.
|
|
36
|
+
*/
|
|
37
|
+
const BASE44_TOKENS = {
|
|
38
|
+
background: "0 0% 100%",
|
|
39
|
+
foreground: "60 3% 10%",
|
|
40
|
+
card: "0 0% 100%",
|
|
41
|
+
"card-foreground": "60 3% 10%",
|
|
42
|
+
popover: "0 0% 100%",
|
|
43
|
+
"popover-foreground": "60 3% 10%",
|
|
44
|
+
primary: "60 3% 10%",
|
|
45
|
+
"primary-foreground": "40 20% 98%",
|
|
46
|
+
secondary: "45 14% 96%",
|
|
47
|
+
"secondary-foreground": "60 3% 10%",
|
|
48
|
+
muted: "45 14% 96%",
|
|
49
|
+
"muted-foreground": "50 4% 44%",
|
|
50
|
+
accent: "45 14% 93%",
|
|
51
|
+
"accent-foreground": "60 3% 10%",
|
|
52
|
+
destructive: "0 72% 45%",
|
|
53
|
+
"destructive-foreground": "0 0% 98%",
|
|
54
|
+
border: "45 10% 91%",
|
|
55
|
+
input: "45 10% 88%",
|
|
56
|
+
ring: "60 3% 10%",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const BASE44_FONT_SANS =
|
|
60
|
+
'Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif';
|
|
61
|
+
const BASE44_FONT_MONO =
|
|
62
|
+
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
|
|
63
|
+
|
|
64
|
+
/** Tokens read to learn the host app's format; the first one defined decides. */
|
|
65
|
+
const PROBE_TOKENS = ["--background", "--foreground", "--primary", "--card"];
|
|
66
|
+
|
|
67
|
+
/** A bare shadcn triple: "0 0% 100%", "222.2 47.4% 11.2%". */
|
|
68
|
+
const HSL_TRIPLE = /^-?[\d.]+(deg)?\s+[\d.]+%\s+[\d.]+%$/;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether this app's tokens hold *finished* colors rather than bare triples.
|
|
72
|
+
*
|
|
73
|
+
* shadcn ships both conventions and the CSS that consumes them differs: a
|
|
74
|
+
* Tailwind v3 app stores `0 0% 100%` and wraps at the point of use
|
|
75
|
+
* (`hsl(var(--background))`), a v4 app stores `oklch(1 0 0)` and uses the var
|
|
76
|
+
* directly (`var(--background)`). Writing the wrong shape paints nothing at
|
|
77
|
+
* all — every surface silently loses its background — so read what the app
|
|
78
|
+
* already has instead of assuming. Unreadable or undefined tokens fall back to
|
|
79
|
+
* triples: that is the convention this admin was written against.
|
|
80
|
+
*/
|
|
81
|
+
function tokensAreColors() {
|
|
82
|
+
if (typeof window === "undefined" || !window.getComputedStyle) return false;
|
|
83
|
+
const style = window.getComputedStyle(document.documentElement);
|
|
84
|
+
for (const token of PROBE_TOKENS) {
|
|
85
|
+
const value = style.getPropertyValue(token).trim();
|
|
86
|
+
if (value) return !HSL_TRIPLE.test(value);
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The style element carrying the rules tokens can't express; created on first use. */
|
|
92
|
+
let scopedStyleEl = null;
|
|
93
|
+
|
|
94
|
+
/** Custom properties this module set on <html>, so cleanup removes only those. */
|
|
95
|
+
let appliedProps = [];
|
|
96
|
+
|
|
97
|
+
/** True when we removed the app's `dark` class and owe it a restore. */
|
|
98
|
+
let suspendedDark = false;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The two things a token override cannot reach.
|
|
102
|
+
*
|
|
103
|
+
* **Fonts**, which are not tokens in shadcn at all — a store's face arrives as
|
|
104
|
+
* `body { font-family }` or a Tailwind utility, and both are outranked by these
|
|
105
|
+
* rules: they are anchored on `<html>` and injected unlayered, so they beat
|
|
106
|
+
* anything an app declares inside `@layer base`. `.font-mono` and friends are
|
|
107
|
+
* listed separately so order IDs, coupon codes and the bot's code spans stay
|
|
108
|
+
* monospaced instead of inheriting the sans stack.
|
|
109
|
+
*
|
|
110
|
+
* **The page's own background and text color**, for the common case of a store
|
|
111
|
+
* that paints `<body>` directly (`background-color: var(--brand-ink)`) next to
|
|
112
|
+
* its tokens rather than through them. The tokens then say "white surfaces" and
|
|
113
|
+
* the body still says "near-black, near-white text": the admin's translucent
|
|
114
|
+
* page layer (`bg-muted/30`) renders muddy over it, and every element that
|
|
115
|
+
* inherits its color instead of naming one — breadcrumbs, table cells, card
|
|
116
|
+
* body text — comes out near-white on a white card. Repainting the root from
|
|
117
|
+
* the same palette is what keeps the theme whole.
|
|
118
|
+
*/
|
|
119
|
+
function applyScopedRules(scope) {
|
|
120
|
+
if (!scopedStyleEl) {
|
|
121
|
+
scopedStyleEl = document.createElement("style");
|
|
122
|
+
scopedStyleEl.setAttribute("data-commerce-admin-theme", "");
|
|
123
|
+
document.head.appendChild(scopedStyleEl);
|
|
124
|
+
}
|
|
125
|
+
scopedStyleEl.textContent = `
|
|
126
|
+
${scope}, ${scope} body {
|
|
127
|
+
background-color: hsl(${BASE44_TOKENS.background});
|
|
128
|
+
color: hsl(${BASE44_TOKENS.foreground});
|
|
129
|
+
font-family: ${BASE44_FONT_SANS};
|
|
130
|
+
}
|
|
131
|
+
${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4, ${scope} h5, ${scope} h6 { font-family: ${BASE44_FONT_SANS}; }
|
|
132
|
+
${scope} .font-mono, ${scope} code, ${scope} kbd, ${scope} pre, ${scope} samp { font-family: ${BASE44_FONT_MONO}; }
|
|
133
|
+
`.trim();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Undo everything `applyAdminTheme` set, leaving the app's own theme untouched. */
|
|
137
|
+
function clearAdminTheme() {
|
|
138
|
+
if (typeof document === "undefined") return;
|
|
139
|
+
const root = document.documentElement;
|
|
140
|
+
appliedProps.forEach((prop) => root.style.removeProperty(prop));
|
|
141
|
+
appliedProps = [];
|
|
142
|
+
root.style.removeProperty("color-scheme");
|
|
143
|
+
root.removeAttribute(THEME_ATTR);
|
|
144
|
+
if (scopedStyleEl) {
|
|
145
|
+
scopedStyleEl.remove();
|
|
146
|
+
scopedStyleEl = null;
|
|
147
|
+
}
|
|
148
|
+
if (suspendedDark) {
|
|
149
|
+
root.classList.add("dark");
|
|
150
|
+
suspendedDark = false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Apply `theme` to the document, replacing whatever this module applied before.
|
|
156
|
+
* `SITE_THEME` (or anything unknown) just clears back to the app's own theme.
|
|
157
|
+
*
|
|
158
|
+
* Everything lands on `<html>`, not on an admin wrapper, for two reasons: the
|
|
159
|
+
* shadcn primitives the admin builds on — dropdowns, dialogs, sheets, selects,
|
|
160
|
+
* toasts — render through portals into `document.body` and would otherwise keep
|
|
161
|
+
* the site theme while the page behind them changed; and inline properties on
|
|
162
|
+
* the root outrank every stylesheet rule, including `.dark`. The admin is a
|
|
163
|
+
* full-page shell, so nothing else is on screen to be affected — but the caller
|
|
164
|
+
* owns the lifetime and must clear on unmount (`ThemeContext` does).
|
|
165
|
+
*/
|
|
166
|
+
export function applyAdminTheme(theme) {
|
|
167
|
+
if (typeof document === "undefined") return;
|
|
168
|
+
clearAdminTheme();
|
|
169
|
+
if (theme !== BASE44_THEME) return;
|
|
170
|
+
|
|
171
|
+
const root = document.documentElement;
|
|
172
|
+
const asColor = tokensAreColors();
|
|
173
|
+
Object.entries(BASE44_TOKENS).forEach(([name, triple]) => {
|
|
174
|
+
const prop = `--${name}`;
|
|
175
|
+
root.style.setProperty(prop, asColor ? `hsl(${triple})` : triple);
|
|
176
|
+
appliedProps.push(prop);
|
|
177
|
+
});
|
|
178
|
+
// Tailwind v4 resolves `font-sans`/`font-mono` through these; harmless in v3.
|
|
179
|
+
root.style.setProperty("--font-sans", BASE44_FONT_SANS);
|
|
180
|
+
root.style.setProperty("--font-mono", BASE44_FONT_MONO);
|
|
181
|
+
appliedProps.push("--font-sans", "--font-mono");
|
|
182
|
+
|
|
183
|
+
// This palette is a light one. Leaving a `dark` class in place would keep the
|
|
184
|
+
// app's `dark:` utilities and native controls dark over light surfaces, so
|
|
185
|
+
// the class steps aside while the theme is on and comes back when it is off.
|
|
186
|
+
if (root.classList.contains("dark")) {
|
|
187
|
+
root.classList.remove("dark");
|
|
188
|
+
suspendedDark = true;
|
|
189
|
+
}
|
|
190
|
+
root.style.setProperty("color-scheme", "light");
|
|
191
|
+
|
|
192
|
+
root.setAttribute(THEME_ATTR, BASE44_THEME);
|
|
193
|
+
applyScopedRules(`html[${THEME_ATTR}="${BASE44_THEME}"]`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The stored choice, or the site theme when nothing is stored or storage is blocked. */
|
|
197
|
+
export function readStoredTheme() {
|
|
198
|
+
try {
|
|
199
|
+
return localStorage.getItem(THEME_STORAGE_KEY) === BASE44_THEME ? BASE44_THEME : SITE_THEME;
|
|
200
|
+
} catch {
|
|
201
|
+
return SITE_THEME;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Remember the choice for next time; a blocked localStorage is not worth failing over. */
|
|
206
|
+
export function storeTheme(theme) {
|
|
207
|
+
try {
|
|
208
|
+
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
209
|
+
} catch {
|
|
210
|
+
/* private mode / storage disabled — the theme still applies for this session */
|
|
211
|
+
}
|
|
212
|
+
}
|