chris 21ebb9667b Add 'estore/' from commit 'e34dfc397c94025670baa2b73b482c01f3033a6a'
git-subtree-dir: estore
git-subtree-mainline: 746868d720b9be1003a2f783b7a12d526d8eea60
git-subtree-split: e34dfc397c94025670baa2b73b482c01f3033a6a
2026-04-13 19:22:23 -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>
)
}