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:
chris 2026-06-07 00:52:31 -04:00
parent 2e5f253580
commit 781f990541
7 changed files with 150 additions and 2 deletions

View File

@ -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>

View 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 })
}

View File

@ -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.' },

View 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)
}

View File

@ -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&rsquo;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.

View 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