add layout for my account, preference, notiication and import

This commit is contained in:
vergiLgood1 2025-03-07 23:08:16 +07:00
parent aa52dd0ca4
commit 276a63a4dc
13 changed files with 1386 additions and 46 deletions

View File

@ -1,3 +1,3 @@
{
"files.autoSave": "afterDelay"
"files.autoSave": "off"
}

View File

@ -69,7 +69,7 @@ export default async function Layout({
</div>
<div className="flex items-center gap-2">
<InboxDrawer showTitle={true} showAvatar={false} />
<ThemeSwitcher showTitle={true} />
<ThemeSwitcher showTitle={true} title="Theme" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">

View File

@ -0,0 +1,108 @@
import React from "react";
import { Button } from "@/app/_components/ui/button";
import { Card, CardContent } from "@/app/_components/ui/card";
import { ScrollArea } from "@/app/_components/ui/scroll-area";
import { Separator } from "@/app/_components/ui/separator";
import { Upload } from "lucide-react";
import { Badge } from "../../ui/badge";
import {
IconBrandGoogleAnalytics,
IconCsv,
IconFileExcel,
IconFileWord,
IconHtml,
IconMarkdown,
TablerIcon,
} from "@tabler/icons-react";
// Data for import options
type ImportOption = {
name: string;
icon: TablerIcon;
beta?: boolean;
new?: boolean;
sync?: boolean;
};
const importOptions: ImportOption[] = [
{ name: "Excel", icon: IconFileExcel },
{ name: "Google Sheets", icon: IconBrandGoogleAnalytics },
{ name: "CSV", icon: IconCsv },
{ name: "Text & Markdown", icon: IconMarkdown },
{ name: "Word", icon: IconFileWord },
];
const ImportData = () => {
return (
<ScrollArea className="h-[calc(100vh-140px)] w-full">
<div className="min-h-screen p-8 max-w-4xl mx-auto space-y-8">
<div className="space-y-4 mb-4">
<h1 className="font-medium">Import data</h1>
<Separator />
<div>
<h2 className="text-sm font-semibold mb-2">
Import data from other apps and files into Sigap.
</h2>
<p className="text-sm text-muted-foreground">
You can import crime data from csv files, text files, and other
apps into Sigap.
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{importOptions.map((option) => (
<Card
key={option.name}
className="cursor-pointer bg-secondary hover:bg-muted-foreground/15"
>
<CardContent className="flex items-center justify-between p-4">
<span className="text-lg">
{React.createElement(option.icon)}
</span>
<div className="flex-1 ml-3">
<p className="text-sm font-medium">{option.name}</p>
</div>
{option.beta && (
<Badge className="text-xs text-red-600 w-fit bg-secondary-foreground dark:bg-secondary-foreground hover:bg-secondary-foreground dark:hover:bg-secondary-foreground text-[10px] rounded px-1 py-0">
Beta
</Badge>
)}
{option.new && (
<Badge className="text-xs text-green-500 w-fit bg-secondary-foreground dark:bg-secondary-foreground hover:bg-secondary-foreground dark:hover:bg-secondary-foreground text-[10px] rounded px-1 py-0">
New
</Badge>
)}
{option.sync && (
<Badge className="text-xs text-blue-500 w-fit bg-secondary-foreground dark:bg-secondary-foreground hover:bg-secondary-foreground dark:hover:bg-secondary-foreground text-[10px] rounded px-1 py-0">
Sync
</Badge>
)}
</CardContent>
</Card>
))}
</div>
</div>
{/* <div>
<p className="text-sm text-muted-foreground text-center mb-2">
Don't see the app you use? Import a ZIP file, and Notion will
convert it.
</p>
<div className="flex flex-col items-center gap-2">
<label
htmlFor="file-upload"
className="w-full flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg cursor-pointer p-6 hover:border-gray-400"
>
<Upload className="w-10 h-10 text-gray-500 mb-2" />
<span className="text-sm text-gray-500">
Drag and drop your ZIP files here
</span>
<input id="file-upload" type="file" className="hidden" />
</label>
</div>
</div> */}
</div>
</ScrollArea>
);
};
export default ImportData;

View File

@ -0,0 +1,150 @@
"use client";
import { useState, useEffect } from "react";
import { Switch } from "@/app/_components/ui/switch";
import { Separator } from "@/app/_components/ui/separator";
import { ScrollArea } from "@/app/_components/ui/scroll-area";
import {
type NotificationPreferences,
defaultNotificationPreferences,
getNotificationPreferences,
saveNotificationPreferences,
applyNotificationPreferences,
} from "@/utils/notification-cookies-manager";
import { toast } from "sonner";
export default function NotificationsSetting() {
const [isLoading, setIsLoading] = useState(true);
const [lastToastTime, setLastToastTime] = useState(0);
const [notificationPreferences, setNotificationPreferences] =
useState<NotificationPreferences | null>(null);
// Show toast with debounce to prevent too many notifications
const showSavedToast = () => {
// const now = Date.now();
// if (now - lastToastTime > 2000) {
// toast("Preferences saved");
// setLastToastTime(now);
// }
};
// Load saved preferences on component mount
useEffect(() => {
try {
const savedPreferences = getNotificationPreferences();
setNotificationPreferences(savedPreferences);
} catch (error) {
console.error("Error loading notification preferences:", error);
setNotificationPreferences(defaultNotificationPreferences);
} finally {
setIsLoading(false);
}
}, []);
// Jika masih loading atau notificationPreferences masih null, tampilkan loading
if (isLoading || !notificationPreferences) {
return (
<div className="flex items-center justify-center h-[calc(100vh-140px)]">
Loading...
</div>
);
}
// Gunakan nilai default jika notificationPreferences belum ada
const {
mobilePushNotifications,
emailNotifications,
announcementNotifications,
} = notificationPreferences || defaultNotificationPreferences;
// Generic handler untuk update state dan menyimpan preferensi
const updatePreference = (
key: keyof NotificationPreferences,
value: boolean
) => {
const newPreferences = { ...notificationPreferences, [key]: value };
setNotificationPreferences(newPreferences);
saveNotificationPreferences(newPreferences);
applyNotificationPreferences(newPreferences);
showSavedToast();
};
return (
<ScrollArea className="h-[calc(100vh-140px)] w-full">
<div className="min-h-screen p-8 max-w-4xl mx-auto space-y-14">
<div>
<div className="space-y-4 mb-4">
<h1 className="font-medium">Notifications</h1>
<Separator />
</div>
{/* Mobile push notification Section */}
<div>
<div className="space-y-8">
<div className="flex justify-between items-center">
<div>
<h3 className="text-sm font-medium">
Mobile push notifications
</h3>
<p className="text-sm text-neutral-400">
Receive push notifications on your mobile device when you're
offline.
</p>
</div>
<Switch
checked={mobilePushNotifications}
onCheckedChange={(checked) =>
updatePreference("mobilePushNotifications", checked)
}
/>
</div>
</div>
</div>
</div>
{/* Email notifications Section */}
<div>
<h2 className="font-medium">Email Notifications</h2>
<Separator className="my-4" />
<div className="space-y-8">
<div className="flex justify-between items-center">
<div>
<h3 className="text-sm font-medium">
Always send email notifications
</h3>
<p className="text-sm text-neutral-400">
Receive emails about activity in your workspace, even when
you're active on the app.
</p>
</div>
<Switch
checked={emailNotifications}
onCheckedChange={(checked) =>
updatePreference("emailNotifications", checked)
}
/>
</div>
<div className="flex justify-between items-center">
<div>
<h3 className="text-sm font-medium">
Announcements and Update Emails
</h3>
<p className="text-sm text-neutral-400">
Receive occasional emails about product launches and new
features from Notion.
</p>
</div>
<Switch
checked={announcementNotifications}
onCheckedChange={(checked) =>
updatePreference("announcementNotifications", checked)
}
/>
</div>
</div>
</div>
</div>
</ScrollArea>
);
}

View File

@ -0,0 +1,433 @@
"use client";
import { useState, useEffect } from "react";
import { ChevronDown } from "lucide-react";
import { Switch } from "@/app/_components/ui/switch";
import { Separator } from "@/app/_components/ui/separator";
import { ScrollArea } from "@/app/_components/ui/scroll-area";
import { ThemeSwitcher } from "../../theme-switcher";
import DropdownSwitcher from "../../custom-dropdown-switcher";
import {
type CookiePreferences,
defaultCookiePreferences,
getCookiePreferences,
saveCookiePreferences,
getLanguagePreference,
saveLanguagePreference,
getTimezonePreference,
saveTimezonePreference,
getWeekStartPreference,
saveWeekStartPreference,
getAutoTimezonePreference,
saveAutoTimezonePreference,
applyCookiePreferences,
} from "@/utils/cookies-manager";
import { toast } from "sonner";
import { initialTimezones, TimezoneType } from "@/prisma/data/timezones";
import { languages, LanguageType } from "@/prisma/data/languages";
export default function PreferencesSettings() {
const [language, setLanguage] = useState("en-US");
const [languageLabel, setLanguageLabel] = useState("English");
const [startWeekOnMonday, setStartWeekOnMonday] = useState(false);
const [autoTimezone, setAutoTimezone] = useState(true);
const [timezone, setTimezone] = useState("Asia/Jakarta");
const [timezoneLabel, setTimezoneLabel] = useState("(GMT+7:00) Jakarta");
const [timezones, setTimezones] = useState<LanguageType[]>([]);
const [availableLanguages, setAvailableLanguages] = useState<TimezoneType[]>(
[]
);
const [isLoading, setIsLoading] = useState(true);
const [cookiePreferences, setCookiePreferences] = useState<CookiePreferences>(
defaultCookiePreferences
);
const [lastToastTime, setLastToastTime] = useState(0);
// Show toast with debounce to prevent too many notifications
const showSavedToast = () => {
const now = Date.now();
// Only show toast if it's been at least 2 seconds since the last one
// if (now - lastToastTime > 2000) {
// toast("Preferences saved");
// setLastToastTime(now);
// }
};
// Load data when component mounts
useEffect(() => {
try {
// Load cookie preferences
const savedCookiePreferences = getCookiePreferences();
setCookiePreferences(savedCookiePreferences);
// Load language preference
const savedLanguage = getLanguagePreference();
if (savedLanguage) {
setLanguage(savedLanguage);
}
// Load week start preference
const savedWeekStart = getWeekStartPreference();
setStartWeekOnMonday(savedWeekStart);
// Load auto timezone preference
const savedAutoTimezone = getAutoTimezonePreference();
setAutoTimezone(savedAutoTimezone);
// Load timezone preference
const savedTimezone = getTimezonePreference();
if (savedTimezone) {
setTimezone(savedTimezone);
}
// Load timezone and language data
if (Array.isArray(initialTimezones)) {
setTimezones(initialTimezones);
} else {
console.warn("Using fallback timezone data");
setTimezones([
{
value: "Asia/Jakarta",
prefix: ChevronDown,
label: "Jakarta",
subLabel: "(GMT+7:00)",
},
]);
}
if (Array.isArray(languages)) {
setAvailableLanguages(languages);
} else {
console.warn("Using fallback language data");
setAvailableLanguages([
{
value: "en-US",
prefix: ChevronDown,
label: "English",
subLabel: "English (US)",
},
]);
}
} catch (error) {
console.error("Error loading data:", error);
setTimezones([
{
value: "Asia/Jakarta",
prefix: ChevronDown,
label: "Jakarta",
subLabel: "(GMT+7:00)",
},
]);
setAvailableLanguages([
{
value: "en-US",
prefix: ChevronDown,
label: "English",
subLabel: "English (US)",
},
]);
} finally {
setIsLoading(false);
}
}, []);
// Update labels when data is loaded
useEffect(() => {
if (!isLoading) {
// Set language label
const selectedLanguage = availableLanguages.find(
(lang) => lang.value === language
);
if (selectedLanguage) {
setLanguageLabel(selectedLanguage.label);
}
// Set timezone label
const selectedTimezone = timezones.find((tz) => tz.value === timezone);
if (selectedTimezone) {
setTimezoneLabel(
selectedTimezone.subLabel + " " + selectedTimezone.label
);
}
}
}, [isLoading, language, timezone, availableLanguages, timezones]);
// Detect browser language
useEffect(() => {
if (
!isLoading &&
availableLanguages.length > 0 &&
!getLanguagePreference()
) {
const browserLang = navigator.language;
const matchedLang = availableLanguages.find(
(lang) =>
lang.value === browserLang ||
lang.value.split("-")[0] === browserLang.split("-")[0]
);
if (matchedLang) {
setLanguage(matchedLang.value);
setLanguageLabel(matchedLang.label);
// Save the detected language
saveLanguagePreference(matchedLang.value);
}
}
}, [isLoading, availableLanguages]);
// Handle language change
const handleLanguageChange = (value: string) => {
setLanguage(value);
const selectedLanguage = availableLanguages.find(
(lang) => lang.value === value
);
if (selectedLanguage) {
setLanguageLabel(selectedLanguage.label);
}
// Save immediately
saveLanguagePreference(value);
showSavedToast();
};
// Handle timezone change
const handleTimezoneChange = (value: string) => {
setTimezone(value);
const selectedTimezone = timezones.find((tz) => tz.value === value);
if (selectedTimezone) {
setTimezoneLabel(
selectedTimezone.subLabel + " " + selectedTimezone.label
);
}
// Save immediately
saveTimezonePreference(value);
showSavedToast();
};
// Handle week start change
const handleWeekStartChange = (value: boolean) => {
setStartWeekOnMonday(value);
// Save immediately
saveWeekStartPreference(value);
showSavedToast();
};
// Handle auto timezone change
const handleAutoTimezoneChange = (value: boolean) => {
setAutoTimezone(value);
// Save immediately
saveAutoTimezonePreference(value);
showSavedToast();
};
// Handle cookie preference changes
const handleCookiePreferenceChange = (
type: keyof CookiePreferences,
value: boolean
) => {
const newPreferences = { ...cookiePreferences, [type]: value };
setCookiePreferences(newPreferences);
// Save and apply immediately
saveCookiePreferences(newPreferences);
applyCookiePreferences(newPreferences);
showSavedToast();
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-[calc(100vh-140px)]">
Loading...
</div>
);
}
return (
<ScrollArea className="h-[calc(100vh-140px)] w-full">
<div className="min-h-screen p-8 max-w-4xl mx-auto space-y-14">
<div>
<div className="space-y-4 mb-4">
<h1 className="font-medium">Preferences</h1>
<Separator className="" />
</div>
{/* Appearance Section */}
<div className="">
<div className="flex justify-between items-center">
<h2 className="font-medium">Appearance</h2>
<div className="flex items-center cursor-pointer">
<ThemeSwitcher
showTitle={true}
prefix={false}
suffix={ChevronDown}
variant={"ghost"}
/>
</div>
</div>
<p className="text-sm text-muted-foreground">
Customize how the application looks on your device.
</p>
</div>
</div>
{/* Language & Time Section */}
<div className="">
<div className="space-y-4 mb-4">
<h2 className="font-medium">Language & Time</h2>
<Separator className="my-4" />
</div>
<div className="space-y-8">
{/* Language Subsection */}
<div className="flex justify-between items-center mb-2">
<div>
<h3 className="text-sm font-medium">Language</h3>
<p className="text-sm text-muted-foreground">
Change the language used in the user interface.
</p>
</div>
<DropdownSwitcher
key={language}
options={availableLanguages}
prefix={false}
suffix={ChevronDown}
showTitle={true}
variant="ghost"
title={languageLabel}
selectedValue={language}
searchable={true}
searchPlaceholder="Search languages..."
onChange={handleLanguageChange}
currentLabel="Current language"
selectLabel="Select a language"
/>
</div>
{/* Start week on Monday Subsection */}
<div className="flex justify-between items-center mb-2">
<div>
<h3 className="text-sm font-medium">Start week on Monday</h3>
<p className="text-sm text-muted-foreground">
This will change how all calendars in your app look.
</p>
</div>
<Switch
checked={startWeekOnMonday}
onCheckedChange={handleWeekStartChange}
/>
</div>
{/* Auto Timezone Subsection */}
<div className="flex justify-between items-center mb-2">
<div>
<h3 className="text-sm font-medium">
Set timezone automatically using your location
</h3>
<p className="text-sm text-muted-foreground">
Reminders, notifications and emails are delivered based on
your time zone.
</p>
</div>
<Switch
checked={autoTimezone}
onCheckedChange={handleAutoTimezoneChange}
/>
</div>
{/* Timezone Subsection */}
<div className="flex justify-between items-center mb-2">
<div>
<h3 className="text-sm font-medium">Timezone</h3>
<p className="text-sm text-muted-foreground">
Current timezone setting.
</p>
</div>
<DropdownSwitcher
key={timezone}
options={timezones}
prefix={false}
suffix={ChevronDown}
showTitle={true}
variant="ghost"
title={timezoneLabel}
selectedValue={timezone}
searchable={true}
searchPlaceholder="Search for a timezone..."
onChange={handleTimezoneChange}
currentLabel="Current timezone"
selectLabel="Select a timezone"
disabled={autoTimezone}
/>
</div>
</div>
</div>
{/* Privacy Section */}
<div className="">
<div className="space-y-4 mb-4">
<h2 className="font-medium">Privacy</h2>
<Separator className="" />
</div>
<div className="space-y-8">
{/* Cookies Settings */}
<div className="flex justify-between items-center mb-2">
<div>
<h3 className="text-sm font-medium">Necessary Cookies</h3>
<p className="text-sm text-muted-foreground">
Required for the website to function properly. Cannot be
disabled.
</p>
</div>
<Switch checked={true} disabled className="opacity-50" />
</div>
<div className="flex justify-between items-center mb-2">
<div>
<h3 className="text-sm font-medium">Functional Cookies</h3>
<p className="text-sm text-muted-foreground">
Enable enhanced functionality and personalization.
</p>
</div>
<Switch
checked={cookiePreferences.functional}
onCheckedChange={(checked) =>
handleCookiePreferenceChange("functional", checked)
}
/>
</div>
<div className="flex justify-between items-center mb-2">
<div>
<h3 className="text-sm font-medium">Analytics Cookies</h3>
<p className="text-sm text-muted-foreground">
Help us improve by collecting anonymous information about how
you use the site.
</p>
</div>
<Switch
checked={cookiePreferences.analytics}
onCheckedChange={(checked) =>
handleCookiePreferenceChange("analytics", checked)
}
/>
</div>
<div className="flex justify-between items-center mb-2">
<div>
<h3 className="text-sm font-medium">Marketing Cookies</h3>
<p className="text-sm text-muted-foreground">
Used to display personalized advertisements based on your
browsing habits.
</p>
</div>
<Switch
checked={cookiePreferences.marketing || false}
onCheckedChange={(checked) =>
handleCookiePreferenceChange("marketing", checked)
}
/>
</div>
</div>
</div>
</div>
</ScrollArea>
);
}

View File

@ -107,12 +107,14 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
return (
<ScrollArea className="h-[calc(100vh-140px)] w-full ">
<div className="space-y-16 px-20 py-10">
<div className="space-y-14 min-h-screen p-8 max-w-4xl mx-auto">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="space-y-4">
<div className="space-y-4 mb-4">
<h3 className="text-lg font-semibold">Account</h3>
<Separator className="" />
</div>
<div className="flex items-start gap-4">
<div
className="relative cursor-pointer group"
@ -186,8 +188,10 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
</Form>
<div className="">
<div className="space-y-4 mb-4">
<h3 className="text-base font-medium">Account security</h3>
<Separator className="my-2" />
<Separator className="" />
</div>
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
@ -239,8 +243,10 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
</div>
<div>
<h3 className="text-base font-medium">Support</h3>
<Separator className="my-2" />
<div className="space-y-4 mb-4">
<h3 className="text-base font-medium">Notifications</h3>
<Separator className="" />
</div>
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>

View File

@ -16,6 +16,8 @@ import {
} from "@/app/_components/ui/avatar";
import {
IconBell,
IconFileExport,
IconFileImport,
IconFingerprint,
IconLock,
IconPlugConnected,
@ -29,6 +31,10 @@ import { ProfileSettings } from "./profile-settings";
import { DialogTitle } from "@radix-ui/react-dialog";
import { useState } from "react";
import NotificationsSetting from "./notification-settings";
import PreferencesSettings from "./preference-settings";
import ImportData from "./import-data";
interface SettingsDialogProps {
user: User | null;
trigger: React.ReactNode;
@ -78,19 +84,13 @@ export function SettingsDialog({
id: "preferences",
icon: IconSettings,
title: "Preferences",
content: <div>Preferences content</div>,
content: <PreferencesSettings />,
},
{
id: "notifications",
icon: IconBell,
title: "Notifications",
content: <div>Notifications content</div>,
},
{
id: "connections",
icon: IconPlugConnected,
title: "Connections",
content: <div>Connections content</div>,
content: <NotificationsSetting />,
},
],
},
@ -98,28 +98,16 @@ export function SettingsDialog({
title: "Workspace",
tabs: [
{
id: "general",
icon: IconWorld,
title: "General",
content: <div>General content</div>,
id: "import",
icon: IconFileImport,
title: "Import",
content: <ImportData />,
},
{
id: "members",
icon: IconUsers,
title: "Members",
content: <div>Members content</div>,
},
{
id: "security",
icon: IconLock,
title: "Security",
content: <div>Security content</div>,
},
{
id: "identity",
icon: IconFingerprint,
title: "Identity",
content: <div>Identity content</div>,
id: "export",
icon: IconFileExport,
title: "Export",
content: <div>Export</div>,
},
],
},

View File

@ -0,0 +1,270 @@
"use client";
import React, {
useEffect,
useState,
useMemo,
useRef,
useCallback,
} from "react";
import { Button } from "@/app/_components/ui/button";
import { Check, Search, type LucideIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/_components/ui/dropdown-menu";
import { Badge } from "@/app/_components/ui/badge";
import { Input } from "@/app/_components/ui/input";
import { cn } from "@/lib/utils";
type Option<T> = {
value: T;
prefix?: LucideIcon;
label: string;
subLabel?: string;
beta?: boolean;
isCurrent?: boolean;
};
type Variant = "outline" | "ghost";
interface DropdownSwitcherProps<T> {
options: Option<T>[];
selectedValue: T;
onChange: (value: T) => void;
showTitle?: boolean;
title?: string;
prefix?: boolean;
suffix?: LucideIcon;
variant?: Variant;
searchable?: boolean;
searchPlaceholder?: string;
currentLabel?: string;
selectLabel?: string;
disabled?: boolean;
}
const ICON_SIZE = 16;
const DropdownSwitcher = <T extends string>({
options,
selectedValue,
onChange,
showTitle = false,
title = "Select",
prefix = true,
suffix,
variant = "outline",
searchable = false,
searchPlaceholder = "Search...",
currentLabel = "Current timezone",
selectLabel = "Select a timezone",
disabled = false,
}: DropdownSwitcherProps<T>) => {
const [searchQuery, setSearchQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const currentOption = useMemo(
() =>
options.find((option) => option.value === selectedValue) || options[0],
[selectedValue, options]
);
const filteredOptions = useMemo(() => {
if (!searchQuery.trim()) return options;
const query = searchQuery.toLowerCase();
return options.filter(
(option) =>
option.label.toLowerCase().includes(query) ||
option.value.toLowerCase().includes(query) ||
(option.subLabel && option.subLabel.toLowerCase().includes(query))
);
}, [options, searchQuery]);
const { currentOptions, selectableOptions } = useMemo(
() => ({
currentOptions: filteredOptions.filter(
(opt) => opt.value === selectedValue
),
selectableOptions: filteredOptions.filter(
(opt) => opt.value !== selectedValue
),
}),
[filteredOptions, selectedValue]
);
useEffect(() => {
if (!isOpen) {
setSearchQuery("");
}
}, [isOpen]);
useEffect(() => {
if (isOpen && searchable) {
const timeoutId = setTimeout(() => {
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, 10);
return () => clearTimeout(timeoutId);
}
}, [isOpen, searchable]);
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
},
[]
);
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open);
}, []);
const handleOptionSelect = useCallback(
(value: T) => {
onChange(value);
setIsOpen(false);
},
[onChange]
);
const handleSearchInteraction = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation();
},
[]
);
return (
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={variant}
size={showTitle ? "sm" : "icon"}
className={cn(
variant !== "outline" ? "" : "border-2",
showTitle ? "flex justify-center items-center" : ""
)}
aria-label={`Current selection: ${selectedValue}`}
disabled={disabled}
>
{prefix && currentOption.prefix && (
<currentOption.prefix
size={ICON_SIZE}
className="text-muted-foreground"
/>
)}
{showTitle && (
<span className="text-muted-foreground font-medium">{title}</span>
)}
{!prefix &&
suffix &&
React.createElement(suffix, {
size: ICON_SIZE,
className: "text-muted-foreground",
})}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[300px] max-h-[400px] overflow-auto"
align="start"
onCloseAutoFocus={(e) => e.preventDefault()}
>
{searchable && (
<div
className="sticky top-0 bg-background z-50 px-2 py-2"
onClick={handleSearchInteraction}
onMouseDown={handleSearchInteraction}
>
<div className="relative"></div>
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder={searchPlaceholder}
value={searchQuery}
onChange={handleSearchChange}
className="pl-8 h-9"
onClick={handleSearchInteraction}
onMouseDown={handleSearchInteraction}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
setIsOpen(false);
}
}}
disabled={disabled}
/>
</div>
)}
{currentOptions.length > 0 && (
<>
<div className="px-2 py-1.5">
<p className="text-sm text-muted-foreground mb-1">
{currentLabel}
</p>
{currentOptions.map((option) => (
<DropdownMenuItem
key={option.value}
className="flex items-center justify-between py-0"
>
<div className="flex flex-col">
<span className="font-medium">{option.label}</span>
{option.subLabel && (
<span className="text-sm text-muted-foreground">
{option.subLabel}
</span>
)}
</div>
<Check className="h-4 w-4" />
</DropdownMenuItem>
))}
</div>
<DropdownMenuSeparator />
</>
)}
<div className="px-2 py-1.5">
<p className="text-sm text-muted-foreground mb-1">{selectLabel}</p>
{selectableOptions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No timezones found
</p>
) : (
selectableOptions.map((option) => (
<DropdownMenuItem
key={option.value}
className="flex items-center justify-between py-2"
onClick={() => handleOptionSelect(option.value)}
disabled={disabled}
>
<div className="flex flex-col">
<span className="font-medium">{option.label}</span>
{option.subLabel && (
<span className="text-sm text-muted-foreground">
{option.subLabel}
</span>
)}
</div>
{option.beta && (
<Badge className="w-fit bg-secondary-foreground dark:bg-muted hover:bg-secondary-foreground dark:hover:bg-muted text-[10px] rounded px-1 py-0">
BETA
</Badge>
)}
</DropdownMenuItem>
))
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default DropdownSwitcher;

View File

@ -14,8 +14,20 @@ import { Laptop, Moon, Sun, type LucideIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState, useMemo } from "react";
type Variant =
| "link"
| "outline"
| "default"
| "destructive"
| "secondary"
| "ghost";
interface ThemeSwitcherComponentProps {
showTitle?: boolean;
title?: string;
prefix?: boolean;
suffix?: LucideIcon;
variant?: Variant;
}
type ThemeOption = {
@ -34,6 +46,10 @@ const themeOptions: ThemeOption[] = [
const ThemeSwitcherComponent = ({
showTitle = false,
title,
prefix = true,
suffix,
variant = "outline",
}: ThemeSwitcherComponentProps) => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
@ -56,18 +72,28 @@ const ThemeSwitcherComponent = ({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
variant={variant}
size={showTitle ? "sm" : "icon"}
className={`border-2 ${showTitle ? "flex justify-center items-center" : ""}`}
aria-label={`Current theme: theme`}
className={`${variant != "outline" ? "" : "border-2"} ${showTitle ? "flex justify-center items-center" : ""}`}
aria-label={`Current theme: ${theme}`}
>
{prefix && (
<currentTheme.icon
size={ICON_SIZE}
className="text-muted-foreground"
/>
{showTitle && (
<span className="text-muted-foreground font-medium">Theme</span>
)}
{showTitle && (
<span className="text-muted-foreground font-medium">
{title || currentTheme.label}
</span>
)}
{!prefix &&
suffix &&
React.createElement(suffix, {
size: ICON_SIZE,
className: "text-muted-foreground",
})}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-content" align="start">

View File

@ -0,0 +1,54 @@
import { ChevronDown, LucideIcon } from "lucide-react";
export type LanguageType = {
value: string;
prefix?: LucideIcon;
label: string;
subLabel: string;
beta?: boolean;
};
export const languages = [
{
value: "en-US",
prefix: ChevronDown,
label: "English",
subLabel: "English (US)",
beta: true,
},
{
value: "id-ID",
prefix: ChevronDown,
label: "Indonesian",
subLabel: "Indonesia",
beta: true,
},
{
value: "ja-JP",
prefix: ChevronDown,
label: "日本語",
subLabel: "Japanese",
beta: true,
},
{
value: "ko-KR",
prefix: ChevronDown,
label: "한국어",
subLabel: "Korean",
beta: true,
},
{
value: "zh-CN",
prefix: ChevronDown,
label: "中文",
subLabel: "Chinese (Simplified)",
beta: true,
},
{
value: "es-419",
prefix: ChevronDown,
label: "Español (Latinoamérica)",
subLabel: "Spanish (Latin America)",
beta: true,
},
];

View File

@ -0,0 +1,42 @@
import { ChevronDown, LucideIcon } from "lucide-react";
export type TimezoneType = {
value: string;
prefix?: LucideIcon;
label: string;
subLabel: string;
};
// Initial timezone options
export const initialTimezones = [
{
value: "Asia/Jakarta",
prefix: ChevronDown,
label: "Jakarta",
subLabel: "(GMT+7:00)",
},
{
value: "Asia/Singapore",
prefix: ChevronDown,
label: "Singapore",
subLabel: "(GMT+8:00)",
},
{
value: "Asia/Tokyo",
prefix: ChevronDown,
label: "Tokyo",
subLabel: "(GMT+9:00)",
},
{
value: "Australia/Adelaide",
prefix: ChevronDown,
label: "Adelaide",
subLabel: "(GMT+9:30)",
},
{
value: "Australia/Sydney",
prefix: ChevronDown,
label: "Sydney",
subLabel: "(GMT+10:00)",
},
];

View File

@ -0,0 +1,162 @@
// Cookie management utility functions
// Cookie types
export type CookiePreferences = {
necessary: boolean;
functional: boolean;
analytics: boolean;
marketing?: boolean;
};
// Default cookie preferences
export const defaultCookiePreferences: CookiePreferences = {
necessary: true, // Always true, cannot be disabled
functional: false,
analytics: false,
marketing: false,
};
// Cookie names
const COOKIE_PREFERENCES_KEY = "cookie-preferences";
const LANGUAGE_PREFERENCE_KEY = "language-preference";
const TIMEZONE_PREFERENCE_KEY = "timezone-preference";
const WEEK_START_PREFERENCE_KEY = "week-start-preference";
const AUTO_TIMEZONE_PREFERENCE_KEY = "auto-timezone-preference";
// Helper to set a cookie with expiration
export const setCookie = (
name: string,
value: string,
days: number = 365
): void => {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `; expires=${date.toUTCString()}`;
document.cookie = `${name}=${value}${expires}; path=/; SameSite=Lax`;
};
// Helper to get a cookie by name
export const getCookie = (name: string): string | null => {
if (typeof document === "undefined") return null;
const nameEQ = `${name}=`;
const ca = document.cookie.split(";");
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === " ") c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
}
return null;
};
// Helper to delete a cookie
export const deleteCookie = (name: string): void => {
setCookie(name, "", -1);
};
// Save cookie preferences
export const saveCookiePreferences = (preferences: CookiePreferences): void => {
// Always ensure necessary cookies are enabled
const prefsToSave = {
...preferences,
necessary: true,
};
setCookie(COOKIE_PREFERENCES_KEY, JSON.stringify(prefsToSave));
};
// Get cookie preferences
export const getCookiePreferences = (): CookiePreferences => {
try {
const preferences = getCookie(COOKIE_PREFERENCES_KEY);
if (preferences) {
return { ...defaultCookiePreferences, ...JSON.parse(preferences) };
}
} catch (error) {
console.error("Error parsing cookie preferences:", error);
}
return defaultCookiePreferences;
};
// Save language preference
export const saveLanguagePreference = (language: string): void => {
setCookie(LANGUAGE_PREFERENCE_KEY, language);
};
// Get language preference
export const getLanguagePreference = (): string | null => {
return getCookie(LANGUAGE_PREFERENCE_KEY);
};
// Save timezone preference
export const saveTimezonePreference = (timezone: string): void => {
setCookie(TIMEZONE_PREFERENCE_KEY, timezone);
};
// Get timezone preference
export const getTimezonePreference = (): string | null => {
return getCookie(TIMEZONE_PREFERENCE_KEY);
};
// Save week start preference
export const saveWeekStartPreference = (startOnMonday: boolean): void => {
setCookie(WEEK_START_PREFERENCE_KEY, startOnMonday ? "monday" : "sunday");
};
// Get week start preference
export const getWeekStartPreference = (): boolean => {
const pref = getCookie(WEEK_START_PREFERENCE_KEY);
return pref === "monday";
};
// Save auto timezone preference
export const saveAutoTimezonePreference = (auto: boolean): void => {
setCookie(AUTO_TIMEZONE_PREFERENCE_KEY, auto ? "true" : "false");
};
// Get auto timezone preference
export const getAutoTimezonePreference = (): boolean => {
const pref = getCookie(AUTO_TIMEZONE_PREFERENCE_KEY);
return pref === "true";
};
// Apply cookie preferences to the application
export const applyCookiePreferences = (
preferences: CookiePreferences
): void => {
// This function would implement the actual cookie policy enforcement
// For example, enabling/disabling analytics scripts based on preferences
if (preferences.analytics) {
// Enable analytics scripts
console.log("Analytics cookies enabled");
// Example: initAnalytics();
} else {
// Disable analytics scripts
console.log("Analytics cookies disabled");
// Example: disableAnalytics();
}
if (preferences.functional) {
// Enable functional cookies
console.log("Functional cookies enabled");
// Example: enableFunctionalFeatures();
} else {
// Disable functional cookies
console.log("Functional cookies disabled");
// Example: disableFunctionalFeatures();
}
if (preferences.marketing) {
// Enable marketing cookies
console.log("Marketing cookies enabled");
// Example: enableMarketingFeatures();
} else {
// Disable marketing cookies
console.log("Marketing cookies disabled");
// Example: disableMarketingFeatures();
}
};

View File

@ -0,0 +1,101 @@
// Notification preferences cookie management
// Notification preferences type
export type NotificationPreferences = {
mobilePushNotifications: boolean;
emailNotifications: boolean;
announcementNotifications: boolean;
};
// Default notification preferences
export const defaultNotificationPreferences: NotificationPreferences = {
mobilePushNotifications: true,
emailNotifications: true,
announcementNotifications: false,
};
// Cookie names
const NOTIFICATION_PREFERENCES_KEY = "notification-preferences";
// Helper to set a cookie with expiration
export const setCookie = (name: string, value: string, days = 365): void => {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `; expires=${date.toUTCString()}`;
document.cookie = `${name}=${value}${expires}; path=/; SameSite=Lax`;
};
// Helper to get a cookie by name
export const getCookie = (name: string): string | null => {
if (typeof document === "undefined") return null;
const nameEQ = `${name}=`;
const ca = document.cookie.split(";");
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === " ") c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
}
return null;
};
// Save notification preferences
export const saveNotificationPreferences = (
preferences: NotificationPreferences
): void => {
setCookie(NOTIFICATION_PREFERENCES_KEY, JSON.stringify(preferences));
};
// Get notification preferences
export const getNotificationPreferences = (): NotificationPreferences => {
try {
const preferences = getCookie(NOTIFICATION_PREFERENCES_KEY);
if (preferences) {
return { ...defaultNotificationPreferences, ...JSON.parse(preferences) };
}
} catch (error) {
console.error("Error parsing notification preferences:", error);
}
return defaultNotificationPreferences;
};
// Apply notification preferences to the application
export const applyNotificationPreferences = (
preferences: NotificationPreferences
): void => {
// This function would implement the actual notification settings
// For example, enabling/disabling push notifications
if (preferences.mobilePushNotifications) {
// Enable push notifications
console.log("Push notifications enabled");
// Example: requestPushPermission();
} else {
// Disable push notifications
console.log("Push notifications disabled");
// Example: disablePushNotifications();
}
if (preferences.emailNotifications) {
// Enable email notifications
console.log("Email notifications enabled");
// Example: enableEmailNotifications();
} else {
// Disable email notifications
console.log("Email notifications disabled");
// Example: disableEmailNotifications();
}
if (preferences.announcementNotifications) {
// Enable announcement notifications
console.log("Announcement notifications enabled");
// Example: subscribeToAnnouncementEmails();
} else {
// Disable announcement notifications
console.log("Announcement notifications disabled");
// Example: unsubscribeFromAnnouncementEmails();
}
};