From be7f98a34736c5e284c0370628971c2307435cad Mon Sep 17 00:00:00 2001
From: chris
Date: Wed, 29 Apr 2026 23:09:34 -0400
Subject: [PATCH] Add admin backup and restore for all config files
Download button exports item-overrides, delivery-rates, categories-display,
occasions, hours, and vinyl-config as a single JSON file. Restore button
applies a previously downloaded backup (skips vinyl-config to avoid
overwriting it). Both accessible from the admin header.
Co-Authored-By: Claude Sonnet 4.6
---
estore/src/app/admin/page.tsx | 60 +++++++++++++++++++++
estore/src/app/api/admin/backup/route.ts | 68 ++++++++++++++++++++++++
2 files changed, 128 insertions(+)
create mode 100644 estore/src/app/api/admin/backup/route.ts
diff --git a/estore/src/app/admin/page.tsx b/estore/src/app/admin/page.tsx
index f521299..75859d6 100644
--- a/estore/src/app/admin/page.tsx
+++ b/estore/src/app/admin/page.tsx
@@ -1514,6 +1514,9 @@ export default function AdminPage() {
const [fetchedAt, setFetchedAt] = useState(null)
const [refreshing, setRefreshing] = useState(false)
const [refreshMsg, setRefreshMsg] = useState('')
+ const [restoring, setRestoring] = useState(false)
+ const [restoreMsg, setRestoreMsg] = useState('')
+ const restoreInputRef = useRef(null)
// Category creation state (for the Categories tab)
const [newCatName, setNewCatName] = useState('')
@@ -1590,6 +1593,43 @@ export default function AdminPage() {
setCatMessage(cat.id ? `Created: ${cat.name}` : 'Failed to create category')
}
+ function handleBackup() {
+ window.location.href = BASE + '/api/admin/backup'
+ }
+
+ async function handleRestore(e: React.ChangeEvent) {
+ const file = e.target.files?.[0]
+ if (!file) return
+ if (!confirm('This will overwrite your current item overrides, delivery rates, hours, occasions, and category settings. Continue?')) {
+ if (restoreInputRef.current) restoreInputRef.current.value = ''
+ return
+ }
+ setRestoring(true)
+ setRestoreMsg('')
+ try {
+ const text = await file.text()
+ const bundle = JSON.parse(text)
+ const res = await fetch(BASE + '/api/admin/backup', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(bundle),
+ })
+ const data = await res.json()
+ if (res.ok) {
+ setRestoreMsg(`Restored: ${data.restored.join(', ')}`)
+ load()
+ } else {
+ setRestoreMsg(`Error: ${data.error}`)
+ }
+ } catch {
+ setRestoreMsg('Invalid backup file')
+ } finally {
+ setRestoring(false)
+ if (restoreInputRef.current) restoreInputRef.current.value = ''
+ }
+ setTimeout(() => setRestoreMsg(''), 5000)
+ }
+
function handleSaved(id: string, patch: Partial) {
setItems((prev) =>
prev.map((item) =>
@@ -1630,6 +1670,21 @@ export default function AdminPage() {
+
+
{refreshMsg && (
@@ -1637,6 +1692,11 @@ export default function AdminPage() {
{refreshMsg}
)}
+ {restoreMsg && (
+
+ {restoreMsg}
+
+ )}
{fetchedAt && !refreshMsg && (
Cache: {new Date(fetchedAt).toLocaleString()}
diff --git a/estore/src/app/api/admin/backup/route.ts b/estore/src/app/api/admin/backup/route.ts
new file mode 100644
index 0000000..a64dab8
--- /dev/null
+++ b/estore/src/app/api/admin/backup/route.ts
@@ -0,0 +1,68 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { readFileSync, existsSync, writeFileSync } from 'fs'
+import path from 'path'
+
+export const dynamic = 'force-dynamic'
+
+const DATA = path.join(process.cwd(), 'data')
+
+const FILES = [
+ 'item-overrides',
+ 'delivery-rates',
+ 'categories-display',
+ 'occasions',
+ 'hours',
+ 'vinyl-config',
+] as const
+
+type FileKey = typeof FILES[number]
+
+function readFile(key: FileKey): unknown {
+ const p = path.join(DATA, `${key}.json`)
+ if (!existsSync(p)) return null
+ try { return JSON.parse(readFileSync(p, 'utf-8')) } catch { return null }
+}
+
+/** GET — download a JSON bundle of all config files */
+export async function GET() {
+ const bundle: Record = {
+ version: 1,
+ exportedAt: new Date().toISOString(),
+ }
+ for (const key of FILES) {
+ bundle[key] = readFile(key)
+ }
+ return new NextResponse(JSON.stringify(bundle, null, 2), {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Disposition': `attachment; filename="bpb-config-${new Date().toISOString().slice(0, 10)}.json"`,
+ },
+ })
+}
+
+/** POST — restore from a previously downloaded bundle */
+export async function POST(req: NextRequest) {
+ let bundle: Record
+ try {
+ bundle = await req.json()
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
+ }
+
+ const restored: string[] = []
+ const skipped: string[] = []
+
+ for (const key of FILES) {
+ if (key === 'vinyl-config') continue // never overwrite vinyl-config from restore
+ const value = bundle[key]
+ if (value == null) { skipped.push(key); continue }
+ try {
+ writeFileSync(path.join(DATA, `${key}.json`), JSON.stringify(value, null, 2), 'utf-8')
+ restored.push(key)
+ } catch (err) {
+ return NextResponse.json({ error: `Failed to write ${key}: ${String(err)}` }, { status: 500 })
+ }
+ }
+
+ return NextResponse.json({ ok: true, restored, skipped })
+}