Settings.tsx raw
1 import {
2 CheckIcon,
3 LockIcon,
4 MonitorIcon,
5 MoonIcon,
6 StarsIcon,
7 SunIcon,
8 } from "lucide-react";
9 import React from "react";
10 import { toast } from "sonner";
11 import Loading from "src/components/Loading";
12 import SettingsHeader from "src/components/SettingsHeader";
13 import { ThemePreview } from "src/components/ThemePreview";
14 import { UpgradeDialog } from "src/components/UpgradeDialog";
15 import { Badge } from "src/components/ui/badge";
16 import { Label } from "src/components/ui/label";
17 import {
18 Select,
19 SelectContent,
20 SelectItem,
21 SelectTrigger,
22 SelectValue,
23 } from "src/components/ui/select";
24 import { Separator } from "src/components/ui/separator";
25 import { Tabs, TabsList, TabsTrigger } from "src/components/ui/tabs";
26 import { DarkMode, Themes, useTheme } from "src/components/ui/theme-provider";
27 import {
28 BITCOIN_DISPLAY_FORMAT_BIP177,
29 BITCOIN_DISPLAY_FORMAT_SATS,
30 } from "src/constants";
31 import { useAlbyMe } from "src/hooks/useAlbyMe";
32 import { useCurrencies } from "src/hooks/useCurrencies";
33 import { useInfo } from "src/hooks/useInfo";
34 import { cn } from "src/lib/utils";
35 import { handleRequestError } from "src/utils/handleRequestError";
36 import { request } from "src/utils/request";
37
38 function Settings() {
39 const { data: albyMe } = useAlbyMe();
40 const { theme, darkMode, setTheme, setDarkMode } = useTheme();
41 const { currencies, isLoading: isCurrenciesLoading } = useCurrencies();
42 const [showUpgradeDialog, setShowUpgradeDialog] = React.useState(false);
43
44 const { data: info, mutate: reloadInfo } = useInfo();
45
46 async function updateSettings(
47 payload: Record<string, string | boolean>,
48 successMessage: string,
49 errorMessage: string
50 ) {
51 try {
52 await request("/api/settings", {
53 method: "PATCH",
54 headers: {
55 "Content-Type": "application/json",
56 },
57 body: JSON.stringify(payload),
58 });
59 await reloadInfo();
60 toast(successMessage);
61 } catch (error) {
62 console.error(error);
63 handleRequestError(errorMessage, error);
64 }
65 }
66
67 async function updateCurrency(currency: string) {
68 await updateSettings(
69 { currency },
70 `Currency set to ${currency}`,
71 "Failed to update currencies"
72 );
73 }
74
75 async function updateBitcoinDisplayFormat(bitcoinDisplayFormat: string) {
76 await updateSettings(
77 { bitcoinDisplayFormat },
78 "Bitcoin display format updated",
79 "Failed to update bitcoin display format"
80 );
81 }
82
83 if (!info) {
84 return <Loading />;
85 }
86
87 const paidThemes = ["matrix", "ghibli", "claymorphism"];
88 const hasPlan = !!albyMe?.subscription.plan_code;
89
90 const darkModeOptions: {
91 value: DarkMode;
92 icon: React.ReactNode;
93 label: string;
94 }[] = [
95 { value: "light", icon: <SunIcon className="size-4" />, label: "Light" },
96 { value: "dark", icon: <MoonIcon className="size-4" />, label: "Dark" },
97 {
98 value: "system",
99 icon: <MonitorIcon className="size-4" />,
100 label: "System",
101 },
102 ];
103
104 return (
105 <>
106 <SettingsHeader
107 pageTitle="Settings"
108 title="General"
109 description="Customize how Alby Hub looks and feels."
110 />
111 <div className="flex flex-col gap-6 pb-10">
112 <div className="flex flex-col gap-4">
113 <div className="flex flex-col gap-1 text-sm">
114 <h3 className="font-semibold">Appearance</h3>
115 <p className="text-muted-foreground">
116 Choose a theme and light/dark mode.
117 </p>
118 </div>
119 <div className="space-y-6">
120 <div className="space-y-3">
121 <Label id="theme-label">Theme</Label>
122 <div
123 role="radiogroup"
124 aria-labelledby="theme-label"
125 className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3"
126 >
127 {Themes.map((t) => {
128 const isPaidTheme = paidThemes.includes(t);
129 const isDisabled = isPaidTheme && !hasPlan;
130 const isSelected = theme === t;
131
132 return (
133 <button
134 key={t}
135 type="button"
136 role="radio"
137 aria-checked={isSelected}
138 onClick={() => {
139 if (isDisabled) {
140 setShowUpgradeDialog(true);
141 return;
142 }
143 setTheme(t);
144 toast("Theme updated.");
145 }}
146 className={cn(
147 "group relative flex flex-col rounded-lg border-2 text-left transition-all w-full overflow-hidden hover:border-primary/50",
148 isSelected
149 ? "border-primary ring-2 ring-primary/20"
150 : "border-border",
151 isDisabled
152 ? "cursor-not-allowed opacity-60 hover:border-border"
153 : "cursor-pointer"
154 )}
155 >
156 <ThemePreview theme={t} />
157 <div className="flex items-center justify-center gap-1.5 py-1.5 px-1 w-full">
158 <span className="text-xs font-medium capitalize truncate">
159 {t}
160 </span>
161 {isPaidTheme && (
162 <Badge
163 variant="outline"
164 className="text-[10px] px-1 py-0"
165 >
166 <StarsIcon className="size-2.5" />
167 Pro
168 </Badge>
169 )}
170 </div>
171 {isSelected && (
172 <div className="absolute top-1.5 right-1.5 size-4 rounded-full bg-primary flex items-center justify-center">
173 <CheckIcon className="size-2.5 text-primary-foreground" />
174 </div>
175 )}
176 {isDisabled && (
177 <div className="absolute top-1.5 right-1.5 size-4 rounded-full bg-background flex items-center justify-center">
178 <LockIcon className="size-2.5 text-foreground" />
179 </div>
180 )}
181 </button>
182 );
183 })}
184 </div>
185 <UpgradeDialog
186 open={showUpgradeDialog}
187 onOpenChange={setShowUpgradeDialog}
188 />
189 </div>
190
191 <div className="space-y-3">
192 <Label id="dark-mode-label">Mode</Label>
193 <Tabs value={darkMode}>
194 <TabsList>
195 {darkModeOptions.map((option) => (
196 <TabsTrigger
197 value={option.value}
198 onClick={() => {
199 setDarkMode(option.value);
200 toast("Appearance updated.");
201 }}
202 className="px-3"
203 >
204 {option.icon}
205 {option.label}
206 </TabsTrigger>
207 ))}
208 </TabsList>
209 </Tabs>
210 </div>
211 </div>
212 </div>
213 <Separator />
214 <div className="flex flex-col gap-4">
215 <div className="flex flex-col gap-1 text-sm">
216 <h3 className="font-semibold">Units & Currency</h3>
217 <p className="text-muted-foreground">
218 Choose how amounts are displayed.
219 </p>
220 </div>
221 <div className="space-y-4">
222 <div className="grid gap-1.5">
223 <Label htmlFor="bitcoinDisplayFormat">Display Unit</Label>
224 <Select
225 value={info.bitcoinDisplayFormat}
226 onValueChange={updateBitcoinDisplayFormat}
227 >
228 <SelectTrigger className="w-full md:w-60">
229 <SelectValue placeholder="Select a display format" />
230 </SelectTrigger>
231 <SelectContent>
232 <SelectItem value={BITCOIN_DISPLAY_FORMAT_BIP177}>
233 ₿
234 </SelectItem>
235 <SelectItem value={BITCOIN_DISPLAY_FORMAT_SATS}>
236 sats
237 </SelectItem>
238 </SelectContent>
239 </Select>
240 </div>
241 <div className="grid gap-1.5">
242 <Label htmlFor="currency">Fiat Currency</Label>
243 <Select
244 value={info?.currency}
245 onValueChange={updateCurrency}
246 disabled={isCurrenciesLoading}
247 >
248 <SelectTrigger className="w-full md:w-60">
249 <SelectValue
250 placeholder={
251 isCurrenciesLoading
252 ? "Loading currencies..."
253 : "Select a currency"
254 }
255 />
256 </SelectTrigger>
257 <SelectContent>
258 {currencies.map(([code, name]) => (
259 <SelectItem key={code} value={code}>
260 {name} ({code})
261 </SelectItem>
262 ))}
263 </SelectContent>
264 </Select>
265 </div>
266 </div>
267 </div>
268 </div>
269 </>
270 );
271 }
272
273 export default Settings;
274