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.
Files changed (164) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +488 -0
  3. package/dist/RestaurantSiteRoutes.js +23 -0
  4. package/dist/admin/AdminActionMenu.js +29 -0
  5. package/dist/admin/AdminAuthPanel.js +53 -0
  6. package/dist/admin/AdminBackupsDialog.js +54 -0
  7. package/dist/admin/AdminDraftProvider.js +10 -0
  8. package/dist/admin/AdminGroupsPanel.js +68 -0
  9. package/dist/admin/AdminItemDrawer.js +264 -0
  10. package/dist/admin/AdminMenuCard.js +63 -0
  11. package/dist/admin/AdminMenuEditor.module.css.js +209 -0
  12. package/dist/admin/AdminMenuGroupTabs.js +43 -0
  13. package/dist/admin/AdminMenuIconPicker.js +39 -0
  14. package/dist/admin/AdminRoutes.js +13 -0
  15. package/dist/admin/AdminSectionEditor.js +104 -0
  16. package/dist/admin/AdminTestimonialsPanel.js +214 -0
  17. package/dist/admin/AdminTestimonialsPanel.module.css.js +29 -0
  18. package/dist/admin/adminDraftContext.js +5 -0
  19. package/dist/admin/adminEditorUtils.js +47 -0
  20. package/dist/admin/adminMenuCommands.js +83 -0
  21. package/dist/admin/useAdminDraft.js +14 -0
  22. package/dist/admin/useAdminMenuDraft.js +226 -0
  23. package/dist/cart/CartDrawer.js +87 -0
  24. package/dist/cart/CartDrawer.module.css.js +47 -0
  25. package/dist/cart/CartProvider.js +31 -0
  26. package/dist/cart/cartContext.js +5 -0
  27. package/dist/cart/cartOrder.js +34 -0
  28. package/dist/cart/cartReducer.js +57 -0
  29. package/dist/cart/useCart.js +10 -0
  30. package/dist/components/Brand.js +16 -0
  31. package/dist/components/Brand.module.css.js +15 -0
  32. package/dist/components/Footer.js +19 -0
  33. package/dist/components/Footer.module.css.js +11 -0
  34. package/dist/components/Header.js +34 -0
  35. package/dist/components/Header.module.css.js +19 -0
  36. package/dist/components/Hero.js +23 -0
  37. package/dist/components/Hero.module.css.js +15 -0
  38. package/dist/components/InstagramIcon.js +27 -0
  39. package/dist/components/LocationsSection.js +34 -0
  40. package/dist/components/LocationsSection.module.css.js +17 -0
  41. package/dist/components/Marquee.js +10 -0
  42. package/dist/components/Marquee.module.css.js +9 -0
  43. package/dist/components/OrderIcon.js +11 -0
  44. package/dist/components/RatingSection.js +32 -0
  45. package/dist/components/RatingSection.module.css.js +19 -0
  46. package/dist/components/TestimonialsSection.js +73 -0
  47. package/dist/components/TestimonialsSection.module.css.js +37 -0
  48. package/dist/components/WhatsAppIcon.js +13 -0
  49. package/dist/config/RestaurantKitProvider.js +26 -0
  50. package/dist/config/siteConfigContext.js +23 -0
  51. package/dist/config/useOrdering.js +12 -0
  52. package/dist/index.js +92 -0
  53. package/dist/menu/localMenuApi.js +43 -0
  54. package/dist/menu/menuApi.js +121 -0
  55. package/dist/menu/menuDraftStorage.js +35 -0
  56. package/dist/menu/menuIcons.js +24 -0
  57. package/dist/menu/menuMutations.js +99 -0
  58. package/dist/menu/menuOrdering.js +94 -0
  59. package/dist/menu/menuSchema.js +4 -0
  60. package/dist/menu/priceFormat.js +39 -0
  61. package/dist/menu/useMenuData.js +38 -0
  62. package/dist/menu/validateMenu.js +63 -0
  63. package/dist/menu-browser/MenuBrowser.js +54 -0
  64. package/dist/menu-browser/MenuBrowser.module.css.js +153 -0
  65. package/dist/menu-browser/MenuCard.js +106 -0
  66. package/dist/menu-browser/MenuCartDrawer.js +18 -0
  67. package/dist/menu-browser/MenuCategoryBlock.js +45 -0
  68. package/dist/menu-browser/MenuItemSheet.js +132 -0
  69. package/dist/menu-browser/MenuLightbox.js +33 -0
  70. package/dist/menu-browser/MenuRow.js +43 -0
  71. package/dist/menu-browser/MenuSection.js +10 -0
  72. package/dist/menu-browser/menuItemDisplay.js +19 -0
  73. package/dist/menu-browser/useMediaQuery.js +19 -0
  74. package/dist/menu-browser/useMenuBrowserState.js +82 -0
  75. package/dist/menu-browser/useParkedSheet.js +32 -0
  76. package/dist/pages/AdminMenuEditor.js +291 -0
  77. package/dist/pages/AdminPreview.js +43 -0
  78. package/dist/pages/AdminPreview.module.css.js +9 -0
  79. package/dist/pages/MenuSite.js +40 -0
  80. package/dist/restaurant-kit.css +1 -0
  81. package/dist/testimonials/downscaleImage.js +14 -0
  82. package/dist/testimonials/testimonialMutations.js +52 -0
  83. package/dist/types/RestaurantSiteRoutes.d.ts +11 -0
  84. package/dist/types/admin/AdminActionMenu.d.ts +20 -0
  85. package/dist/types/admin/AdminAuthPanel.d.ts +13 -0
  86. package/dist/types/admin/AdminBackupsDialog.d.ts +9 -0
  87. package/dist/types/admin/AdminDraftProvider.d.ts +12 -0
  88. package/dist/types/admin/AdminGroupsPanel.d.ts +14 -0
  89. package/dist/types/admin/AdminItemDrawer.d.ts +16 -0
  90. package/dist/types/admin/AdminMenuCard.d.ts +10 -0
  91. package/dist/types/admin/AdminMenuGroupTabs.d.ts +11 -0
  92. package/dist/types/admin/AdminMenuIconPicker.d.ts +8 -0
  93. package/dist/types/admin/AdminRoutes.d.ts +5 -0
  94. package/dist/types/admin/AdminSectionEditor.d.ts +20 -0
  95. package/dist/types/admin/AdminTestimonialsPanel.d.ts +7 -0
  96. package/dist/types/admin/adminDraftContext.d.ts +2 -0
  97. package/dist/types/admin/adminEditorUtils.d.ts +22 -0
  98. package/dist/types/admin/adminMenuCommands.d.ts +73 -0
  99. package/dist/types/admin/useAdminDraft.d.ts +5 -0
  100. package/dist/types/admin/useAdminMenuDraft.d.ts +41 -0
  101. package/dist/types/cart/CartDrawer.d.ts +7 -0
  102. package/dist/types/cart/CartProvider.d.ts +4 -0
  103. package/dist/types/cart/cartContext.d.ts +2 -0
  104. package/dist/types/cart/cartOrder.d.ts +9 -0
  105. package/dist/types/cart/cartReducer.d.ts +36 -0
  106. package/dist/types/cart/cartTypes.d.ts +37 -0
  107. package/dist/types/cart/useCart.d.ts +1 -0
  108. package/dist/types/components/Brand.d.ts +5 -0
  109. package/dist/types/components/Footer.d.ts +1 -0
  110. package/dist/types/components/Header.d.ts +1 -0
  111. package/dist/types/components/Hero.d.ts +1 -0
  112. package/dist/types/components/InstagramIcon.d.ts +6 -0
  113. package/dist/types/components/LocationsSection.d.ts +1 -0
  114. package/dist/types/components/Marquee.d.ts +1 -0
  115. package/dist/types/components/OrderIcon.d.ts +4 -0
  116. package/dist/types/components/RatingSection.d.ts +1 -0
  117. package/dist/types/components/TestimonialsSection.d.ts +2 -0
  118. package/dist/types/components/WhatsAppIcon.d.ts +5 -0
  119. package/dist/types/config/RestaurantKitProvider.d.ts +24 -0
  120. package/dist/types/config/siteConfigContext.d.ts +28 -0
  121. package/dist/types/config/siteTypes.d.ts +50 -0
  122. package/dist/types/config/useOrdering.d.ts +16 -0
  123. package/dist/types/index.d.ts +38 -0
  124. package/dist/types/menu/localMenuApi.d.ts +9 -0
  125. package/dist/types/menu/menuApi.d.ts +68 -0
  126. package/dist/types/menu/menuDraftStorage.d.ts +5 -0
  127. package/dist/types/menu/menuIcons.d.ts +7 -0
  128. package/dist/types/menu/menuMutations.d.ts +13 -0
  129. package/dist/types/menu/menuOrdering.d.ts +38 -0
  130. package/dist/types/menu/menuSchema.d.ts +68 -0
  131. package/dist/types/menu/priceFormat.d.ts +8 -0
  132. package/dist/types/menu/useMenuData.d.ts +12 -0
  133. package/dist/types/menu/validateMenu.d.ts +16 -0
  134. package/dist/types/menu-browser/MenuBrowser.d.ts +6 -0
  135. package/dist/types/menu-browser/MenuCard.d.ts +7 -0
  136. package/dist/types/menu-browser/MenuCartDrawer.d.ts +5 -0
  137. package/dist/types/menu-browser/MenuCategoryBlock.d.ts +11 -0
  138. package/dist/types/menu-browser/MenuItemSheet.d.ts +10 -0
  139. package/dist/types/menu-browser/MenuLightbox.d.ts +9 -0
  140. package/dist/types/menu-browser/MenuRow.d.ts +7 -0
  141. package/dist/types/menu-browser/MenuSection.d.ts +1 -0
  142. package/dist/types/menu-browser/menuItemDisplay.d.ts +5 -0
  143. package/dist/types/menu-browser/useMediaQuery.d.ts +1 -0
  144. package/dist/types/menu-browser/useMenuBrowserState.d.ts +17 -0
  145. package/dist/types/menu-browser/useParkedSheet.d.ts +13 -0
  146. package/dist/types/pages/AdminMenuEditor.d.ts +1 -0
  147. package/dist/types/pages/AdminPreview.d.ts +1 -0
  148. package/dist/types/pages/MenuSite.d.ts +6 -0
  149. package/dist/types/testimonials/downscaleImage.d.ts +6 -0
  150. package/dist/types/testimonials/testimonialMutations.d.ts +9 -0
  151. package/dist/types/utils.d.ts +1 -0
  152. package/dist/types/worker/auth.d.ts +97 -0
  153. package/dist/types/worker/http.d.ts +17 -0
  154. package/dist/types/worker/index.d.ts +16 -0
  155. package/dist/types/worker/menuStorage.d.ts +94 -0
  156. package/dist/utils.js +6 -0
  157. package/dist/worker/auth.js +183 -0
  158. package/dist/worker/http.js +65 -0
  159. package/dist/worker/menuStorage.js +95 -0
  160. package/dist/worker.js +131 -0
  161. package/package.json +105 -0
  162. package/scripts/bootstrap-site.mjs +125 -0
  163. package/scripts/hash-admin-password.mjs +11 -0
  164. 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
+ [![npm](https://img.shields.io/npm/v/restaurant-kit)](https://www.npmjs.com/package/restaurant-kit)
4
+ [![CI](https://github.com/HadiAlHassan/restaurant-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/HadiAlHassan/restaurant-kit/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/restaurant-kit)](./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
+ };