Add step date picker and show full renewal dates.
Replace native date inputs with year→month→day glass picker and display exact next renewal dates in the businesses list. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
58408fed07
commit
b50e4fd812
@@ -0,0 +1,299 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { CalendarDays, ChevronLeft } from 'lucide-react'
|
||||
import styles from './StepDatePicker.module.css'
|
||||
|
||||
type Step = 'year' | 'month' | 'day'
|
||||
|
||||
interface StepDatePickerProps {
|
||||
id?: string
|
||||
value: string
|
||||
onChange: (isoDate: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
] as const
|
||||
|
||||
function parseIsoDate(value: string | null | undefined): {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
} | null {
|
||||
if (!value?.trim()) return null
|
||||
const match = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})/)
|
||||
if (!match) return null
|
||||
const year = Number(match[1])
|
||||
const month = Number(match[2])
|
||||
const day = Number(match[3])
|
||||
if (!year || month < 1 || month > 12 || day < 1 || day > 31) return null
|
||||
const probe = new Date(year, month - 1, day)
|
||||
if (
|
||||
probe.getFullYear() !== year ||
|
||||
probe.getMonth() !== month - 1 ||
|
||||
probe.getDate() !== day
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { year, month, day }
|
||||
}
|
||||
|
||||
function toIsoDate(year: number, month: number, day: number): string {
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDisplayDate(iso: string): string {
|
||||
const parsed = parseIsoDate(iso)
|
||||
if (!parsed) return ''
|
||||
return new Date(parsed.year, parsed.month - 1, parsed.day).toLocaleDateString(
|
||||
'en-US',
|
||||
{ month: 'short', day: 'numeric', year: 'numeric' },
|
||||
)
|
||||
}
|
||||
|
||||
function daysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 0).getDate()
|
||||
}
|
||||
|
||||
function startWeekday(year: number, month: number): number {
|
||||
return new Date(year, month - 1, 1).getDay()
|
||||
}
|
||||
|
||||
export function StepDatePicker({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
placeholder = 'Select date',
|
||||
}: StepDatePickerProps) {
|
||||
const autoId = useId()
|
||||
const fieldId = id ?? autoId
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState<Step>('year')
|
||||
|
||||
const today = useMemo(() => {
|
||||
const now = new Date()
|
||||
return {
|
||||
year: now.getFullYear(),
|
||||
month: now.getMonth() + 1,
|
||||
day: now.getDate(),
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selected = parseIsoDate(value)
|
||||
const [draftYear, setDraftYear] = useState(selected?.year ?? today.year)
|
||||
const [draftMonth, setDraftMonth] = useState(selected?.month ?? today.month)
|
||||
|
||||
const years = useMemo(() => {
|
||||
const start = today.year - 8
|
||||
const end = today.year + 12
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
}, [today.year])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onPointerDown)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function openPicker() {
|
||||
if (disabled) return
|
||||
const initial = selected ?? today
|
||||
setDraftYear(initial.year)
|
||||
setDraftMonth(initial.month)
|
||||
setStep('year')
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function pickYear(year: number) {
|
||||
setDraftYear(year)
|
||||
setStep('month')
|
||||
}
|
||||
|
||||
function pickMonth(month: number) {
|
||||
setDraftMonth(month)
|
||||
setStep('day')
|
||||
}
|
||||
|
||||
function pickDay(day: number) {
|
||||
onChange(toIsoDate(draftYear, draftMonth, day))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const display = value ? formatDisplayDate(value) : ''
|
||||
const dayCount = daysInMonth(draftYear, draftMonth)
|
||||
const padStart = startWeekday(draftYear, draftMonth)
|
||||
const highlightDay =
|
||||
selected && selected.year === draftYear && selected.month === draftMonth
|
||||
? selected.day
|
||||
: draftYear === today.year && draftMonth === today.month
|
||||
? today.day
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<button
|
||||
id={fieldId}
|
||||
type="button"
|
||||
className={`${styles.trigger} ${open ? styles.triggerOpen : ''} ${
|
||||
!display ? styles.triggerPlaceholder : ''
|
||||
}`}
|
||||
onClick={() => (open ? setOpen(false) : openPicker())}
|
||||
disabled={disabled}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className={styles.triggerText}>{display || placeholder}</span>
|
||||
<CalendarDays size={16} className={styles.icon} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className={styles.panel} role="dialog" aria-label="Choose date">
|
||||
<div className={styles.panelHeader}>
|
||||
{step !== 'year' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => setStep(step === 'day' ? 'month' : 'year')}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.backSpacer} />
|
||||
)}
|
||||
<div className={styles.steps}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.stepChip} ${step === 'year' ? styles.stepActive : ''}`}
|
||||
onClick={() => setStep('year')}
|
||||
>
|
||||
{draftYear}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.stepChip} ${step === 'month' ? styles.stepActive : ''}`}
|
||||
onClick={() => setStep('month')}
|
||||
>
|
||||
{MONTHS[draftMonth - 1]}
|
||||
</button>
|
||||
<span
|
||||
className={`${styles.stepChip} ${step === 'day' ? styles.stepActive : ''} ${styles.stepStatic}`}
|
||||
>
|
||||
Day
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step === 'year' ? (
|
||||
<div className={styles.yearList}>
|
||||
{years.map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
type="button"
|
||||
className={`${styles.optionBtn} ${
|
||||
year === draftYear ? styles.optionSelected : ''
|
||||
} ${year === today.year ? styles.optionToday : ''}`}
|
||||
onClick={() => pickYear(year)}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'month' ? (
|
||||
<div className={styles.monthGrid}>
|
||||
{MONTHS.map((label, index) => {
|
||||
const month = index + 1
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={`${styles.optionBtn} ${
|
||||
month === draftMonth ? styles.optionSelected : ''
|
||||
} ${
|
||||
draftYear === today.year && month === today.month
|
||||
? styles.optionToday
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => pickMonth(month)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'day' ? (
|
||||
<div className={styles.dayPanel}>
|
||||
<div className={styles.weekdayRow}>
|
||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d) => (
|
||||
<span key={d} className={styles.weekday}>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.dayGrid}>
|
||||
{Array.from({ length: padStart }, (_, i) => (
|
||||
<span key={`pad-${i}`} className={styles.dayEmpty} />
|
||||
))}
|
||||
{Array.from({ length: dayCount }, (_, i) => {
|
||||
const day = i + 1
|
||||
const isSelected =
|
||||
selected?.year === draftYear &&
|
||||
selected?.month === draftMonth &&
|
||||
selected?.day === day
|
||||
const isToday =
|
||||
draftYear === today.year &&
|
||||
draftMonth === today.month &&
|
||||
day === today.day
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
className={`${styles.dayBtn} ${
|
||||
isSelected ? styles.daySelected : ''
|
||||
} ${isToday && !isSelected ? styles.dayToday : ''} ${
|
||||
highlightDay === day && !isSelected ? styles.dayPrefill : ''
|
||||
}`}
|
||||
onClick={() => pickDay(day)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user