Add store kill switch to admin panel and estore
Admin panel shows a prominent open/closed toggle above the tabs. When closed, the shop displays a branded closure message and the checkout API returns 503. The closure state persists in data/store-status.json. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2e5f253580
commit
781f990541
@ -1756,6 +1756,10 @@ export default function AdminPage() {
|
||||
const [error, setError] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions' | 'delivery' | 'orders'>('items')
|
||||
const [storeClosed, setStoreClosed] = useState(false)
|
||||
const [closeMessage, setCloseMessage] = useState('')
|
||||
const [storeStatusMsg, setStoreStatusMsg] = useState('')
|
||||
const [savingStatus, setSavingStatus] = useState(false)
|
||||
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [refreshMsg, setRefreshMsg] = useState('')
|
||||
@ -1808,7 +1812,26 @@ export default function AdminPage() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
useEffect(() => {
|
||||
load()
|
||||
fetch(BASE + '/api/admin/store-status')
|
||||
.then(r => r.json())
|
||||
.then(s => { setStoreClosed(s.closed ?? false); setCloseMessage(s.message ?? '') })
|
||||
.catch(() => {})
|
||||
}, [load])
|
||||
|
||||
async function saveStoreStatus(closed: boolean, message: string) {
|
||||
setSavingStatus(true)
|
||||
setStoreStatusMsg('')
|
||||
const res = await fetch(BASE + '/api/admin/store-status', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ closed, message }),
|
||||
})
|
||||
setSavingStatus(false)
|
||||
setStoreStatusMsg(res.ok ? (closed ? 'Store closed' : 'Store open') : 'Save failed')
|
||||
setTimeout(() => setStoreStatusMsg(''), 3000)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch(BASE + '/api/admin/logout', { method: 'POST' })
|
||||
@ -1950,6 +1973,46 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Store kill switch */}
|
||||
<div style={{
|
||||
border: `2px solid ${storeClosed ? '#e74c3c' : '#27ae60'}`,
|
||||
borderRadius: '8px',
|
||||
padding: '0.75rem 1rem',
|
||||
marginBottom: '1.25rem',
|
||||
background: storeClosed ? '#fff5f5' : '#f0fff4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<span style={{ fontWeight: 700, fontSize: '0.9rem', color: storeClosed ? '#c0392b' : '#27ae60', flexShrink: 0 }}>
|
||||
{storeClosed ? '🔴 Store closed' : '🟢 Store open'}
|
||||
</span>
|
||||
<input
|
||||
className="input is-small"
|
||||
placeholder="Closure message shown to customers (optional)"
|
||||
value={closeMessage}
|
||||
onChange={e => setCloseMessage(e.target.value)}
|
||||
onBlur={() => storeClosed && saveStoreStatus(storeClosed, closeMessage)}
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
disabled={!storeClosed}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexShrink: 0 }}>
|
||||
<button
|
||||
className={`button is-small ${storeClosed ? 'is-success' : 'is-danger'}${savingStatus ? ' is-loading' : ''}`}
|
||||
onClick={() => { const next = !storeClosed; setStoreClosed(next); saveStoreStatus(next, closeMessage) }}
|
||||
type="button"
|
||||
>
|
||||
{storeClosed ? 'Reopen store' : 'Close store'}
|
||||
</button>
|
||||
{storeStatusMsg && (
|
||||
<span className={`is-size-7 ${storeStatusMsg === 'Save failed' ? 'has-text-danger' : 'has-text-success'}`}>
|
||||
{storeStatusMsg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="tabs" style={{ marginBottom: '1rem' }}>
|
||||
<ul>
|
||||
|
||||
23
estore/src/app/api/admin/store-status/route.ts
Normal file
23
estore/src/app/api/admin/store-status/route.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getStoreStatus, setStoreStatus } from '@/lib/store-status'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
function isAuthed() {
|
||||
return cookies().get('bpb_admin')?.value === 'true'
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (!isAuthed()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
return NextResponse.json(await getStoreStatus())
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
if (!isAuthed()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const body = await req.json()
|
||||
const status = {
|
||||
closed: Boolean(body.closed),
|
||||
message: String(body.message ?? '').trim(),
|
||||
}
|
||||
await setStoreStatus(status)
|
||||
return NextResponse.json({ ok: true, ...status })
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createHash } from 'crypto'
|
||||
import { inferTier, JOB_MINUTES, type DeliveryTier } from '@/lib/delivery'
|
||||
import { getHoursConfig } from '@/lib/hours'
|
||||
import { getStoreStatus } from '@/lib/store-status'
|
||||
|
||||
interface LineItem {
|
||||
name: string
|
||||
@ -32,6 +33,14 @@ interface CheckoutBody {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const storeStatus = await getStoreStatus()
|
||||
if (storeStatus.closed) {
|
||||
return NextResponse.json(
|
||||
{ error: storeStatus.message || 'The store is temporarily closed. Please check back soon.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!process.env.SQUARE_ACCESS_TOKEN) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Square is not configured on this server.' },
|
||||
|
||||
9
estore/src/app/api/store-status/route.ts
Normal file
9
estore/src/app/api/store-status/route.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getStoreStatus } from '@/lib/store-status'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
const status = await getStoreStatus()
|
||||
return NextResponse.json(status)
|
||||
}
|
||||
@ -22,6 +22,8 @@ export default function FeaturedProducts() {
|
||||
const [catHidden, setCatHidden] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const [storeClosed, setStoreClosed] = useState(false)
|
||||
const [closeMessage, setCloseMessage] = useState('')
|
||||
|
||||
const [category, setCategory] = useState<string>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
@ -98,6 +100,13 @@ export default function FeaturedProducts() {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(BASE + '/api/store-status').then(r => r.json()).then(s => {
|
||||
if (s.closed) { setStoreClosed(true); setCloseMessage(s.message || ''); setLoading(false) }
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (storeClosed) return
|
||||
Promise.all([
|
||||
fetch(BASE + '/api/catalog').then((r) => { if (!r.ok) throw new Error('catalog error'); return r.json() }),
|
||||
fetch(BASE + '/api/inventory').then((r) => r.ok ? r.json() : { counts: {} }).catch(() => ({ counts: {} })),
|
||||
@ -243,6 +252,17 @@ export default function FeaturedProducts() {
|
||||
{/* Product grid */}
|
||||
{loading ? (
|
||||
<SkeletonGrid />
|
||||
) : storeClosed ? (
|
||||
<div style={{ textAlign: 'center', padding: '4rem 1rem' }}>
|
||||
<p style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎈</p>
|
||||
<h2 className="title is-3">We’re temporarily closed</h2>
|
||||
<p className="has-text-grey" style={{ fontSize: '1.05rem', maxWidth: 480, margin: '0 auto 1.5rem' }}>
|
||||
{closeMessage || 'Online ordering is paused right now. Please check back soon or contact us directly.'}
|
||||
</p>
|
||||
<a href="https://beachpartyballoons.com/contact/" className="button is-info is-medium">
|
||||
Contact us
|
||||
</a>
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="has-text-centered has-text-danger py-6">
|
||||
Unable to load products right now. Please try again shortly.
|
||||
|
||||
24
estore/src/lib/store-status.ts
Normal file
24
estore/src/lib/store-status.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const FILE = path.join(process.cwd(), 'data', 'store-status.json')
|
||||
|
||||
export interface StoreStatus {
|
||||
closed: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
const DEFAULT: StoreStatus = { closed: false, message: '' }
|
||||
|
||||
export async function getStoreStatus(): Promise<StoreStatus> {
|
||||
try {
|
||||
const raw = await fs.readFile(FILE, 'utf8')
|
||||
return { ...DEFAULT, ...JSON.parse(raw) }
|
||||
} catch {
|
||||
return { ...DEFAULT }
|
||||
}
|
||||
}
|
||||
|
||||
export async function setStoreStatus(status: StoreStatus): Promise<void> {
|
||||
await fs.writeFile(FILE, JSON.stringify(status, null, 2))
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user