Skip to content

Commit 3301276

Browse files
committed
feat: invoice expiry date-time picker with timezone support (#486)
1 parent 8149b9f commit 3301276

3 files changed

Lines changed: 200 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
'use client';
2+
3+
import { useState, useEffect } from 'react';
4+
5+
interface ExpiryDatePickerProps {
6+
value: string;
7+
onChange: (iso: string) => void;
8+
onTimezoneChange?: (tz: string) => void;
9+
timezone?: string;
10+
error?: string;
11+
}
12+
13+
export default function ExpiryDatePicker({
14+
value,
15+
onChange,
16+
onTimezoneChange,
17+
timezone: externalTimezone,
18+
error,
19+
}: ExpiryDatePickerProps) {
20+
const [timezone, setTimezone] = useState<string>(() => {
21+
if (externalTimezone) return externalTimezone;
22+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
23+
});
24+
25+
const [timezones, setTimezones] = useState<string[]>([]);
26+
27+
useEffect(() => {
28+
try {
29+
const supported = Intl.supportedValuesOf('timeZone') as string[];
30+
setTimezones(supported);
31+
} catch {
32+
setTimezones([]);
33+
}
34+
}, []);
35+
36+
useEffect(() => {
37+
if (externalTimezone) {
38+
setTimezone(externalTimezone);
39+
}
40+
}, [externalTimezone]);
41+
42+
const handleTimezoneChange = (tz: string) => {
43+
setTimezone(tz);
44+
if (onTimezoneChange) {
45+
onTimezoneChange(tz);
46+
}
47+
};
48+
49+
const isExpired = value && new Date(value) < new Date();
50+
51+
return (
52+
<div className="space-y-3">
53+
<div className="grid grid-cols-2 gap-3">
54+
<div>
55+
<label htmlFor="expiry-date" className="block text-sm font-medium text-gray-300 mb-1">
56+
Expiry Date & Time
57+
</label>
58+
<input
59+
id="expiry-date"
60+
type="datetime-local"
61+
value={value}
62+
onChange={(e) => onChange(e.target.value)}
63+
aria-invalid={isExpired || !!error}
64+
aria-describedby={error ? 'expiry-error' : undefined}
65+
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 aria-invalid:border-red-600 aria-invalid:ring-red-600"
66+
/>
67+
</div>
68+
69+
<div>
70+
<label htmlFor="timezone-select" className="block text-sm font-medium text-gray-300 mb-1">
71+
Timezone
72+
</label>
73+
<select
74+
id="timezone-select"
75+
value={timezone}
76+
onChange={(e) => handleTimezoneChange(e.target.value)}
77+
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
78+
>
79+
{timezones.map((tz) => (
80+
<option key={tz} value={tz}>
81+
{tz}
82+
</option>
83+
))}
84+
</select>
85+
</div>
86+
</div>
87+
88+
{(error || isExpired) && (
89+
<div
90+
id="expiry-error"
91+
role="alert"
92+
className="text-sm text-red-400 bg-red-950/40 border border-red-800 rounded-lg px-3 py-2"
93+
>
94+
{error || 'Expiry date cannot be in the past'}
95+
</div>
96+
)}
97+
</div>
98+
);
99+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client';
2+
3+
interface ExpiryDisplayProps {
4+
timestamp: number;
5+
overrideTimezone?: string;
6+
}
7+
8+
export default function ExpiryDisplay({
9+
timestamp,
10+
overrideTimezone,
11+
}: ExpiryDisplayProps) {
12+
const date = new Date(timestamp * 1000);
13+
const timezone = overrideTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
14+
15+
const formatted = new Intl.DateTimeFormat('en-US', {
16+
year: 'numeric',
17+
month: 'long',
18+
day: 'numeric',
19+
hour: '2-digit',
20+
minute: '2-digit',
21+
second: '2-digit',
22+
timeZone: timezone,
23+
timeZoneName: 'short',
24+
}).format(date);
25+
26+
const isExpired = date < new Date();
27+
28+
return (
29+
<div className={`text-sm ${isExpired ? 'text-red-400' : 'text-gray-300'}`}>
30+
<span className="font-mono">{formatted}</span>
31+
{isExpired && <span className="ml-2 text-xs font-medium">(Expired)</span>}
32+
</div>
33+
);
34+
}

src/hooks/useInvoiceForm.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use client';
2+
3+
import { useCallback, useState, useMemo } from 'react';
4+
5+
interface InvoiceFormState {
6+
expiryDate: string;
7+
timezone: string;
8+
}
9+
10+
export function useInvoiceForm() {
11+
const [state, setState] = useState<InvoiceFormState>(() => ({
12+
expiryDate: '',
13+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
14+
}));
15+
16+
const setExpiryDate = useCallback((date: string) => {
17+
setState((prev) => ({ ...prev, expiryDate: date }));
18+
}, []);
19+
20+
const setTimezone = useCallback((tz: string) => {
21+
setState((prev) => ({ ...prev, timezone: tz }));
22+
}, []);
23+
24+
const validation = useMemo(() => {
25+
const errors: Record<string, string> = {};
26+
27+
if (state.expiryDate) {
28+
const date = new Date(state.expiryDate);
29+
if (date < new Date()) {
30+
errors.expiryDate = 'Expiry date cannot be in the past';
31+
}
32+
}
33+
34+
return {
35+
isValid: Object.keys(errors).length === 0,
36+
errors,
37+
};
38+
}, [state.expiryDate]);
39+
40+
const getUtcTimestamp = useCallback((): number | null => {
41+
if (!state.expiryDate || !validation.isValid) {
42+
return null;
43+
}
44+
45+
const localDate = new Date(state.expiryDate);
46+
return Math.floor(localDate.getTime() / 1000);
47+
}, [state.expiryDate, validation.isValid]);
48+
49+
const convertToUtcIso = useCallback((): string | null => {
50+
if (!state.expiryDate) {
51+
return null;
52+
}
53+
54+
const localDate = new Date(state.expiryDate);
55+
return localDate.toISOString();
56+
}, [state.expiryDate]);
57+
58+
return {
59+
expiryDate: state.expiryDate,
60+
timezone: state.timezone,
61+
setExpiryDate,
62+
setTimezone,
63+
validation,
64+
getUtcTimestamp,
65+
convertToUtcIso,
66+
};
67+
}

0 commit comments

Comments
 (0)