Full Next.js storefront with Square catalog integration, balloon color picker, delivery/pickup slot booking, CalDAV calendar sync, and admin panel. Admin features: item overrides, category display order/visibility, hours editor, holiday/occasion windows, quantity units, and modifier deselect. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
148 lines
5.8 KiB
TypeScript
148 lines
5.8 KiB
TypeScript
'use client'
|
||
|
||
import { useState } from 'react'
|
||
|
||
const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
||
const MONTHS = ['January','February','March','April','May','June',
|
||
'July','August','September','October','November','December']
|
||
|
||
interface Props {
|
||
selected: string // YYYY-MM-DD or ''
|
||
onSelect: (date: string) => void
|
||
minDate: string // YYYY-MM-DD — dates before this are disabled
|
||
maxDate: string // YYYY-MM-DD — dates after this are disabled
|
||
disabledDates?: Set<string> // fully blocked (strikethrough, not clickable)
|
||
busyDates?: Set<string> // has events but still selectable (orange dot)
|
||
}
|
||
|
||
function pad(n: number) { return String(n).padStart(2, '0') }
|
||
|
||
export default function CalendarPicker({
|
||
selected, onSelect, minDate, maxDate, disabledDates, busyDates,
|
||
}: Props) {
|
||
const today = new Date().toISOString().slice(0, 10)
|
||
const initRef = selected || minDate || today
|
||
|
||
const [year, setYear] = useState(() => parseInt(initRef.slice(0, 4)))
|
||
const [month, setMonth] = useState(() => parseInt(initRef.slice(5, 7)) - 1)
|
||
|
||
const firstDow = new Date(year, month, 1).getDay()
|
||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||
|
||
const curMonthStr = `${year}-${pad(month + 1)}`
|
||
const canPrev = curMonthStr > minDate.slice(0, 7)
|
||
const canNext = curMonthStr < maxDate.slice(0, 7)
|
||
|
||
const prevMonth = () => {
|
||
if (!canPrev) return
|
||
if (month === 0) { setYear(y => y - 1); setMonth(11) } else setMonth(m => m - 1)
|
||
}
|
||
const nextMonth = () => {
|
||
if (!canNext) return
|
||
if (month === 11) { setYear(y => y + 1); setMonth(0) } else setMonth(m => m + 1)
|
||
}
|
||
|
||
// Empty cells before the 1st, then day numbers
|
||
const cells: (number | null)[] = [
|
||
...Array.from({ length: firstDow }, () => null),
|
||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||
]
|
||
|
||
return (
|
||
<div style={{ userSelect: 'none', fontSize: '0.82rem' }}>
|
||
|
||
{/* Month navigation */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||
<button
|
||
type="button" onClick={prevMonth} disabled={!canPrev}
|
||
style={{ background: 'none', border: 'none', padding: '2px 8px', fontSize: '1.1rem', lineHeight: 1,
|
||
cursor: canPrev ? 'pointer' : 'default', color: canPrev ? '#11b3be' : '#ddd' }}
|
||
>‹</button>
|
||
|
||
<span style={{ fontWeight: 'bold' }}>{MONTHS[month]} {year}</span>
|
||
|
||
<button
|
||
type="button" onClick={nextMonth} disabled={!canNext}
|
||
style={{ background: 'none', border: 'none', padding: '2px 8px', fontSize: '1.1rem', lineHeight: 1,
|
||
cursor: canNext ? 'pointer' : 'default', color: canNext ? '#11b3be' : '#ddd' }}
|
||
>›</button>
|
||
</div>
|
||
|
||
{/* Day-of-week labels */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px', marginBottom: '2px' }}>
|
||
{DAYS.map(d => (
|
||
<div key={d} style={{ textAlign: 'center', color: '#bbb', fontSize: '0.68rem', padding: '1px 0' }}>
|
||
{d}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Day cells */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px' }}>
|
||
{cells.map((day, i) => {
|
||
if (!day) return <div key={`e${i}`} />
|
||
|
||
const date = `${year}-${pad(month + 1)}-${pad(day)}`
|
||
const isSelected = date === selected
|
||
const isToday = date === today
|
||
const outOfRange = date < minDate || date > maxDate
|
||
const hardBlocked = disabledDates?.has(date) ?? false
|
||
const isDisabled = outOfRange || hardBlocked
|
||
const isBusy = !isDisabled && (busyDates?.has(date) ?? false)
|
||
|
||
return (
|
||
<button
|
||
key={date}
|
||
type="button"
|
||
disabled={isDisabled}
|
||
onClick={() => onSelect(date)}
|
||
style={{
|
||
position: 'relative',
|
||
padding: '5px 2px 6px',
|
||
borderRadius: '6px',
|
||
border: isSelected
|
||
? '2px solid #11b3be'
|
||
: isToday
|
||
? '1.5px solid rgba(17,179,190,0.35)'
|
||
: '1px solid transparent',
|
||
background: isSelected ? '#11b3be' : outOfRange ? 'transparent' : hardBlocked ? '#f9f9f9' : '#fff',
|
||
color: isSelected ? '#fff' : isDisabled ? '#ccc' : '#333',
|
||
cursor: isDisabled ? 'default' : 'pointer',
|
||
fontWeight: isSelected ? 'bold' : 'normal',
|
||
fontFamily: 'inherit',
|
||
fontSize: '0.82rem',
|
||
textAlign: 'center',
|
||
lineHeight: 1.3,
|
||
textDecoration: hardBlocked ? 'line-through' : 'none',
|
||
}}
|
||
>
|
||
{day}
|
||
{isBusy && (
|
||
<span style={{
|
||
position: 'absolute',
|
||
bottom: '2px',
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
width: '3px',
|
||
height: '3px',
|
||
borderRadius: '50%',
|
||
background: isSelected ? 'rgba(255,255,255,0.7)' : '#f5a623',
|
||
display: 'block',
|
||
}} />
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Legend */}
|
||
{busyDates && busyDates.size > 0 && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '0.4rem', fontSize: '0.68rem', color: '#999' }}>
|
||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#f5a623', display: 'inline-block' }} />
|
||
May have limited availability
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|