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 [error, setError] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [tab, setTab] = useState<'items' | 'categories' | 'hours' | 'occasions' | 'delivery' | 'orders'>('items')
|
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 [fetchedAt, setFetchedAt] = useState<string | null>(null)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [refreshMsg, setRefreshMsg] = useState('')
|
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() {
|
async function handleLogout() {
|
||||||
await fetch(BASE + '/api/admin/logout', { method: 'POST' })
|
await fetch(BASE + '/api/admin/logout', { method: 'POST' })
|
||||||
@ -1950,6 +1973,46 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Tabs */}
|
||||||
<div className="tabs" style={{ marginBottom: '1rem' }}>
|
<div className="tabs" style={{ marginBottom: '1rem' }}>
|
||||||
<ul>
|
<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 { createHash } from 'crypto'
|
||||||
import { inferTier, JOB_MINUTES, type DeliveryTier } from '@/lib/delivery'
|
import { inferTier, JOB_MINUTES, type DeliveryTier } from '@/lib/delivery'
|
||||||
import { getHoursConfig } from '@/lib/hours'
|
import { getHoursConfig } from '@/lib/hours'
|
||||||
|
import { getStoreStatus } from '@/lib/store-status'
|
||||||
|
|
||||||
interface LineItem {
|
interface LineItem {
|
||||||
name: string
|
name: string
|
||||||
@ -32,6 +33,14 @@ interface CheckoutBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
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) {
|
if (!process.env.SQUARE_ACCESS_TOKEN) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Square is not configured on this server.' },
|
{ 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 [catHidden, setCatHidden] = useState<string[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
|
const [storeClosed, setStoreClosed] = useState(false)
|
||||||
|
const [closeMessage, setCloseMessage] = useState('')
|
||||||
|
|
||||||
const [category, setCategory] = useState<string>('all')
|
const [category, setCategory] = useState<string>('all')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@ -98,6 +100,13 @@ export default function FeaturedProducts() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
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([
|
Promise.all([
|
||||||
fetch(BASE + '/api/catalog').then((r) => { if (!r.ok) throw new Error('catalog error'); return r.json() }),
|
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: {} })),
|
fetch(BASE + '/api/inventory').then((r) => r.ok ? r.json() : { counts: {} }).catch(() => ({ counts: {} })),
|
||||||
@ -243,6 +252,17 @@ export default function FeaturedProducts() {
|
|||||||
{/* Product grid */}
|
{/* Product grid */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<SkeletonGrid />
|
<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 ? (
|
) : error ? (
|
||||||
<p className="has-text-centered has-text-danger py-6">
|
<p className="has-text-centered has-text-danger py-6">
|
||||||
Unable to load products right now. Please try again shortly.
|
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