@base44/app-plugin-commerce 0.9.0 → 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.
@@ -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/02-storefront.md covers the happy path."
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/02-storefront.md`](../install/02-storefront.md)); §5's rules govern selector *behaviour*, never its form.
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 ([`../install/01-install.md`](../install/01-install.md)).
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/03-data.md."
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/03-data.md`](../install/03-data.md). Everything below assumes that decision is made.
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/03-data.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 ../install/03-data.md already expresses the requirement."
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/03-data.md`](../install/03-data.md)); editing the entity afterwards is the day-2 route (last section).
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/03-data.md`](../install/03-data.md)). |
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 `install/01-install.md`; the map for everything else is
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 `install/01-install.md`.
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/03-data.md.
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
- <BasePathProvider value={basePath}>
50
- <Toaster richColors position="top-right" />
51
- <AuthGuard>
52
- <SettingsProvider>
53
- <AdminLayout>{outlet ?? <AdminRoutes />}</AdminLayout>
54
- </SettingsProvider>
55
- </AuthGuard>
56
- </BasePathProvider>
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 install/01-install.md.
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
+ }