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