beachPartyBalloons/src/components/CalendarPicker.tsx
chris 3cb9eae975 Initial commit — Beach Party Balloons shop
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>
2026-04-11 20:37:10 -04:00

148 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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