add onboarding wizard for new users

This commit is contained in:
Josh Hawkins
2026-08-22 11:51:08 -05:00
parent 01bb9f3f37
commit bb5e9264b0
19 changed files with 1812 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import { Button } from "@/components/ui/button";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { FaCircleCheck } from "react-icons/fa6";
type SetupCameraProps = {
onNext: (cameraNames?: string[]) => void;
onBack: () => void;
};
export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
const { t } = useTranslation(["views/setup"]);
const [showWizard, setShowWizard] = useState(false);
const [addedCameras, setAddedCameras] = useState<string[]>([]);
const { mutate: mutateConfig } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const handleClose = useCallback(() => {
setShowWizard(false);
// Delay to let CameraWizardDialog's config save and go2rtc setup complete,
// then force a fresh fetch from the server (bypassing SWR cache)
setTimeout(() => {
fetch(`${window.baseUrl || ""}api/config`)
.then((res) => res.json())
.then((freshConfig: FrigateConfig) => {
const cameraNames = Object.keys(freshConfig.cameras || {});
if (cameraNames.length > addedCameras.length) {
mutateConfig(freshConfig, { revalidate: false });
setAddedCameras(cameraNames);
}
})
.catch(() => {
// Fetch failed, stay on this step
});
}, 1000);
}, [mutateConfig, addedCameras]);
const handleNext = useCallback(() => {
onNext(addedCameras.length > 0 ? addedCameras : undefined);
}, [onNext, addedCameras]);
return (
<>
<div className="flex flex-col gap-4 py-4">
<div>
<h2 className="text-xl font-semibold">
{t("setupWizard.camera.title")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("setupWizard.camera.description")}
</p>
</div>
{addedCameras.length > 0 && (
<div className="flex flex-col gap-2">
{addedCameras.map((name) => (
<div
key={name}
className="flex items-center justify-between rounded-md border p-3"
>
<span className="text-sm font-medium">{name}</span>
<FaCircleCheck className="size-4 text-success" />
</div>
))}
</div>
)}
<div className="flex flex-col items-center gap-3 py-4">
<Button
variant="select"
className="w-full"
onClick={() => setShowWizard(true)}
>
{addedCameras.length > 0
? t("setupWizard.camera.addAnother")
: t("setupWizard.camera.addCamera")}
</Button>
</div>
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack}>
{t("setupWizard.actions.back")}
</Button>
<div className="flex flex-1 justify-end gap-3">
{addedCameras.length > 0 && (
<Button type="button" variant="select" onClick={handleNext}>
{t("setupWizard.actions.next")}
</Button>
)}
</div>
</div>
</div>
<CameraWizardDialog open={showWizard} onClose={handleClose} />
</>
);
}
+218
View File
@@ -0,0 +1,218 @@
import Logo from "@/components/Logo";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import axios from "axios";
import { FaCircleCheck } from "react-icons/fa6";
type ConfiguredItem = {
key: string;
label: string;
value: string | null;
};
type SetupCompleteProps = {
cameraNames: string[];
configuredSteps: {
camera: boolean;
hwaccel: boolean;
detector: boolean;
recording: boolean;
};
restartRequired: boolean;
onBack: () => void;
};
export default function SetupComplete({
cameraNames,
configuredSteps,
restartRequired,
onBack,
}: SetupCompleteProps) {
const { t } = useTranslation(["views/setup"]);
const [restarting, setRestarting] = useState(false);
const [finishing, setFinishing] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cameraItems: ConfiguredItem[] =
configuredSteps.camera && cameraNames.length > 0
? cameraNames.map((name) => ({
key: `camera-${name}`,
label: t("setupWizard.complete.camera"),
value: name,
}))
: [
{
key: "camera",
label: t("setupWizard.complete.camera"),
value: null,
},
];
const items: ConfiguredItem[] = [
...cameraItems,
{
key: "hwaccel",
label: t("setupWizard.complete.hwaccel"),
value: configuredSteps.hwaccel
? t("setupWizard.complete.configured")
: null,
},
{
key: "detector",
label: t("setupWizard.complete.detector"),
value: configuredSteps.detector
? t("setupWizard.complete.configured")
: null,
},
{
key: "recording",
label: t("setupWizard.complete.recording"),
value: configuredSteps.recording
? t("setupWizard.complete.configured")
: null,
},
];
useEffect(() => {
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
}
};
}, []);
const handleFinish = useCallback(async () => {
setFinishing(true);
try {
// Mark setup as complete
await axios.put("config/set", {
config_data: {
onboarding: { setup_complete: true },
},
requires_restart: 0,
});
// camera adds were applied live, so with no pending requires_restart
// saves the wizard can hand off without restarting
if (!restartRequired) {
window.location.href = window.baseUrl || "/";
return;
}
setRestarting(true);
// Trigger restart
await axios.post("restart");
// Poll for server availability with max retries
let retries = 0;
const maxRetries = 60; // 2 minutes max
pollRef.current = setInterval(async () => {
retries++;
if (retries > maxRetries) {
if (pollRef.current) {
clearInterval(pollRef.current);
}
// Give up polling, redirect anyway
window.location.href = window.baseUrl || "/";
return;
}
try {
const resp = await axios.get("version", { timeout: 2000 });
if (resp.status === 200) {
if (pollRef.current) {
clearInterval(pollRef.current);
}
window.location.href = window.baseUrl || "/";
}
} catch {
// Server not ready yet
}
}, 2000);
} catch {
setRestarting(false);
setFinishing(false);
toast.error(t("setupWizard.errors.saveFailed"));
}
}, [restartRequired, t]);
if (restarting) {
return (
<div className="flex flex-col items-center gap-6 py-12">
<Logo className="h-12 w-12" />
<ActivityIndicator />
<div className="text-center">
<p className="font-semibold">
{t("setupWizard.complete.restarting")}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{t("setupWizard.complete.restartingDescription")}
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-4 py-4">
<div>
<h2 className="text-xl font-semibold">
{t("setupWizard.complete.title")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("setupWizard.complete.description")}
</p>
</div>
<div className="flex flex-col gap-2">
{items.map((item) => (
<div
key={item.key}
className="flex items-center justify-between rounded-md border p-3"
>
<span className="text-sm font-medium">{item.label}</span>
<div className="flex items-center gap-2">
{item.value && <FaCircleCheck className="size-4 text-success" />}
<span
className={`text-sm ${item.value ? "" : "text-muted-foreground"}`}
>
{item.value ?? t("setupWizard.complete.notConfigured")}
</span>
</div>
</div>
))}
</div>
<p className="text-sm text-muted-foreground">
{t("setupWizard.complete.nextSteps")}
</p>
{restartRequired && (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
{t("setupWizard.complete.restartNotice")}
</p>
)}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack}>
{t("setupWizard.actions.back")}
</Button>
<div className="flex flex-1 justify-end">
<Button
type="button"
variant="select"
onClick={handleFinish}
disabled={finishing}
>
{restartRequired
? t("setupWizard.complete.applyAndRestart")
: t("setupWizard.complete.goToLiveView")}
</Button>
</div>
</div>
</div>
);
}
+278
View File
@@ -0,0 +1,278 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import type { FrigatePlusModel } from "@/components/config-form/theme/fields/ModelSourcePicker";
import { Button } from "@/components/ui/button";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useDocDomain } from "@/hooks/use-doc-domain";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { DetectionHardware } from "@/types/hardware";
import { recommendedDetectorCount } from "@/utils/detectionHardware";
import axios from "axios";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuExternalLink } from "react-icons/lu";
import { toast } from "sonner";
import useSWR from "swr";
// detectors that ship no default model; configuring them without one leaves
// the detector unable to start
const MODEL_REQUIRED_DETECTORS = ["onnx", "tensorrt"];
const CPU_FALLBACK: DetectionHardware[] = [
{
key: "cpu",
detector: "cpu",
name: "CPU",
units: [{ device: "cpu", label: "CPU" }],
count: 1,
unlimited: true,
},
];
type SetupDetectorProps = {
cameraCount: number;
onNext: (hardwareKey: string) => void;
onBack: () => void;
onSkip: (hardwareKey?: string) => void;
};
export default function SetupDetector({
cameraCount,
onNext,
onBack,
onSkip,
}: SetupDetectorProps) {
const { t } = useTranslation(["views/setup", "common"]);
const { getLocaleDocUrl } = useDocDomain();
const {
data: hardware,
isLoading,
error: probeError,
} = useSWR<DetectionHardware[]>("hardware/probe", {
revalidateOnFocus: false,
});
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const plusEnabled = Boolean(config?.plus?.enabled);
// the cpu is always probed, so an empty or failed probe leaves a fallback
const options = useMemo(
() => (hardware && hardware.length > 0 ? hardware : CPU_FALLBACK),
[hardware],
);
// the prober orders accelerators ahead of the cpu
const recommendedKey = options[0].key;
const [selectedKey, setSelectedKey] = useState<string>();
const selected =
options.find((entry) => entry.key === (selectedKey ?? recommendedKey)) ??
options[0];
const needsModel = MODEL_REQUIRED_DETECTORS.includes(selected.detector);
const { data: plusModels } = useSWR<FrigatePlusModel[]>(
plusEnabled && needsModel ? "/plus/models" : null,
{
fetcher: async (url) => {
const res = await axios.get(url, { withCredentials: true });
return res.data;
},
},
);
const [plusModelId, setPlusModelId] = useState("");
const compatiblePlusModels = useMemo(
() =>
(plusModels ?? []).filter((model) =>
model.supportedDetectors.includes(selected.detector),
),
[plusModels, selected.detector],
);
const [saving, setSaving] = useState(false);
const buildDevices = useCallback(
(entry: DetectionHardware): string[] => {
const first = entry.units[0]?.device;
if (!first) {
return [];
}
if (!entry.unlimited) {
return [first];
}
// repeating a device runs an extra inference process on it
const count = recommendedDetectorCount(Math.max(cameraCount, 1));
return Array.from({ length: count }, () => first);
},
[cameraCount],
);
const handleSave = useCallback(async () => {
if (needsModel && !plusModelId) {
// no model to run on this hardware yet, so leave the config untouched
onSkip(selected.key);
return;
}
setSaving(true);
try {
const model: Record<string, unknown> = {
devices: buildDevices(selected),
};
if (needsModel) {
model.path = `plus://${plusModelId}`;
}
await axios.put("config/set", {
config_data: {
models: [model],
detect: { enabled: true },
},
requires_restart: 1,
});
onNext(selected.key);
} catch {
toast.error(t("setupWizard.errors.saveFailed"));
} finally {
setSaving(false);
}
}, [needsModel, plusModelId, selected, buildDevices, onNext, onSkip, t]);
if (isLoading) {
return (
<div className="flex flex-col items-center gap-4 py-12">
<ActivityIndicator />
<p className="text-sm text-muted-foreground">
{t("setupWizard.detector.detecting")}
</p>
</div>
);
}
return (
<div className="flex flex-col gap-4 py-4">
<div>
<h2 className="text-xl font-semibold">
{t("setupWizard.detector.title")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("setupWizard.detector.description")}
</p>
</div>
{probeError && (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
{t("setupWizard.detector.probeFailed")}
</p>
)}
<RadioGroup
value={selected.key}
onValueChange={(value) => {
setSelectedKey(value);
setPlusModelId("");
}}
>
{options.map((entry) => (
<div key={entry.key} className="flex items-center space-x-2">
<RadioGroupItem
value={entry.key}
id={`detector-${entry.key}`}
className={
selected.key === entry.key
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label
htmlFor={`detector-${entry.key}`}
className="cursor-pointer text-sm font-medium"
>
{entry.name}
{entry.count > 1 ? ` (${entry.count})` : ""}
{entry.key === recommendedKey && entry.key !== "cpu" && (
<span className="ml-2 text-xs text-selected">
{t("setupWizard.detector.recommended")}
</span>
)}
</label>
</div>
))}
</RadioGroup>
{needsModel && (
<div className="flex flex-col gap-3 rounded-md bg-muted p-3 text-sm">
<p>
{t("setupWizard.detector.modelRequired", { name: selected.name })}
</p>
{plusEnabled ? (
<Select value={plusModelId} onValueChange={setPlusModelId}>
<SelectTrigger className="max-w-xs">
<SelectValue
placeholder={t("setupWizard.detector.plusModelPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
{compatiblePlusModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{`${model.name} (${model.width}x${model.height})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<a
href={getLocaleDocUrl("configuration/object_detectors")}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-primary"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 size-3" />
</a>
)}
</div>
)}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack}>
{t("setupWizard.actions.back")}
</Button>
<div className="flex flex-1 justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => onSkip(selected.key)}
>
{t("setupWizard.actions.skip")}
</Button>
<Button
type="button"
variant="select"
onClick={handleSave}
disabled={saving}
>
{saving
? t("setupWizard.actions.saving")
: needsModel && !plusModelId
? t("setupWizard.detector.continueWithout")
: t("setupWizard.actions.next")}
</Button>
</div>
</div>
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import type { HwaccelRecommendation } from "@/types/hardware";
import axios from "axios";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import useSWR from "swr";
const AUTO = "auto";
const NONE = "none";
const HWACCEL_PRESETS = [
{ value: "preset-vaapi", key: "vaapi" },
{ value: "preset-nvidia", key: "cuda" },
{ value: "preset-intel-qsv-h264", key: "qsv" },
{ value: "preset-rkmpp", key: "rkmpp" },
{ value: "preset-rpi-64-h264", key: "rpi" },
{ value: "preset-jetson-h264", key: "jetson" },
] as const;
type SetupHwAccelProps = {
detectorHardwareKey?: string;
// saved reports whether a config write happened, so the wizard knows
// whether finishing requires a restart
onNext: (saved: boolean) => void;
onBack: () => void;
onSkip: () => void;
};
export default function SetupHwAccel({
detectorHardwareKey,
onNext,
onBack,
onSkip,
}: SetupHwAccelProps) {
const { t } = useTranslation(["views/setup"]);
const {
data: recommendation,
isLoading,
error: recommendError,
} = useSWR<HwaccelRecommendation>(
detectorHardwareKey
? `hardware/hwaccel?detector=${encodeURIComponent(detectorHardwareKey)}`
: "hardware/hwaccel",
{ revalidateOnFocus: false },
);
const [selected, setSelected] = useState<string>(AUTO);
const [saving, setSaving] = useState(false);
const derived = recommendation?.preset ?? "";
const handleSave = useCallback(async () => {
// Auto with nothing derived writes nothing: the config default of "auto"
// stays in place and the backend decides at startup
if (selected === AUTO && !derived) {
onNext(false);
return;
}
// an empty string would make config/set delete the key (reviving the
// "auto" default), so an explicit no-hwaccel is an empty list
const preset: string | string[] =
selected === AUTO ? derived : selected === NONE ? [] : selected;
setSaving(true);
try {
await axios.put("config/set", {
config_data: {
ffmpeg: { hwaccel_args: preset },
},
requires_restart: 1,
});
onNext(true);
} catch {
toast.error(t("setupWizard.errors.saveFailed"));
} finally {
setSaving(false);
}
}, [selected, derived, onNext, t]);
if (isLoading) {
return (
<div className="flex flex-col items-center gap-4 py-12">
<ActivityIndicator />
<p className="text-sm text-muted-foreground">
{t("setupWizard.hwaccel.detecting")}
</p>
</div>
);
}
const radioClass = (value: string) =>
selected === value
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary";
return (
<div className="flex flex-col gap-4 py-4">
<div>
<h2 className="text-xl font-semibold">
{t("setupWizard.hwaccel.title")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("setupWizard.hwaccel.description")}
</p>
</div>
<RadioGroup value={selected} onValueChange={setSelected}>
<div className="flex flex-col gap-0.5">
<div className="flex items-center space-x-2">
<RadioGroupItem
value={AUTO}
id="hwaccel-auto"
className={radioClass(AUTO)}
/>
<label htmlFor="hwaccel-auto" className="cursor-pointer text-sm">
{t("setupWizard.hwaccel.auto")}
</label>
</div>
<p className="ml-6 text-xs text-muted-foreground">
{derived
? t("setupWizard.hwaccel.autoResolved", { preset: derived })
: recommendError
? t("setupWizard.hwaccel.recommendFailed")
: t("setupWizard.hwaccel.autoNone")}
</p>
</div>
{HWACCEL_PRESETS.map((preset) => (
<div key={preset.key} className="flex items-center space-x-2">
<RadioGroupItem
value={preset.value}
id={`hwaccel-${preset.key}`}
className={radioClass(preset.value)}
/>
<label
htmlFor={`hwaccel-${preset.key}`}
className="cursor-pointer text-sm"
>
{t(`setupWizard.hwaccel.presets.${preset.key}`)}
</label>
</div>
))}
<div className="flex items-center space-x-2">
<RadioGroupItem
value={NONE}
id="hwaccel-none"
className={radioClass(NONE)}
/>
<label htmlFor="hwaccel-none" className="cursor-pointer text-sm">
{t("setupWizard.hwaccel.presets.none")}
</label>
</div>
</RadioGroup>
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack}>
{t("setupWizard.actions.back")}
</Button>
<div className="flex flex-1 justify-end gap-3">
<Button type="button" variant="outline" onClick={onSkip}>
{t("setupWizard.actions.skip")}
</Button>
<Button
type="button"
variant="select"
onClick={handleSave}
disabled={saving}
>
{saving
? t("setupWizard.actions.saving")
: t("setupWizard.actions.next")}
</Button>
</div>
</div>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import axios from "axios";
import useSWR from "swr";
type SetupRecordingProps = {
cameraNames: string[];
onNext: () => void;
onBack: () => void;
onSkip: () => void;
};
export default function SetupRecording({
cameraNames,
onNext,
onBack,
onSkip,
}: SetupRecordingProps) {
const { t } = useTranslation(["views/setup"]);
const [enabled, setEnabled] = useState(true);
const [retentionDays, setRetentionDays] = useState(10);
const [saving, setSaving] = useState(false);
const { data: stats } = useSWR("stats", { revalidateOnFocus: false });
// Calculate storage estimate
const storageInfo = stats?.service?.storage?.["/tmp/frigate/recordings"];
const freeGb = storageInfo ? Math.round(storageInfo.free / 1024) : null;
const cameraCount = cameraNames.length;
// Rough estimate: ~2 Mbps per camera continuous recording
const estimatedDays =
freeGb && cameraCount > 0
? Math.round((freeGb * 1024) / ((2 * 0.125 * 86400) / 1024) / cameraCount)
: null;
const handleSave = useCallback(async () => {
setSaving(true);
try {
await axios.put("config/set", {
config_data: {
record: {
enabled,
alerts: { retain: { days: retentionDays } },
detections: { retain: { days: retentionDays } },
},
},
requires_restart: 1,
});
onNext();
} catch {
toast.error(t("setupWizard.errors.saveFailed"));
} finally {
setSaving(false);
}
}, [enabled, retentionDays, onNext, t]);
return (
<div className="flex flex-col gap-4 py-4">
<div>
<h2 className="text-xl font-semibold">
{t("setupWizard.recording.title")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("setupWizard.recording.description")}
</p>
</div>
{cameraCount === 0 && (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
{t("setupWizard.recording.noCameras")}
</p>
)}
<div className="flex items-center justify-between rounded-md border p-4">
<Label htmlFor="recording-toggle" className="font-medium">
{t("setupWizard.recording.enableRecording")}
</Label>
<Switch
id="recording-toggle"
checked={enabled}
onCheckedChange={setEnabled}
/>
</div>
{enabled && (
<>
<div className="flex flex-col gap-2">
<Label htmlFor="retention-days">
{t("setupWizard.recording.retentionDays")}
</Label>
<Input
id="retention-days"
type="number"
min={1}
max={365}
value={retentionDays}
onChange={(e) =>
setRetentionDays(Math.max(1, parseInt(e.target.value) || 1))
}
/>
</div>
{freeGb !== null && estimatedDays !== null && cameraCount > 0 && (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
{t("setupWizard.recording.storageEstimate", {
free: freeGb,
days: estimatedDays,
cameras: cameraCount,
})}
</p>
)}
</>
)}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack}>
{t("setupWizard.actions.back")}
</Button>
<div className="flex flex-1 justify-end gap-3">
<Button type="button" variant="outline" onClick={onSkip}>
{t("setupWizard.actions.skip")}
</Button>
<Button
type="button"
variant="select"
onClick={handleSave}
disabled={saving}
>
{saving
? t("setupWizard.actions.saving")
: t("setupWizard.actions.next")}
</Button>
</div>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Logo from "@/components/Logo";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
type SetupWelcomeProps = {
onNext: () => void;
onSkip: () => void;
};
export default function SetupWelcome({ onNext, onSkip }: SetupWelcomeProps) {
const { t } = useTranslation(["views/setup"]);
return (
<div className="flex flex-col items-center gap-6 py-4">
<Logo className="h-16 w-16" />
<div className="text-center">
<h2 className="text-2xl font-semibold">
{t("setupWizard.welcome.title")}
</h2>
<p className="mt-2 text-muted-foreground">
{t("setupWizard.welcome.description")}
</p>
</div>
<div className="flex w-full flex-col gap-3 pt-4">
<Button variant="select" className="w-full" onClick={onNext}>
{t("setupWizard.welcome.getStarted")}
</Button>
<button
className="text-sm text-muted-foreground hover:text-primary"
onClick={onSkip}
>
{t("setupWizard.welcome.skipSetup")}
</button>
</div>
</div>
);
}