1import { useEffect, useId, useState } from "react";
2import { create } from "zustand";
3import {
4 localSnoozeDate,
5 localSnoozeTime,
6 resolveCustomSnooze,
7 type CustomSnoozeInput,
8} from "@t3tools/client-runtime/state/thread-settled";
9import { Button } from "./ui/button";
10import { CalendarIcon } from "lucide-react";
11import { Calendar } from "./ui/calendar";
12import { weekStartsOn } from "../timestampFormat";
13import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover";
14import { Input } from "./ui/input";
15import { Label } from "./ui/label";
16import { Toggle, ToggleGroup } from "./ui/toggle-group";
17import { Select, SelectTrigger, SelectValue, SelectPopup, SelectItem } from "./ui/select";
18import {
19 NumberField,
20 NumberFieldGroup,
21 NumberFieldInput,
22 NumberFieldDecrement,
23 NumberFieldIncrement,
24} from "./ui/number-field";
25import {
26 Dialog,
27 DialogPopup,
28 DialogHeader,
29 DialogTitle,
30 DialogDescription,
31 DialogPanel,
32 DialogFooter,
33} from "./ui/dialog";
34
35type SnoozeChoice = { readonly snoozedUntil: string };
36type Request = { readonly resolve: (choice: SnoozeChoice | null) => void };
37const useRequest = create<{ request: Request | null }>(() => ({ request: null }));
38
39export function requestCustomSnooze(): Promise<SnoozeChoice | null> {
40 useRequest.getState().request?.resolve(null);
41 return new Promise((resolve) => useRequest.setState({ request: { resolve } }));
42}
43
44function finish(choice: SnoozeChoice | null) {
45 const request = useRequest.getState().request;
46 useRequest.setState({ request: null });
47 request?.resolve(choice);
48}
49
50export function CustomSnoozeDialogHost() {
51 const request = useRequest((state) => state.request);
52 useEffect(() => () => finish(null), []);
53 return request ? <CustomSnoozeDialog /> : null;
54}
55
56function CustomSnoozeDialog() {
57 const id = useId();
58 const [initial] = useState(() => new Date(Date.now() + 3_600_000));
59 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");
60 const [date, setDate] = useState(initial);
61 const [calendarOpen, setCalendarOpen] = useState(false);
62 const [time, setTime] = useState(localSnoozeTime(initial));
63 const [amount, setAmount] = useState("2");
64 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
65 const [error, setError] = useState<string | null>(null);
66 const input: CustomSnoozeInput =
67 mode === "date" ? { mode, date: localSnoozeDate(date), time } : { mode, amount, unit };
68 return (
69 <Dialog
70 open
71 onOpenChange={(open) => {
72 if (!open) finish(null);
73 }}
74 >
75 <DialogPopup className="sm:max-w-sm">
76 <form
77 className="flex min-h-0 flex-col"
78 onSubmit={(event) => {
79 event.preventDefault();
80 const snoozedUntil = resolveCustomSnooze(input, new Date());
81 if (!snoozedUntil) {
82 setError(
83 mode === "date"
84 ? "Choose a valid date and time in the future."
85 : "Enter a positive duration.",
86 );
87 return;
88 }
89 finish({ snoozedUntil });
90 }}
91 >
92 <DialogHeader>
93 <DialogTitle>Custom snooze</DialogTitle>
94 <DialogDescription>Choose when snoozed threads return to your inbox.</DialogDescription>
95 </DialogHeader>
96 <DialogPanel>
97 <div className="flex flex-col gap-4">
98 <ToggleGroup
99 aria-label="Schedule type"
100 className="w-full *:flex-1"
101 value={[mode]}
102 onValueChange={(next) => {
103 const value = next[0];
104 if (value === "date" || value === "duration") setMode(value);
105 setError(null);
106 }}
107 >
108 <Toggle value="date">Date and time</Toggle>
109 <Toggle value="duration">Duration</Toggle>
110 </ToggleGroup>
111 <div className="flex flex-col gap-4">
112 {mode === "date" ? (
113 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
114 <div className="flex min-w-0 flex-col gap-1.5">
115 <Label htmlFor={`${id}-date`}>Date</Label>
116 <Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
117 <PopoverTrigger
118 render={
119 <Button
120 id={`${id}-date`}
121 variant="outline"
122 className="w-full justify-between"
123 />
124 }
125 >
126 {date.toLocaleDateString(undefined, {
127 month: "short",
128 day: "numeric",
129 year: "numeric",
130 })}
131 <CalendarIcon className="size-4 text-muted-foreground" />
132 </PopoverTrigger>
133 <PopoverPopup align="start" aria-label="Choose snooze date">
134 <Calendar
135 mode="single"
136 required
137 selected={date}
138 defaultMonth={date}
139 {...(weekStartsOn === undefined ? {} : { weekStartsOn })}
140 disabled={{ before: new Date(new Date().setHours(0, 0, 0, 0)) }}
141 onSelect={(selected) => {
142 setDate(selected);
143 setCalendarOpen(false);
144 setError(null);
145 }}
146 />
147 </PopoverPopup>
148 </Popover>
149 </div>
150 <Label className="flex min-w-0 flex-col items-stretch" htmlFor={`${id}-time`}>
151 Time
152 <Input
153 nativeInput
154 id={`${id}-time`}
155 className="h-9 sm:h-8"
156 type="time"
157 required
158 value={time}
159 onChange={(event) => {
160 setTime(event.target.value);
161 setError(null);
162 }}
163 />
164 </Label>
165 </div>
166 ) : (
167 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
168 <NumberField
169 id={`${id}-amount`}
170 min={0}
171 step="any"
172 value={amount === "" ? null : Number(amount)}
173 onValueChange={(value) => {
174 setAmount(value === null ? "" : String(value));
175 setError(null);
176 }}
177 >
178 <Label htmlFor={`${id}-amount`}>Snooze for</Label>
179 <NumberFieldGroup>
180 <NumberFieldDecrement aria-label="Decrease duration" />
181 <NumberFieldInput required />
182 <NumberFieldIncrement aria-label="Increase duration" />
183 </NumberFieldGroup>
184 </NumberField>
185 <Label className="flex min-w-0 flex-col items-stretch" htmlFor={`${id}-unit`}>
186 Unit
187 <Select
188 value={unit}
189 items={{ minutes: "Minutes", hours: "Hours", days: "Days" }}
190 onValueChange={(value) => {
191 if (value === "minutes" || value === "hours" || value === "days")
192 setUnit(value);
193 setError(null);
194 }}
195 >
196 <SelectTrigger id={`${id}-unit`} className="min-w-0">
197 <SelectValue />
198 </SelectTrigger>
199 <SelectPopup>
200 <SelectItem value="minutes">Minutes</SelectItem>
201 <SelectItem value="hours">Hours</SelectItem>
202 <SelectItem value="days">Days</SelectItem>
203 </SelectPopup>
204 </Select>
205 </Label>
206 </div>
207 )}
208 </div>
209 </div>
210 {error && (
211 <p role="alert" className="text-destructive">
212 {error}
213 </p>
214 )}
215 </DialogPanel>
216 <DialogFooter>
217 <Button type="button" variant="outline" onClick={() => finish(null)}>
218 Cancel
219 </Button>
220 <Button type="submit">Snooze</Button>
221 </DialogFooter>
222 </form>
223 </DialogPopup>
224 </Dialog>
225 );
226}