Compare commits
No commits in common. "34b5300904718d995d08f5464dd6d142ac2ddef8" and "a8e147e52ac2335c53e4bfdbc2bd7b336ded929a" have entirely different histories.
34b5300904
...
a8e147e52a
9 changed files with 122 additions and 395 deletions
|
|
@ -203,7 +203,6 @@ export async function updatePreferences(input: {
|
|||
mutedAll: boolean;
|
||||
mutedTypes: string[];
|
||||
showCallsign: boolean;
|
||||
showVersionOverlay: boolean;
|
||||
leadOrActual: "lead" | "actual";
|
||||
discordEnabled: boolean;
|
||||
discordMutedTypes: string[];
|
||||
|
|
@ -227,7 +226,6 @@ export async function updatePreferences(input: {
|
|||
notifications: { mutedAll: input.mutedAll, mutedTypes },
|
||||
display: {
|
||||
showCallsign: input.showCallsign,
|
||||
showVersionOverlay: input.showVersionOverlay,
|
||||
leadOrActual: input.leadOrActual,
|
||||
},
|
||||
discord: {
|
||||
|
|
|
|||
|
|
@ -91,7 +91,6 @@ export default async function AccountPage() {
|
|||
mutedAll={notifications?.mutedAll ?? false}
|
||||
mutedTypes={notifications?.mutedTypes ?? []}
|
||||
showCallsign={fullUser.preferences?.display?.showCallsign ?? false}
|
||||
showVersionOverlay={fullUser.preferences?.display?.showVersionOverlay ?? true}
|
||||
leadOrActual={fullUser.preferences?.display?.leadOrActual ?? "lead"}
|
||||
discordEnabled={discord?.enabled ?? false}
|
||||
discordMutedTypes={discord?.mutedTypes ?? []}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,11 @@ import { hasIntelligenceQualification } from "@/utils/access-control/hasIntellig
|
|||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
|
||||
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
|
||||
import VersionOverlay from "@/components/static/VersionOverlay";
|
||||
import { LandingPage } from "@/components/frontend/LandingPage";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { CommandPalette } from "@/components/command-palette/CommandPalette";
|
||||
import { CommandPaletteProvider } from "@/components/command-palette/CommandPaletteContext";
|
||||
import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
|
||||
// Skip static prerendering of this layout (and all pages under (frontend)/).
|
||||
// The layout calls `getPayload()` at render time to resolve the current user,
|
||||
|
|
@ -35,9 +33,9 @@ export const metadata: Metadata = {
|
|||
{
|
||||
url: "/favicon.webp",
|
||||
type: "image/webp",
|
||||
rel: "icon",
|
||||
},
|
||||
],
|
||||
rel: "icon"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||
|
|
@ -131,9 +129,6 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
) : (
|
||||
<LandingPage />
|
||||
)}
|
||||
{(!user || user.preferences?.display?.showVersionOverlay !== false) && (
|
||||
<VersionOverlay canSeeCommit={isSuperuser(payload, user)} />
|
||||
)}
|
||||
{user && <GameTickRealtime />}
|
||||
{isLogistics && <ShipmentToasts />}
|
||||
<Toaster />
|
||||
|
|
|
|||
|
|
@ -275,13 +275,7 @@ export const Users: CollectionConfig = {
|
|||
{ label: "Actual", value: "actual" },
|
||||
],
|
||||
defaultValue: "lead",
|
||||
},
|
||||
{
|
||||
name: "showVersionOverlay",
|
||||
label: "Show version overlay at top of page",
|
||||
type: "checkbox",
|
||||
defaultValue: true,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,92 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { updatePreferences } from "@/app/(frontend)/account/actions";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
|
||||
import { updatePreferences } from "@/app/(frontend)/account/actions";
|
||||
|
||||
interface PreferencesFormProps {
|
||||
readonly mutedAll: boolean;
|
||||
readonly mutedTypes: readonly string[];
|
||||
readonly showCallsign: boolean;
|
||||
readonly showVersionOverlay: boolean;
|
||||
readonly leadOrActual: "lead" | "actual";
|
||||
readonly discordEnabled: boolean;
|
||||
readonly discordMutedTypes: readonly string[];
|
||||
mutedAll: boolean;
|
||||
mutedTypes: string[];
|
||||
showCallsign: boolean;
|
||||
leadOrActual: "lead" | "actual";
|
||||
discordEnabled: boolean;
|
||||
discordMutedTypes: string[];
|
||||
}
|
||||
|
||||
export function PreferencesForm({
|
||||
mutedAll: initialMutedAll,
|
||||
mutedTypes: initialMutedTypes,
|
||||
showCallsign: initialShowCallsign,
|
||||
showVersionOverlay: initialShowVersionOverlay,
|
||||
leadOrActual: initialLeadOrActual,
|
||||
discordEnabled: initialDiscordEnabled,
|
||||
discordMutedTypes: initialDiscordMutedTypes,
|
||||
}: PreferencesFormProps) {
|
||||
const router = useRouter();
|
||||
const [mutedAll, setMutedAll] = useState(initialMutedAll);
|
||||
const [mutedTypes, setMutedTypes] = useState<string[]>(() => [...initialMutedTypes]);
|
||||
const [mutedTypes, setMutedTypes] = useState<string[]>(initialMutedTypes);
|
||||
const [showCallsign, setShowCallsign] = useState(initialShowCallsign);
|
||||
const [showVersionOverlay, setShowVersionOverlay] = useState(initialShowVersionOverlay);
|
||||
const [leadOrActual, setLeadOrActual] = useState<"lead" | "actual">(initialLeadOrActual);
|
||||
const [discordEnabled, setDiscordEnabled] = useState(initialDiscordEnabled);
|
||||
const [discordMutedTypes, setDiscordMutedTypes] = useState<string[]>(() => [
|
||||
...initialDiscordMutedTypes,
|
||||
]);
|
||||
const [discordMutedTypes, setDiscordMutedTypes] = useState<string[]>(initialDiscordMutedTypes);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const toggleType = (value: string, checked: boolean) => {
|
||||
setMutedTypes((previous) =>
|
||||
checked ? [...new Set([...previous, value])] : previous.filter((type) => type !== value),
|
||||
setMutedTypes((prev) =>
|
||||
checked ? [...new Set([...prev, value])] : prev.filter((t) => t !== value),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleDiscordType = (value: string, checked: boolean) => {
|
||||
setDiscordMutedTypes((previous) =>
|
||||
checked ? [...new Set([...previous, value])] : previous.filter((type) => type !== value),
|
||||
setDiscordMutedTypes((prev) =>
|
||||
checked ? [...new Set([...prev, value])] : prev.filter((t) => t !== value),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const result = await updatePreferences({
|
||||
const res = await updatePreferences({
|
||||
mutedAll,
|
||||
mutedTypes,
|
||||
showCallsign,
|
||||
showVersionOverlay,
|
||||
leadOrActual,
|
||||
discordEnabled,
|
||||
discordMutedTypes,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Could not save preferences.");
|
||||
if (!res.success) {
|
||||
setError(res.error ?? "Failed to save preferences.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess("Preferences updated.");
|
||||
setSuccess("Preferences saved.");
|
||||
router.refresh();
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Could not save preferences. Please try again.",
|
||||
);
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -94,190 +79,101 @@ export function PreferencesForm({
|
|||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Display</CardTitle>
|
||||
<CardDescription>Choose what appears in the app shell and roster views.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Label
|
||||
htmlFor="showVersionOverlay"
|
||||
className="flex items-center justify-between gap-4 text-sm"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span>Show version overlay</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Display the current build version at the top of every page.
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
id="showVersionOverlay"
|
||||
checked={showVersionOverlay}
|
||||
onCheckedChange={setShowVersionOverlay}
|
||||
aria-label="Show version overlay"
|
||||
/>
|
||||
</Label>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">Notifications</h3>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={mutedAll} onCheckedChange={(c) => setMutedAll(c === true)} />
|
||||
Mute all in-app notifications
|
||||
</label>
|
||||
<div
|
||||
className={
|
||||
mutedAll
|
||||
? "flex flex-col gap-3 pl-6 opacity-50 pointer-events-none"
|
||||
: "flex flex-col gap-3 pl-6"
|
||||
}
|
||||
>
|
||||
{MUTEABLE_NOTIFICATION_TYPES.map((t) => (
|
||||
<label key={t.value} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={mutedTypes.includes(t.value)}
|
||||
onCheckedChange={(c) => toggleType(t.value, c === true)}
|
||||
/>
|
||||
{t.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Label htmlFor="showCallsign" className="flex items-center justify-between gap-4 text-sm">
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span>Show callsign instead of display name</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Use your username in the sidebar and app shell.
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
id="showCallsign"
|
||||
checked={showCallsign}
|
||||
onCheckedChange={setShowCallsign}
|
||||
aria-label="Show callsign instead of display name"
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">Display</h3>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={showCallsign} onCheckedChange={(c) => setShowCallsign(c === true)} />
|
||||
Show callsign (username) instead of display name
|
||||
</label>
|
||||
<div className="flex flex-col gap-2 pl-6">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
Roster leader label
|
||||
</Label>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">Roster leader label</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Choose the label used when reviewing the unit roster.
|
||||
</span>
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={leadOrActual}
|
||||
onValueChange={(value) => {
|
||||
if (value === "lead" || value === "actual") setLeadOrActual(value);
|
||||
}}
|
||||
className="flex flex-wrap gap-4"
|
||||
aria-label="Roster leader label"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id="leadOrActual-lead" value="lead" />
|
||||
<Label htmlFor="leadOrActual-lead">Lead</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id="leadOrActual-actual" value="actual" />
|
||||
<Label htmlFor="leadOrActual-actual">Actual</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="leadOrActual"
|
||||
value="lead"
|
||||
checked={leadOrActual === "lead"}
|
||||
onChange={() => setLeadOrActual("lead")}
|
||||
className="accent-primary"
|
||||
/>
|
||||
Lead
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="leadOrActual"
|
||||
value="actual"
|
||||
checked={leadOrActual === "actual"}
|
||||
onChange={() => setLeadOrActual("actual")}
|
||||
className="accent-primary"
|
||||
/>
|
||||
Actual
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notifications</CardTitle>
|
||||
<CardDescription>Control the in-app alerts that appear in your inbox.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Label htmlFor="mutedAll" className="flex items-center justify-between gap-4 text-sm">
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span>Mute all in-app notifications</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Pause every in-app notification listed below.
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
id="mutedAll"
|
||||
checked={mutedAll}
|
||||
onCheckedChange={setMutedAll}
|
||||
aria-label="Mute all in-app notifications"
|
||||
/>
|
||||
</Label>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">Discord</h3>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={discordEnabled}
|
||||
onCheckedChange={(c) => setDiscordEnabled(c === true)}
|
||||
/>
|
||||
Discord notifications (DM me about app events)
|
||||
</label>
|
||||
<div
|
||||
className={
|
||||
discordEnabled
|
||||
? "flex flex-col gap-3 pl-6"
|
||||
: "flex flex-col gap-3 pl-6 opacity-50 pointer-events-none"
|
||||
}
|
||||
>
|
||||
{MUTEABLE_NOTIFICATION_TYPES.map((t) => (
|
||||
<label key={t.value} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={discordMutedTypes.includes(t.value)}
|
||||
onCheckedChange={(c) => toggleDiscordType(t.value, c === true)}
|
||||
/>
|
||||
Mute {t.label.toLowerCase()}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn("flex flex-col gap-3 pl-6", mutedAll && "opacity-50 pointer-events-none")}
|
||||
aria-disabled={mutedAll}
|
||||
>
|
||||
{MUTEABLE_NOTIFICATION_TYPES.map((notificationType) => {
|
||||
const id = `notification-${notificationType.value.replace(":", "-")}`;
|
||||
return (
|
||||
<Label
|
||||
key={notificationType.value}
|
||||
htmlFor={id}
|
||||
className="flex items-center justify-between gap-4 text-sm font-normal"
|
||||
>
|
||||
<span>{notificationType.label}</span>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={mutedTypes.includes(notificationType.value)}
|
||||
onCheckedChange={(checked) => toggleType(notificationType.value, checked)}
|
||||
disabled={mutedAll}
|
||||
aria-label={`Mute ${notificationType.label.toLowerCase()}`}
|
||||
/>
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Discord</CardTitle>
|
||||
<CardDescription>Choose whether the bot can send you direct messages.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Label
|
||||
htmlFor="discordEnabled"
|
||||
className="flex items-center justify-between gap-4 text-sm"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span>Enable Discord notifications</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Receive direct messages about app events.
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
id="discordEnabled"
|
||||
checked={discordEnabled}
|
||||
onCheckedChange={setDiscordEnabled}
|
||||
aria-label="Enable Discord notifications"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 pl-6",
|
||||
!discordEnabled && "opacity-50 pointer-events-none",
|
||||
)}
|
||||
aria-disabled={!discordEnabled}
|
||||
>
|
||||
{MUTEABLE_NOTIFICATION_TYPES.map((notificationType) => {
|
||||
const id = `discord-${notificationType.value.replace(":", "-")}`;
|
||||
return (
|
||||
<Label
|
||||
key={notificationType.value}
|
||||
htmlFor={id}
|
||||
className="flex items-center justify-between gap-4 text-sm font-normal"
|
||||
>
|
||||
<span>Mute {notificationType.label.toLowerCase()}</span>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={discordMutedTypes.includes(notificationType.value)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleDiscordType(notificationType.value, checked)
|
||||
}
|
||||
disabled={!discordEnabled}
|
||||
aria-label={`Mute ${notificationType.label.toLowerCase()}`}
|
||||
/>
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{success && (
|
||||
<p aria-live="polite" className="text-sm text-emerald-500">
|
||||
{success}
|
||||
</p>
|
||||
)}
|
||||
<div className="sticky bottom-0 bg-card p-6 w-full -m-6">
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{success && <p className="text-sm text-emerald-500">{success}</p>}
|
||||
<div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
|
||||
{loading && <Loader2Icon className="size-4 animate-spin" />}
|
||||
{loading ? "Saving..." : "Save preferences"}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@
|
|||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { VersionInfo } from "@/lib/versionInfo";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { User } from "@/payload-types";
|
||||
|
||||
const EMPTY: VersionInfo = {
|
||||
version: null,
|
||||
|
|
@ -33,7 +30,7 @@ function formatCommitDate(iso: string | null): string | null {
|
|||
});
|
||||
}
|
||||
|
||||
const VersionOverlay = ({ canSeeCommit }: { canSeeCommit: boolean }) => {
|
||||
const VersionOverlay = () => {
|
||||
const [info, setInfo] = useState<VersionInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -62,15 +59,13 @@ const VersionOverlay = ({ canSeeCommit }: { canSeeCommit: boolean }) => {
|
|||
|
||||
return (
|
||||
<div className="fixed top-0 z-9999 w-full flex justify-center">
|
||||
<div className="text-white flex flex-col items-center justify-center bg-accent w-fit px-2 rounded-b group overflow-hidden opacity-33 hover:opacity-100">
|
||||
<span className="text-center text-xs pointer-events-none text-amber-500">
|
||||
version {versionLabel}
|
||||
</span>
|
||||
<div className="text-white flex flex-col items-center justify-center bg-red-950 w-fit px-2 rounded-b group overflow-hidden">
|
||||
<span className="text-center text-sm pointer-events-none">version {versionLabel}</span>
|
||||
{showSecondary && (
|
||||
<span className="text-center text-[10px] gap-0 leading-3.5 h-0 group-hover:h-full pointer-events-none transition-all duration-100 ease-in-out">
|
||||
<span className="text-center text-xs h-0 group-hover:h-full pointer-events-none transition-all duration-100 ease-in-out">
|
||||
{lastUpdated && <>last updated {lastUpdated}</>}
|
||||
{canSeeCommit && lastUpdated && commitInfo && <br />}
|
||||
{canSeeCommit && commitInfo && <span className="opacity-50">{commitInfo}</span>}
|
||||
{lastUpdated && commitInfo && <br />}
|
||||
{commitInfo && <>{commitInfo}</>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { createElement } from "react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PreferencesForm } from "@/components/frontend/account/PreferencesForm";
|
||||
import { updatePreferences } from "@/app/(frontend)/account/actions";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(frontend)/account/actions", () => ({
|
||||
updatePreferences: vi.fn(),
|
||||
}));
|
||||
|
||||
class ResizeObserverMock implements ResizeObserver {
|
||||
observe(_target: Element, _options?: ResizeObserverOptions): void {}
|
||||
|
||||
unobserve(_target: Element): void {}
|
||||
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserverMock;
|
||||
|
||||
const mockedUpdatePreferences = vi.mocked(updatePreferences);
|
||||
|
||||
describe("PreferencesForm", () => {
|
||||
beforeEach(() => {
|
||||
mockedUpdatePreferences.mockReset();
|
||||
mockedUpdatePreferences.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
it("submits all display, in-app, and Discord preferences", async () => {
|
||||
render(
|
||||
createElement(PreferencesForm, {
|
||||
mutedAll: false,
|
||||
mutedTypes: ["market:offer"],
|
||||
showCallsign: false,
|
||||
showVersionOverlay: true,
|
||||
leadOrActual: "lead",
|
||||
discordEnabled: false,
|
||||
discordMutedTypes: [],
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Show version overlay" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Show callsign instead of display name" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Mute all in-app notifications" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Actual" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Enable Discord notifications" }));
|
||||
const discordMarketOffer = document.getElementById("discord-market-offer");
|
||||
if (!(discordMarketOffer instanceof HTMLElement)) {
|
||||
throw new Error("Discord market-offer switch was not rendered.");
|
||||
}
|
||||
fireEvent.click(discordMarketOffer);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save preferences" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedUpdatePreferences).toHaveBeenCalledWith({
|
||||
mutedAll: true,
|
||||
mutedTypes: ["market:offer"],
|
||||
showCallsign: true,
|
||||
showVersionOverlay: false,
|
||||
leadOrActual: "actual",
|
||||
discordEnabled: true,
|
||||
discordMutedTypes: ["market:offer"],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue