restaurant-kit 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 +488 -0
- package/dist/RestaurantSiteRoutes.js +23 -0
- package/dist/admin/AdminActionMenu.js +29 -0
- package/dist/admin/AdminAuthPanel.js +53 -0
- package/dist/admin/AdminBackupsDialog.js +54 -0
- package/dist/admin/AdminDraftProvider.js +10 -0
- package/dist/admin/AdminGroupsPanel.js +68 -0
- package/dist/admin/AdminItemDrawer.js +264 -0
- package/dist/admin/AdminMenuCard.js +63 -0
- package/dist/admin/AdminMenuEditor.module.css.js +209 -0
- package/dist/admin/AdminMenuGroupTabs.js +43 -0
- package/dist/admin/AdminMenuIconPicker.js +39 -0
- package/dist/admin/AdminRoutes.js +13 -0
- package/dist/admin/AdminSectionEditor.js +104 -0
- package/dist/admin/AdminTestimonialsPanel.js +214 -0
- package/dist/admin/AdminTestimonialsPanel.module.css.js +29 -0
- package/dist/admin/adminDraftContext.js +5 -0
- package/dist/admin/adminEditorUtils.js +47 -0
- package/dist/admin/adminMenuCommands.js +83 -0
- package/dist/admin/useAdminDraft.js +14 -0
- package/dist/admin/useAdminMenuDraft.js +226 -0
- package/dist/cart/CartDrawer.js +87 -0
- package/dist/cart/CartDrawer.module.css.js +47 -0
- package/dist/cart/CartProvider.js +31 -0
- package/dist/cart/cartContext.js +5 -0
- package/dist/cart/cartOrder.js +34 -0
- package/dist/cart/cartReducer.js +57 -0
- package/dist/cart/useCart.js +10 -0
- package/dist/components/Brand.js +16 -0
- package/dist/components/Brand.module.css.js +15 -0
- package/dist/components/Footer.js +19 -0
- package/dist/components/Footer.module.css.js +11 -0
- package/dist/components/Header.js +34 -0
- package/dist/components/Header.module.css.js +19 -0
- package/dist/components/Hero.js +23 -0
- package/dist/components/Hero.module.css.js +15 -0
- package/dist/components/InstagramIcon.js +27 -0
- package/dist/components/LocationsSection.js +34 -0
- package/dist/components/LocationsSection.module.css.js +17 -0
- package/dist/components/Marquee.js +10 -0
- package/dist/components/Marquee.module.css.js +9 -0
- package/dist/components/OrderIcon.js +11 -0
- package/dist/components/RatingSection.js +32 -0
- package/dist/components/RatingSection.module.css.js +19 -0
- package/dist/components/TestimonialsSection.js +73 -0
- package/dist/components/TestimonialsSection.module.css.js +37 -0
- package/dist/components/WhatsAppIcon.js +13 -0
- package/dist/config/RestaurantKitProvider.js +26 -0
- package/dist/config/siteConfigContext.js +23 -0
- package/dist/config/useOrdering.js +12 -0
- package/dist/index.js +92 -0
- package/dist/menu/localMenuApi.js +43 -0
- package/dist/menu/menuApi.js +121 -0
- package/dist/menu/menuDraftStorage.js +35 -0
- package/dist/menu/menuIcons.js +24 -0
- package/dist/menu/menuMutations.js +99 -0
- package/dist/menu/menuOrdering.js +94 -0
- package/dist/menu/menuSchema.js +4 -0
- package/dist/menu/priceFormat.js +39 -0
- package/dist/menu/useMenuData.js +38 -0
- package/dist/menu/validateMenu.js +63 -0
- package/dist/menu-browser/MenuBrowser.js +54 -0
- package/dist/menu-browser/MenuBrowser.module.css.js +153 -0
- package/dist/menu-browser/MenuCard.js +106 -0
- package/dist/menu-browser/MenuCartDrawer.js +18 -0
- package/dist/menu-browser/MenuCategoryBlock.js +45 -0
- package/dist/menu-browser/MenuItemSheet.js +132 -0
- package/dist/menu-browser/MenuLightbox.js +33 -0
- package/dist/menu-browser/MenuRow.js +43 -0
- package/dist/menu-browser/MenuSection.js +10 -0
- package/dist/menu-browser/menuItemDisplay.js +19 -0
- package/dist/menu-browser/useMediaQuery.js +19 -0
- package/dist/menu-browser/useMenuBrowserState.js +82 -0
- package/dist/menu-browser/useParkedSheet.js +32 -0
- package/dist/pages/AdminMenuEditor.js +291 -0
- package/dist/pages/AdminPreview.js +43 -0
- package/dist/pages/AdminPreview.module.css.js +9 -0
- package/dist/pages/MenuSite.js +40 -0
- package/dist/restaurant-kit.css +1 -0
- package/dist/testimonials/downscaleImage.js +14 -0
- package/dist/testimonials/testimonialMutations.js +52 -0
- package/dist/types/RestaurantSiteRoutes.d.ts +11 -0
- package/dist/types/admin/AdminActionMenu.d.ts +20 -0
- package/dist/types/admin/AdminAuthPanel.d.ts +13 -0
- package/dist/types/admin/AdminBackupsDialog.d.ts +9 -0
- package/dist/types/admin/AdminDraftProvider.d.ts +12 -0
- package/dist/types/admin/AdminGroupsPanel.d.ts +14 -0
- package/dist/types/admin/AdminItemDrawer.d.ts +16 -0
- package/dist/types/admin/AdminMenuCard.d.ts +10 -0
- package/dist/types/admin/AdminMenuGroupTabs.d.ts +11 -0
- package/dist/types/admin/AdminMenuIconPicker.d.ts +8 -0
- package/dist/types/admin/AdminRoutes.d.ts +5 -0
- package/dist/types/admin/AdminSectionEditor.d.ts +20 -0
- package/dist/types/admin/AdminTestimonialsPanel.d.ts +7 -0
- package/dist/types/admin/adminDraftContext.d.ts +2 -0
- package/dist/types/admin/adminEditorUtils.d.ts +22 -0
- package/dist/types/admin/adminMenuCommands.d.ts +73 -0
- package/dist/types/admin/useAdminDraft.d.ts +5 -0
- package/dist/types/admin/useAdminMenuDraft.d.ts +41 -0
- package/dist/types/cart/CartDrawer.d.ts +7 -0
- package/dist/types/cart/CartProvider.d.ts +4 -0
- package/dist/types/cart/cartContext.d.ts +2 -0
- package/dist/types/cart/cartOrder.d.ts +9 -0
- package/dist/types/cart/cartReducer.d.ts +36 -0
- package/dist/types/cart/cartTypes.d.ts +37 -0
- package/dist/types/cart/useCart.d.ts +1 -0
- package/dist/types/components/Brand.d.ts +5 -0
- package/dist/types/components/Footer.d.ts +1 -0
- package/dist/types/components/Header.d.ts +1 -0
- package/dist/types/components/Hero.d.ts +1 -0
- package/dist/types/components/InstagramIcon.d.ts +6 -0
- package/dist/types/components/LocationsSection.d.ts +1 -0
- package/dist/types/components/Marquee.d.ts +1 -0
- package/dist/types/components/OrderIcon.d.ts +4 -0
- package/dist/types/components/RatingSection.d.ts +1 -0
- package/dist/types/components/TestimonialsSection.d.ts +2 -0
- package/dist/types/components/WhatsAppIcon.d.ts +5 -0
- package/dist/types/config/RestaurantKitProvider.d.ts +24 -0
- package/dist/types/config/siteConfigContext.d.ts +28 -0
- package/dist/types/config/siteTypes.d.ts +50 -0
- package/dist/types/config/useOrdering.d.ts +16 -0
- package/dist/types/index.d.ts +38 -0
- package/dist/types/menu/localMenuApi.d.ts +9 -0
- package/dist/types/menu/menuApi.d.ts +68 -0
- package/dist/types/menu/menuDraftStorage.d.ts +5 -0
- package/dist/types/menu/menuIcons.d.ts +7 -0
- package/dist/types/menu/menuMutations.d.ts +13 -0
- package/dist/types/menu/menuOrdering.d.ts +38 -0
- package/dist/types/menu/menuSchema.d.ts +68 -0
- package/dist/types/menu/priceFormat.d.ts +8 -0
- package/dist/types/menu/useMenuData.d.ts +12 -0
- package/dist/types/menu/validateMenu.d.ts +16 -0
- package/dist/types/menu-browser/MenuBrowser.d.ts +6 -0
- package/dist/types/menu-browser/MenuCard.d.ts +7 -0
- package/dist/types/menu-browser/MenuCartDrawer.d.ts +5 -0
- package/dist/types/menu-browser/MenuCategoryBlock.d.ts +11 -0
- package/dist/types/menu-browser/MenuItemSheet.d.ts +10 -0
- package/dist/types/menu-browser/MenuLightbox.d.ts +9 -0
- package/dist/types/menu-browser/MenuRow.d.ts +7 -0
- package/dist/types/menu-browser/MenuSection.d.ts +1 -0
- package/dist/types/menu-browser/menuItemDisplay.d.ts +5 -0
- package/dist/types/menu-browser/useMediaQuery.d.ts +1 -0
- package/dist/types/menu-browser/useMenuBrowserState.d.ts +17 -0
- package/dist/types/menu-browser/useParkedSheet.d.ts +13 -0
- package/dist/types/pages/AdminMenuEditor.d.ts +1 -0
- package/dist/types/pages/AdminPreview.d.ts +1 -0
- package/dist/types/pages/MenuSite.d.ts +6 -0
- package/dist/types/testimonials/downscaleImage.d.ts +6 -0
- package/dist/types/testimonials/testimonialMutations.d.ts +9 -0
- package/dist/types/utils.d.ts +1 -0
- package/dist/types/worker/auth.d.ts +97 -0
- package/dist/types/worker/http.d.ts +17 -0
- package/dist/types/worker/index.d.ts +16 -0
- package/dist/types/worker/menuStorage.d.ts +94 -0
- package/dist/utils.js +6 -0
- package/dist/worker/auth.js +183 -0
- package/dist/worker/http.js +65 -0
- package/dist/worker/menuStorage.js +95 -0
- package/dist/worker.js +131 -0
- package/package.json +105 -0
- package/scripts/bootstrap-site.mjs +125 -0
- package/scripts/hash-admin-password.mjs +11 -0
- package/scripts/lib/password.mjs +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hadi Al Hassan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
# restaurant-kit
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/restaurant-kit)
|
|
4
|
+
[](https://github.com/HadiAlHassan/restaurant-kit/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
A complete website for a small restaurant, as one React package and one Cloudflare Worker:
|
|
8
|
+
|
|
9
|
+
- **Public site**: hero, browsable menu with groups, sections, sizes, and photos, ratings and
|
|
10
|
+
customer reviews, locations, and contact links.
|
|
11
|
+
- **Ordering**: a cart that sends the order as a WhatsApp message, or, with no cart, buttons
|
|
12
|
+
that link to a delivery app (Toters, Talabat, the restaurant's own).
|
|
13
|
+
- **Owner admin at `/admin`**: edit the menu, drag to reorder, upload and crop photos, preview,
|
|
14
|
+
publish, and roll back. Protected by a password; no third-party auth service.
|
|
15
|
+
- **Worker API**: stores the draft, the published menu, backups, and images in R2. It fits the
|
|
16
|
+
Workers Free plan.
|
|
17
|
+
|
|
18
|
+
Built for and running on real restaurant sites. The owner can edit prices without calling a
|
|
19
|
+
developer, and hosting costs nothing.
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
browser ──► Cloudflare Worker ──┬── static assets (your Vite build)
|
|
23
|
+
└── /api/* ──► R2: restaurants/<id>/
|
|
24
|
+
├─ draft/menu.json
|
|
25
|
+
├─ published/menu.json
|
|
26
|
+
├─ backups/published-<time>.json
|
|
27
|
+
└─ images/menu/*
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install restaurant-kit react react-dom react-router-dom
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Peer dependencies: `react@^19.1`, `react-dom@^19.1`, `react-router-dom@^7.18`. Node 20+.
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
// src/main.tsx
|
|
42
|
+
import { createRoot } from "react-dom/client";
|
|
43
|
+
import { BrowserRouter } from "react-router-dom";
|
|
44
|
+
import { RestaurantSite, type DynamicMenu, type RestaurantSiteConfig } from "restaurant-kit";
|
|
45
|
+
import "restaurant-kit/styles.css";
|
|
46
|
+
|
|
47
|
+
const config: RestaurantSiteConfig = {
|
|
48
|
+
restaurantId: "demo", // must match the Worker's RESTAURANT_ID
|
|
49
|
+
brandName: "Demo Grill",
|
|
50
|
+
tagline: "Savor The Flavor",
|
|
51
|
+
locationLabel: "Demo City",
|
|
52
|
+
address: "1 Demo Street",
|
|
53
|
+
mapsUrl: "https://maps.google.com/?q=Demo+Grill",
|
|
54
|
+
phoneDisplay: "+1 555 000 0000",
|
|
55
|
+
phoneHref: "tel:+15550000000",
|
|
56
|
+
whatsappNumber: "15550000000",
|
|
57
|
+
orderGreeting: "Hi Demo Grill, I'd like to order:",
|
|
58
|
+
instagramHandle: "@demogrill",
|
|
59
|
+
instagramUrl: "https://www.instagram.com/demogrill/",
|
|
60
|
+
logoSrc: "/logo.jpg",
|
|
61
|
+
rating: "4.7",
|
|
62
|
+
ratingLabel: "4.7 out of 5 stars",
|
|
63
|
+
reviewCount: "320 reviews",
|
|
64
|
+
cuisineSummary: "Burgers & wraps",
|
|
65
|
+
localBadge: "Local favorite",
|
|
66
|
+
highlights: ["Burgers", "Wraps", "Sides"],
|
|
67
|
+
heroEyebrow: "Demo City / 1 Demo Street",
|
|
68
|
+
heroSubline: "Delicious taste with every bite.",
|
|
69
|
+
ratingHeadline: "Trusted for flavor.",
|
|
70
|
+
ratingCopy: "Average rating from customers across Google reviews.",
|
|
71
|
+
footerNote: "Demo City · 1 Demo Street",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Shown until the owner publishes from /admin, and used as the local-dev menu.
|
|
75
|
+
const seedMenu: DynamicMenu = {
|
|
76
|
+
schemaVersion: 1,
|
|
77
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
78
|
+
restaurant: { id: "demo", name: "Demo Grill", tagline: "Savor The Flavor", phone: "+1 555 000 0000", whatsapp: "15550000000", address: "1 Demo Street" },
|
|
79
|
+
groups: [{ id: "mains", label: "Mains", icon: "burger", order: 0, isVisible: true }],
|
|
80
|
+
categories: [{ id: "burgers", groupId: "mains", title: "Burgers", order: 0, isVisible: true }],
|
|
81
|
+
items: [
|
|
82
|
+
{
|
|
83
|
+
id: "classic", categoryId: "burgers", title: "Classic Burger", description: "Beef, cheddar, pickles.",
|
|
84
|
+
image: "/menu/classic.jpg", order: 0, isVisible: true, pricingMode: "single", price: "8", sizes: [],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
createRoot(document.getElementById("root")!).render(
|
|
90
|
+
<BrowserRouter>
|
|
91
|
+
<RestaurantSite
|
|
92
|
+
config={config}
|
|
93
|
+
seedMenu={seedMenu}
|
|
94
|
+
api={{ baseUrl: import.meta.env.VITE_MENU_API_BASE_URL, publicMenuUrl: import.meta.env.VITE_MENU_API_URL }}
|
|
95
|
+
/>
|
|
96
|
+
</BrowserRouter>,
|
|
97
|
+
);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
// worker/index.ts
|
|
102
|
+
export { default } from "restaurant-kit/worker";
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Run `vite` and open `/admin`. On `localhost` the kit uses a browser-local API that stores
|
|
106
|
+
drafts in `localStorage`, so you can try the whole editor before you deploy anything. To go
|
|
107
|
+
live, follow [Deploy as one Worker](#deploy-as-one-worker-recommended).
|
|
108
|
+
|
|
109
|
+
`RestaurantSite` mounts the toaster and these routes: `/` (public site),
|
|
110
|
+
`/admin` (menu editor), `/admin/preview`, and a catch-all redirect to `/`.
|
|
111
|
+
Bring your own `BrowserRouter` so the kit can live inside a larger app. The admin routes are
|
|
112
|
+
lazy-loaded: customers get about 110 kB of gzipped JS, and the editor's chunk is fetched only
|
|
113
|
+
when someone opens `/admin`.
|
|
114
|
+
|
|
115
|
+
### Validating seed data
|
|
116
|
+
|
|
117
|
+
At runtime the kit tolerates orphans: it keeps a category whose group was deleted and hides
|
|
118
|
+
an item whose category is gone. To catch those mistakes early, check the seed menu in a test
|
|
119
|
+
or build step:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { assertValidMenu, validateMenu } from "restaurant-kit";
|
|
123
|
+
|
|
124
|
+
assertValidMenu(seedMenu); // throws, listing every issue (duplicate ids, orphan references, bad icons, …)
|
|
125
|
+
validateMenu(seedMenu); // or get the issues as [{ path, message }]
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Composing your own routes
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
import { RestaurantKitProvider, MenuSite, AdminMenuEditor } from "restaurant-kit";
|
|
132
|
+
|
|
133
|
+
<RestaurantKitProvider config={config} seedMenu={seedMenu}>
|
|
134
|
+
<Routes>
|
|
135
|
+
<Route path="/" element={<MenuSite />} />
|
|
136
|
+
<Route path="/owner" element={<AdminMenuEditor />} />
|
|
137
|
+
</Routes>
|
|
138
|
+
</RestaurantKitProvider>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Individual sections (`Header`, `Hero`, `Marquee`, `MenuBrowser`, `RatingSection`,
|
|
142
|
+
`TestimonialsSection`, `LocationsSection`, `Footer`) are exported if you want a custom page layout.
|
|
143
|
+
|
|
144
|
+
When consuming the kit through `file:` or `npm link`, dedupe React so the linked package
|
|
145
|
+
doesn't bring its own copy:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// vite.config.ts
|
|
149
|
+
export default defineConfig({
|
|
150
|
+
plugins: [react()],
|
|
151
|
+
resolve: { dedupe: ["react", "react-dom", "react-router-dom"] },
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Configuration
|
|
156
|
+
|
|
157
|
+
`RestaurantSiteConfig` — every field is required except `ordering`:
|
|
158
|
+
|
|
159
|
+
| Field | Purpose |
|
|
160
|
+
| --- | --- |
|
|
161
|
+
| `restaurantId` | Namespaces the local draft key (`<id>:menu-draft:v1`); must match the worker's `RESTAURANT_ID` |
|
|
162
|
+
| `brandName`, `tagline`, `cuisineSummary`, `localBadge` | Branding copy |
|
|
163
|
+
| `locationLabel`, `address`, `mapsUrl` | Locations section |
|
|
164
|
+
| `phoneDisplay`, `phoneHref` | Call button |
|
|
165
|
+
| `whatsappNumber`, `orderGreeting` | WhatsApp order link (number in international form, no `+`). Set `whatsappNumber: ""` to hide every WhatsApp link |
|
|
166
|
+
| `instagramHandle`, `instagramUrl` | Social links |
|
|
167
|
+
| `logoSrc` | Header/footer logo URL |
|
|
168
|
+
| `rating`, `ratingLabel`, `reviewCount` | Rating badge |
|
|
169
|
+
| `highlights` | Marquee strip entries |
|
|
170
|
+
| `heroEyebrow`, `heroSubline` | Hero copy |
|
|
171
|
+
| `ratingHeadline`, `ratingCopy` | Rating section copy |
|
|
172
|
+
| `footerNote` | Footer line under the address |
|
|
173
|
+
| `ordering` | Optional. How customers order — see [Ordering modes](#ordering-modes) |
|
|
174
|
+
|
|
175
|
+
### Testimonials
|
|
176
|
+
|
|
177
|
+
An optional section of owner-curated customer reviews (typically copied from Google Maps, with
|
|
178
|
+
the photo the customer posted). It is **data, not config**: reviews live in the menu JSON as
|
|
179
|
+
`testimonials`, so they ride the same draft → publish → backup flow as the menu, and the owner
|
|
180
|
+
manages them under **Reviews** in `/admin` (add, edit, star rating, photo upload, link to the
|
|
181
|
+
original review, show/hide, reorder). Photos are downscaled to 1400px JPEG in the browser
|
|
182
|
+
before upload.
|
|
183
|
+
|
|
184
|
+
`MenuSite` has one "what customers say" slot. With no visible testimonial it shows the plain
|
|
185
|
+
`<RatingSection />` exactly as before; once a testimonial is visible and has text,
|
|
186
|
+
`<TestimonialsSection />` takes the slot (and the `#restaurant` anchor) and folds the score —
|
|
187
|
+
`rating`, `reviewCount`, `ratingCopy` — into its header above a carousel of review cards. Copy is optional config:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
testimonialsEyebrow: "Straight from Google Maps", // small label above; omitted by default
|
|
191
|
+
testimonialsHeadline: "Our customers' cameras don't lie.", // default
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
There is no Google Places integration on purpose: the API needs a billed key, returns at most
|
|
195
|
+
five reviews, and its terms forbid storing them. Only add reviews that are already public, and
|
|
196
|
+
keep the link back to the original.
|
|
197
|
+
|
|
198
|
+
### Ordering modes
|
|
199
|
+
|
|
200
|
+
Not every restaurant takes orders over WhatsApp. `ordering` switches the whole cart feature:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
// Default (omit the field): cart + "Add to cart" everywhere, order sent as a WhatsApp message.
|
|
204
|
+
ordering: { mode: "whatsapp" }
|
|
205
|
+
|
|
206
|
+
// Cart off. Browse-only menu; every order button opens the delivery app / site instead.
|
|
207
|
+
ordering: { mode: "external", url: "https://example.com/order", label: "Order on Toters", shortLabel: "Order", iconSrc: "/assets/brand/toters-mark.svg" }
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
| | `whatsapp` | `external` |
|
|
211
|
+
| --- | --- | --- |
|
|
212
|
+
| Header pill, hero button | `wa.me/<whatsappNumber>` | `url`, labelled `shortLabel` / `label` |
|
|
213
|
+
| Menu cards and rows | "Add to cart" + quantity stepper | no cart controls; tap opens a detail sheet |
|
|
214
|
+
| Item sheet | sizes, remove ingredients, note, add to cart | photo, description, sizes and prices, one `label` button to `url` |
|
|
215
|
+
| Cart drawer + floating cart button | mounted | not rendered |
|
|
216
|
+
|
|
217
|
+
The external buttons take the app's brand from two tokens (default: your primary color) and show
|
|
218
|
+
`iconSrc` inside the button, or an arrow when it is omitted:
|
|
219
|
+
|
|
220
|
+
```css
|
|
221
|
+
:root {
|
|
222
|
+
--restaurant-order-bg: #00b393; /* Toters green */
|
|
223
|
+
--restaurant-order-text: #ffffff;
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
In `external` mode `orderGreeting` is unused, and removable ingredients stay in the menu data
|
|
228
|
+
(and the admin editor) but are not shown to customers. The contact card keeps its
|
|
229
|
+
"Message on WhatsApp" link while `whatsappNumber` is set — a restaurant can order through an
|
|
230
|
+
app and still chat on WhatsApp; set it to `""` to drop WhatsApp entirely.
|
|
231
|
+
|
|
232
|
+
`useOrdering()` returns the resolved `{ cartEnabled, isWhatsApp, href, label, shortLabel }` for
|
|
233
|
+
custom layouts. When composing your own page, skip `<MenuCartDrawer />` when `cartEnabled` is false.
|
|
234
|
+
|
|
235
|
+
### Admin draft across routes
|
|
236
|
+
|
|
237
|
+
`RestaurantSite` mounts an `AdminDraftProvider` above `/admin` and `/admin/preview`, so the
|
|
238
|
+
draft (unsaved edits included) survives navigating between editor and preview, and the
|
|
239
|
+
browser warns before a reload with unsaved work. When composing your own routes, wrap both
|
|
240
|
+
admin routes in `<AdminDraftProvider>`; `AdminMenuEditor` falls back to a local draft when
|
|
241
|
+
no provider is mounted.
|
|
242
|
+
|
|
243
|
+
All restaurant identity flows through `RestaurantKitProvider`: config, seed menu, the API
|
|
244
|
+
client (remote, or the browser-local adapter on `localhost`), and the draft storage key.
|
|
245
|
+
Nothing is read from module scope, so two providers on one page cannot bleed into each other.
|
|
246
|
+
`useRestaurantKit()`, `useSiteConfig()`, `useSeedMenu()`, `useMenuApi()` read from the
|
|
247
|
+
nearest provider and throw outside one.
|
|
248
|
+
|
|
249
|
+
## Theming
|
|
250
|
+
|
|
251
|
+
All colors are CSS custom properties on `:root`, prefixed `--restaurant-`. Override any
|
|
252
|
+
of them after importing `restaurant-kit/styles.css`. The hero background image has no
|
|
253
|
+
default — set it or the hero renders on the flat background color. The kit blurs and dims it
|
|
254
|
+
as a backdrop; for a real photo worth showing, relax `--restaurant-hero-filter` (and the
|
|
255
|
+
`--restaurant-hero-overlay-*` gradients), and aim it with `--restaurant-hero-position`:
|
|
256
|
+
|
|
257
|
+
```css
|
|
258
|
+
:root {
|
|
259
|
+
--restaurant-hero-image: url("/hero.jpg");
|
|
260
|
+
--restaurant-hero-filter: blur(1px) brightness(0.9); /* default: blur(7px) saturate(0.88) brightness(0.78) */
|
|
261
|
+
--restaurant-hero-position: center 60%; /* default: center */
|
|
262
|
+
--restaurant-primary: #4c602b;
|
|
263
|
+
--restaurant-accent: #08743a;
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Notable groups: surfaces (`--restaurant-bg`, `--restaurant-surface*`), text
|
|
268
|
+
(`--restaurant-text*`, `--restaurant-muted`), brand (`--restaurant-primary*`,
|
|
269
|
+
`--restaurant-accent*`, `--restaurant-on-primary` for text on a primary fill — override it when
|
|
270
|
+
the primary is a light color), hero overlays (`--restaurant-hero-overlay-*`), state
|
|
271
|
+
(`--restaurant-danger*`, `--restaurant-warning-*`), and WhatsApp
|
|
272
|
+
(`--restaurant-whatsapp*`).
|
|
273
|
+
|
|
274
|
+
## Worker
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
// worker/src/index.ts
|
|
278
|
+
export { default } from "restaurant-kit/worker";
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Deploy as one Worker (recommended)
|
|
282
|
+
|
|
283
|
+
Serve the built site and the API from a single Worker with static assets. There is no
|
|
284
|
+
Pages-vs-Worker route precedence to get wrong, `/api/*` is same-origin with `/admin`, and
|
|
285
|
+
the whole thing fits the Workers Free plan (static asset requests are not billed as
|
|
286
|
+
invocations).
|
|
287
|
+
|
|
288
|
+
```toml
|
|
289
|
+
# wrangler.toml (site root)
|
|
290
|
+
name = "demo-restaurant"
|
|
291
|
+
main = "worker/src/index.ts"
|
|
292
|
+
compatibility_date = "2026-07-02"
|
|
293
|
+
workers_dev = false
|
|
294
|
+
preview_urls = false
|
|
295
|
+
|
|
296
|
+
[assets]
|
|
297
|
+
directory = "./dist"
|
|
298
|
+
binding = "ASSETS"
|
|
299
|
+
not_found_handling = "single-page-application"
|
|
300
|
+
run_worker_first = ["/api/*"]
|
|
301
|
+
|
|
302
|
+
routes = [
|
|
303
|
+
{ pattern = "demo-restaurant.com", custom_domain = true },
|
|
304
|
+
{ pattern = "www.demo-restaurant.com", custom_domain = true },
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
[[r2_buckets]]
|
|
308
|
+
binding = "MENU_BUCKET"
|
|
309
|
+
bucket_name = "demo-restaurant-menu"
|
|
310
|
+
|
|
311
|
+
[vars]
|
|
312
|
+
RESTAURANT_ID = "demo"
|
|
313
|
+
ALLOWED_ORIGINS = "http://localhost:5173,https://demo-restaurant.com,https://www.demo-restaurant.com"
|
|
314
|
+
|
|
315
|
+
# Optional but recommended: throttle password guesses per IP (5 per minute).
|
|
316
|
+
[[ratelimits]]
|
|
317
|
+
name = "LOGIN_RATE_LIMITER"
|
|
318
|
+
namespace_id = "1001"
|
|
319
|
+
simple = { limit = 5, period = 60 }
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Then `vite build && wrangler deploy --env=""`. Secrets are set once, never in the toml:
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
npx restaurant-kit-hash-password # prints the ADMIN_PASSWORD_HASH value
|
|
326
|
+
wrangler secret put ADMIN_PASSWORD_HASH
|
|
327
|
+
openssl rand -base64 32 | wrangler secret put ADMIN_SESSION_SECRET
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
If your wrangler version rejects `[[ratelimits]]`, the older spelling is
|
|
331
|
+
`[[unsafe.bindings]]` with `type = "ratelimit"` and the same fields. Without the binding
|
|
332
|
+
login still works, just unthrottled.
|
|
333
|
+
|
|
334
|
+
### Bindings and vars
|
|
335
|
+
|
|
336
|
+
| Name | Type | Notes |
|
|
337
|
+
| --- | --- | --- |
|
|
338
|
+
| `MENU_BUCKET` | R2 bucket | Stores draft/published menu JSON and uploaded images |
|
|
339
|
+
| `ASSETS` | assets | Optional. When present, every non-`/api/` request is served from static assets |
|
|
340
|
+
| `RESTAURANT_ID` | var | Key prefix in R2 |
|
|
341
|
+
| `ALLOWED_ORIGINS` | var | Comma-separated origins for CORS and the CSRF origin check; the first non-staging `https://` origin is the default admin redirect target |
|
|
342
|
+
| `ADMIN_PASSWORD_HASH` | secret | Enables the **password** strategy. Output of `restaurant-kit-hash-password` |
|
|
343
|
+
| `ADMIN_SESSION_SECRET` | secret | HMAC key for session cookies (min 16 chars; 32 random bytes recommended) |
|
|
344
|
+
| `ADMIN_SESSION_TTL_HOURS` | var | Optional session lifetime, default 336 (14 days) |
|
|
345
|
+
| `LOGIN_RATE_LIMITER` | rate limit | Optional. Login attempts per client IP |
|
|
346
|
+
| `ADMIN_EMAILS` | var | Enables the legacy **access** strategy (Cloudflare Access emails) |
|
|
347
|
+
| `ADMIN_AUTH` | var | Optional override: `password`, `access`, or `none` |
|
|
348
|
+
|
|
349
|
+
### Admin auth strategies
|
|
350
|
+
|
|
351
|
+
The Worker picks a strategy from its env: `ADMIN_AUTH` if set, else `password` when
|
|
352
|
+
`ADMIN_PASSWORD_HASH` exists, else `access` when `ADMIN_EMAILS` is non-empty, else `none`
|
|
353
|
+
(every admin request is denied). `GET /api/admin/session` reports the active strategy and
|
|
354
|
+
the editor renders the matching sign-in UI.
|
|
355
|
+
|
|
356
|
+
**password** (default for new sites): the owner enters a password on `/admin`; the Worker
|
|
357
|
+
verifies it against the PBKDF2-SHA256 hash and sets an `HttpOnly; SameSite=Lax; Secure`
|
|
358
|
+
cookie (`rk_admin_session`) holding an HMAC-signed token. No Zero Trust, no Access
|
|
359
|
+
applications, no extra Cloudflare product. See
|
|
360
|
+
[Changing the admin password](#changing-the-admin-password) for rotation.
|
|
361
|
+
|
|
362
|
+
**access** (legacy): trusts the `cf-access-authenticated-user-email` header. Only safe when
|
|
363
|
+
a Cloudflare Access application fronts every route that reaches the Worker, which is why
|
|
364
|
+
`workers_dev = false` / `preview_urls = false` matter here. Keep it if you already pay for
|
|
365
|
+
or run Zero Trust; otherwise prefer password.
|
|
366
|
+
|
|
367
|
+
Every state-changing `/api/admin/*` request (login included) must carry an `Origin` equal
|
|
368
|
+
to the request origin or one of `ALLOWED_ORIGINS`; anything else is rejected with 403.
|
|
369
|
+
|
|
370
|
+
Routes: `GET /api/menu` (published), `GET /api/assets/*` (only keys under the restaurant's
|
|
371
|
+
image prefix; served with a content type from the image allowlist or as an opaque download),
|
|
372
|
+
`GET /api/admin/session`, `POST /api/admin/login`, `POST /api/admin/logout`,
|
|
373
|
+
`GET /api/admin/sign-in` (Access redirect), `GET|PUT /api/admin/menu/draft`,
|
|
374
|
+
`POST /api/admin/menu/publish`, `POST|DELETE /api/admin/images`.
|
|
375
|
+
|
|
376
|
+
`GET /api/admin/menu/backups` lists the copies written on each publish (newest first);
|
|
377
|
+
`POST /api/admin/menu/restore` with `{ source: "published" }` or `{ source: "backup", key }`
|
|
378
|
+
overwrites the draft from that copy. The editor exposes these as *Discard unsaved changes*,
|
|
379
|
+
*Restore last published*, and *Roll back to a backup…*; nothing goes live until you publish.
|
|
380
|
+
|
|
381
|
+
`GET /api/admin/menu/draft` returns the draft's `etag`; the editor sends it back as
|
|
382
|
+
`If-Match` on `PUT`, and the Worker answers `412` when the draft changed in between (two
|
|
383
|
+
tabs, two admins). Uploads accept JPEG/PNG/WebP/AVIF/GIF up to 5 MB (`415` / `413`
|
|
384
|
+
otherwise). Validation failures are JSON `4xx` responses; unexpected failures are a JSON
|
|
385
|
+
`500`.
|
|
386
|
+
|
|
387
|
+
In local dev the provider uses `createLocalMenuApiClient(seedMenu, storageKey)`, which
|
|
388
|
+
persists drafts to `localStorage` and reports the `local` strategy — no worker required.
|
|
389
|
+
To exercise the real Worker locally instead, set `VITE_MENU_API_BASE_URL` (and
|
|
390
|
+
`VITE_MENU_API_URL`) to the `wrangler dev` origin; that overrides the localhost rule.
|
|
391
|
+
|
|
392
|
+
The kit itself never reads `import.meta.env` (Vite would bake the library's empty env at
|
|
393
|
+
build time). Your site forwards its env through the `api` prop:
|
|
394
|
+
|
|
395
|
+
```tsx
|
|
396
|
+
<RestaurantSite
|
|
397
|
+
config={config}
|
|
398
|
+
seedMenu={seedMenu}
|
|
399
|
+
api={{ baseUrl: import.meta.env.VITE_MENU_API_BASE_URL, publicMenuUrl: import.meta.env.VITE_MENU_API_URL }}
|
|
400
|
+
/>
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
`api.mode` can force `"local"` or `"remote"` regardless of hostname.
|
|
404
|
+
`wrangler dev` reads its secrets from `.dev.vars` — generate one with
|
|
405
|
+
`npx restaurant-kit-bootstrap --dev-vars`.
|
|
406
|
+
|
|
407
|
+
### Environments
|
|
408
|
+
|
|
409
|
+
One `wrangler.toml` per site, with production at the top level and a `[env.staging]`
|
|
410
|
+
block that points at **its own bucket and secrets**. Staging never touches production data;
|
|
411
|
+
seed it once from production's published menu:
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
npx restaurant-kit-bootstrap --bucket <site>-menu-staging --env staging --seed-from <site>-menu
|
|
415
|
+
npx wrangler deploy --env staging
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
`restaurant-kit-bootstrap` creates the bucket, seeds it, prompts for the admin password and
|
|
419
|
+
stores both secrets on the right env, then dry-runs the deploy and prints the DNS checklist.
|
|
420
|
+
Same command without `--env` provisions a brand-new production site. Keep `preview_urls`
|
|
421
|
+
off: version previews inherit production bindings, so they would edit the production draft.
|
|
422
|
+
|
|
423
|
+
Once a `[env.staging]` block exists, a bare `wrangler deploy` warns that no target
|
|
424
|
+
environment was specified. Name production explicitly with an empty env — the site's
|
|
425
|
+
`deploy` script should read `wrangler deploy --env=""` (the bootstrap dry run does the same).
|
|
426
|
+
|
|
427
|
+
### Changing the admin password
|
|
428
|
+
|
|
429
|
+
There is no "forgot password" flow — the password only exists as a hash in the Worker's
|
|
430
|
+
secrets, so recovery and rotation are the same operation. Run from the site directory:
|
|
431
|
+
|
|
432
|
+
```bash
|
|
433
|
+
npx restaurant-kit-bootstrap --bucket <site>-menu --skip-bucket --skip-seed
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
It prompts for the new password (hidden input) and stores a fresh `ADMIN_PASSWORD_HASH`
|
|
437
|
+
**and** a fresh `ADMIN_SESSION_SECRET`. No redeploy: secrets apply within seconds. Add
|
|
438
|
+
`--env staging` for staging — each env has its own password.
|
|
439
|
+
|
|
440
|
+
To change only the password and keep the session secret:
|
|
441
|
+
|
|
442
|
+
```bash
|
|
443
|
+
npx restaurant-kit-hash-password # prints the hash
|
|
444
|
+
npx wrangler secret put ADMIN_PASSWORD_HASH --env="" # paste it; --env staging for staging
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Either way **every device is signed out**, because the session signing key is derived from
|
|
448
|
+
the session secret *and* the password hash. To sign everyone out without changing the
|
|
449
|
+
password, rotate only the session secret:
|
|
450
|
+
|
|
451
|
+
```bash
|
|
452
|
+
openssl rand -base64 32 | npx wrangler secret put ADMIN_SESSION_SECRET --env=""
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Picking the password: generate it (`openssl rand -base64 18`, or 4–5 random words if the
|
|
456
|
+
owner types it on a phone), at least 16 characters, unique per site, nothing derived from
|
|
457
|
+
the brand, phone number or handle. Keep a copy in a password manager and hand the owner
|
|
458
|
+
theirs out of band; it never belongs in the repo, `wrangler.toml` or chat logs. The login
|
|
459
|
+
rate limiter (5 attempts per IP per minute) slows guessing but does not excuse a weak one.
|
|
460
|
+
|
|
461
|
+
### Migrating a site off Cloudflare Access
|
|
462
|
+
|
|
463
|
+
1. Move to the single-Worker layout above (or keep your API Worker and just add the
|
|
464
|
+
secrets — the auth change is independent of the routing change).
|
|
465
|
+
2. `npx restaurant-kit-hash-password`, then `wrangler secret put ADMIN_PASSWORD_HASH` and
|
|
466
|
+
`wrangler secret put ADMIN_SESSION_SECRET`.
|
|
467
|
+
3. Remove `ADMIN_EMAILS` from `wrangler.toml` (or set `ADMIN_AUTH = "password"`).
|
|
468
|
+
4. Deploy. Verify `curl -s https://<site>/api/admin/session` returns
|
|
469
|
+
`{"authenticated":false,"strategy":"password","user":null}` — if you get HTML instead,
|
|
470
|
+
`/api/*` is not reaching the Worker.
|
|
471
|
+
5. Delete the Access applications for `/admin*` and `/api/admin/*` in Zero Trust.
|
|
472
|
+
Until you do, Access still gates `/admin` in front of the password form.
|
|
473
|
+
|
|
474
|
+
## Development
|
|
475
|
+
|
|
476
|
+
```bash
|
|
477
|
+
npm install --legacy-peer-deps # works around an npm arborist peer-set bug
|
|
478
|
+
npm run typecheck
|
|
479
|
+
npm run lint
|
|
480
|
+
npm test
|
|
481
|
+
npm run build # dist/index.js, dist/worker.js, dist/restaurant-kit.css, dist/types/
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
`npm run dev` runs the library build in watch mode for linked consumers.
|
|
485
|
+
|
|
486
|
+
## License
|
|
487
|
+
|
|
488
|
+
[MIT](./LICENSE) © Hadi Al Hassan
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsxs as r, jsx as e } from "react/jsx-runtime";
|
|
2
|
+
import { Suspense as l, lazy as o } from "react";
|
|
3
|
+
import { Routes as d, Route as n, Navigate as u } from "react-router-dom";
|
|
4
|
+
import { Toaster as p } from "sonner";
|
|
5
|
+
import { RestaurantKitProvider as s } from "./config/RestaurantKitProvider.js";
|
|
6
|
+
import { MenuSite as c } from "./pages/MenuSite.js";
|
|
7
|
+
const i = () => import("./admin/AdminRoutes.js"), h = o(() => i().then((t) => ({ default: t.AdminLayout }))), f = o(() => i().then((t) => ({ default: t.AdminMenuEditor }))), A = o(() => i().then((t) => ({ default: t.AdminPreview })));
|
|
8
|
+
function P({ config: t, seedMenu: m, api: a }) {
|
|
9
|
+
return /* @__PURE__ */ r(s, { config: t, seedMenu: m, api: a, children: [
|
|
10
|
+
/* @__PURE__ */ e(p, { closeButton: !0, richColors: !0, position: "top-center" }),
|
|
11
|
+
/* @__PURE__ */ e(l, { fallback: null, children: /* @__PURE__ */ r(d, { children: [
|
|
12
|
+
/* @__PURE__ */ e(n, { path: "/", element: /* @__PURE__ */ e(c, {}) }),
|
|
13
|
+
/* @__PURE__ */ r(n, { element: /* @__PURE__ */ e(h, {}), children: [
|
|
14
|
+
/* @__PURE__ */ e(n, { path: "/admin", element: /* @__PURE__ */ e(f, {}) }),
|
|
15
|
+
/* @__PURE__ */ e(n, { path: "/admin/preview", element: /* @__PURE__ */ e(A, {}) })
|
|
16
|
+
] }),
|
|
17
|
+
/* @__PURE__ */ e(n, { path: "*", element: /* @__PURE__ */ e(u, { to: "/", replace: !0 }) })
|
|
18
|
+
] }) })
|
|
19
|
+
] });
|
|
20
|
+
}
|
|
21
|
+
export {
|
|
22
|
+
P as RestaurantSite
|
|
23
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsxs as i, jsx as e } from "react/jsx-runtime";
|
|
2
|
+
import * as n from "@radix-ui/react-dropdown-menu";
|
|
3
|
+
import { MoreHorizontal as u } from "lucide-react";
|
|
4
|
+
import { cx as s } from "../utils.js";
|
|
5
|
+
import t from "./AdminMenuEditor.module.css.js";
|
|
6
|
+
function p({ children: r, label: o }) {
|
|
7
|
+
return /* @__PURE__ */ i(n.Root, { children: [
|
|
8
|
+
/* @__PURE__ */ e(n.Trigger, { asChild: !0, children: /* @__PURE__ */ e("button", { className: t.kebabButton, type: "button", "aria-label": o, children: /* @__PURE__ */ e(u, { "aria-hidden": "true" }) }) }),
|
|
9
|
+
/* @__PURE__ */ e(n.Portal, { children: /* @__PURE__ */ e(n.Content, { className: t.menuContent, align: "end", sideOffset: 8, children: r }) })
|
|
10
|
+
] });
|
|
11
|
+
}
|
|
12
|
+
function b({ children: r, disabled: o, isDanger: a, onSelect: m }) {
|
|
13
|
+
return /* @__PURE__ */ e(n.Item, { className: s(t.menuItem, a && t.menuItemDanger), disabled: o, onSelect: m, children: r });
|
|
14
|
+
}
|
|
15
|
+
function h() {
|
|
16
|
+
return /* @__PURE__ */ e(n.Separator, { className: t.menuSeparator });
|
|
17
|
+
}
|
|
18
|
+
function A({ children: r, label: o }) {
|
|
19
|
+
return /* @__PURE__ */ i(n.Sub, { children: [
|
|
20
|
+
/* @__PURE__ */ e(n.SubTrigger, { className: t.menuItem, children: o }),
|
|
21
|
+
/* @__PURE__ */ e(n.Portal, { children: /* @__PURE__ */ e(n.SubContent, { className: t.menuContent, sideOffset: 6, children: r }) })
|
|
22
|
+
] });
|
|
23
|
+
}
|
|
24
|
+
export {
|
|
25
|
+
b as AdminActionItem,
|
|
26
|
+
p as AdminActionMenu,
|
|
27
|
+
h as AdminActionSeparator,
|
|
28
|
+
A as AdminActionSubMenu
|
|
29
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsxs as n, jsx as e } from "react/jsx-runtime";
|
|
2
|
+
import { LogIn as d } from "lucide-react";
|
|
3
|
+
import { useState as w } from "react";
|
|
4
|
+
import t from "./AdminMenuEditor.module.css.js";
|
|
5
|
+
const A = {
|
|
6
|
+
password: { eyebrow: "Owner sign-in", title: "Sign in to edit the menu" },
|
|
7
|
+
access: { eyebrow: "Cloudflare Access", title: "Sign in to edit the remote menu" },
|
|
8
|
+
none: { eyebrow: "Admin sign-in", title: "Admin sign-in is not configured" },
|
|
9
|
+
local: { eyebrow: "Local draft", title: "Editing the local draft" },
|
|
10
|
+
unknown: { eyebrow: "Menu API", title: "Could not reach the menu API" }
|
|
11
|
+
};
|
|
12
|
+
function S({ strategy: i, isLoadingDraft: a, isSigningIn: r, message: c, onRetry: u, onSignInWithAccess: m, onSignInWithPassword: h }) {
|
|
13
|
+
const [o, p] = w(""), l = A[i ?? "unknown"], b = (s) => {
|
|
14
|
+
s.preventDefault(), o && h(o);
|
|
15
|
+
};
|
|
16
|
+
return /* @__PURE__ */ n("section", { className: t.authPanel, "aria-label": "Admin sign-in required", children: [
|
|
17
|
+
/* @__PURE__ */ n("div", { children: [
|
|
18
|
+
/* @__PURE__ */ e("p", { className: "micro-label", children: l.eyebrow }),
|
|
19
|
+
/* @__PURE__ */ e("h2", { children: l.title }),
|
|
20
|
+
/* @__PURE__ */ e("p", { children: i === "none" ? "The menu Worker has no ADMIN_PASSWORD_HASH (or ADMIN_EMAILS) configured, so every admin request is denied." : c })
|
|
21
|
+
] }),
|
|
22
|
+
i === "password" ? /* @__PURE__ */ n("form", { className: t.authForm, onSubmit: b, children: [
|
|
23
|
+
/* @__PURE__ */ n("label", { className: t.authField, children: [
|
|
24
|
+
/* @__PURE__ */ e("span", { children: "Password" }),
|
|
25
|
+
/* @__PURE__ */ e(
|
|
26
|
+
"input",
|
|
27
|
+
{
|
|
28
|
+
autoComplete: "current-password",
|
|
29
|
+
autoFocus: !0,
|
|
30
|
+
disabled: r,
|
|
31
|
+
name: "password",
|
|
32
|
+
type: "password",
|
|
33
|
+
value: o,
|
|
34
|
+
onChange: (s) => p(s.target.value)
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
] }),
|
|
38
|
+
/* @__PURE__ */ n("button", { className: t.primaryButton, type: "submit", disabled: r || !o, children: [
|
|
39
|
+
/* @__PURE__ */ e(d, { "aria-hidden": "true" }),
|
|
40
|
+
r ? "Signing in" : "Sign in"
|
|
41
|
+
] })
|
|
42
|
+
] }) : /* @__PURE__ */ n("div", { className: t.authActions, children: [
|
|
43
|
+
i === "access" ? /* @__PURE__ */ n("button", { className: t.primaryButton, type: "button", onClick: m, children: [
|
|
44
|
+
/* @__PURE__ */ e(d, { "aria-hidden": "true" }),
|
|
45
|
+
"Sign in with Cloudflare"
|
|
46
|
+
] }) : null,
|
|
47
|
+
/* @__PURE__ */ e("button", { className: t.secondaryButton, type: "button", onClick: u, disabled: a, children: a ? "Checking" : i === "access" ? "I signed in, retry" : "Retry" })
|
|
48
|
+
] })
|
|
49
|
+
] });
|
|
50
|
+
}
|
|
51
|
+
export {
|
|
52
|
+
S as AdminAuthPanel
|
|
53
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { jsxs as s, jsx as t } from "react/jsx-runtime";
|
|
2
|
+
import { X as g } from "lucide-react";
|
|
3
|
+
import { useRef as y, useState as u, useEffect as d } from "react";
|
|
4
|
+
import { getApiErrorMessage as k } from "./adminEditorUtils.js";
|
|
5
|
+
import r from "./AdminMenuEditor.module.css.js";
|
|
6
|
+
function h(i) {
|
|
7
|
+
const a = new Date(i);
|
|
8
|
+
return Number.isNaN(a.getTime()) ? i : a.toLocaleString(void 0, { dateStyle: "medium", timeStyle: "short" });
|
|
9
|
+
}
|
|
10
|
+
function N(i) {
|
|
11
|
+
return i >= 1024 ? `${Math.round(i / 1024)} KB` : `${i} B`;
|
|
12
|
+
}
|
|
13
|
+
function B({ listBackups: i, onClose: a, onRestore: m }) {
|
|
14
|
+
const l = y(null), [n, c] = u(null), [p, f] = u("");
|
|
15
|
+
d(() => {
|
|
16
|
+
const e = l.current;
|
|
17
|
+
e && !e.open && e.showModal();
|
|
18
|
+
}, []), d(() => {
|
|
19
|
+
let e = !0;
|
|
20
|
+
return i().then((o) => {
|
|
21
|
+
e && c(o);
|
|
22
|
+
}).catch((o) => {
|
|
23
|
+
e && (c([]), f(k(o)));
|
|
24
|
+
}), () => {
|
|
25
|
+
e = !1;
|
|
26
|
+
};
|
|
27
|
+
}, [i]);
|
|
28
|
+
const b = (e) => {
|
|
29
|
+
window.confirm(`Replace the draft with the menu as it was before the publish on ${h(e.publishedAt)}? Unsaved edits are lost.`) && m(e.key);
|
|
30
|
+
};
|
|
31
|
+
return /* @__PURE__ */ s("dialog", { className: r.backupsDialog, ref: l, "aria-labelledby": "backups-title", onClose: a, children: [
|
|
32
|
+
/* @__PURE__ */ s("div", { className: r.backupsHeader, children: [
|
|
33
|
+
/* @__PURE__ */ s("div", { children: [
|
|
34
|
+
/* @__PURE__ */ t("p", { className: "micro-label", children: "Publish history" }),
|
|
35
|
+
/* @__PURE__ */ t("h2", { id: "backups-title", children: "Roll back to a backup" }),
|
|
36
|
+
/* @__PURE__ */ t("p", { children: "Each publish keeps a copy of the menu it replaced. Restoring loads that copy into the draft; nothing goes live until you publish again." })
|
|
37
|
+
] }),
|
|
38
|
+
/* @__PURE__ */ t("button", { className: r.iconButton, type: "button", "aria-label": "Close", onClick: a, children: /* @__PURE__ */ t(g, { "aria-hidden": "true" }) })
|
|
39
|
+
] }),
|
|
40
|
+
n === null ? /* @__PURE__ */ t("p", { className: r.backupsEmpty, children: "Loading backups…" }) : n.length === 0 ? /* @__PURE__ */ t("p", { className: r.backupsEmpty, children: p || "No backups yet. One is written every time you publish over an existing menu." }) : /* @__PURE__ */ t("div", { className: r.backupsList, children: n.map((e) => /* @__PURE__ */ s("div", { className: r.backupRow, children: [
|
|
41
|
+
/* @__PURE__ */ s("div", { children: [
|
|
42
|
+
/* @__PURE__ */ t("strong", { children: h(e.publishedAt) }),
|
|
43
|
+
/* @__PURE__ */ s("small", { children: [
|
|
44
|
+
"Menu before this publish · ",
|
|
45
|
+
N(e.size)
|
|
46
|
+
] })
|
|
47
|
+
] }),
|
|
48
|
+
/* @__PURE__ */ t("button", { className: r.secondaryButton, type: "button", onClick: () => b(e), children: "Restore" })
|
|
49
|
+
] }, e.key)) })
|
|
50
|
+
] });
|
|
51
|
+
}
|
|
52
|
+
export {
|
|
53
|
+
B as AdminBackupsDialog
|
|
54
|
+
};
|