mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(i18n): localize settings configuration panels
- Implemented next-intl across Appearance, Security, API Keys, and Deals settings panels. - Defined translation schemas for Settings.appearance, Settings.security, Settings.apiKeys, and Settings.deals in en.json. - Removed explicit 'any' types in components by utilizing local useTranslations hooks directly inside sub-components (ModeCard, ThemeCard, CreateKeyDialog). - Eliminated all new typescript and eslint errors related to the localization migrations. - Updated progress trackers to reflect completion.
This commit is contained in:
parent
c30e117552
commit
9ee79638da
@ -1523,6 +1523,69 @@
|
|||||||
"signingOut": "Signing out…",
|
"signingOut": "Signing out…",
|
||||||
"signOutEverywhere": "Sign out everywhere",
|
"signOutEverywhere": "Sign out everywhere",
|
||||||
"signOutFailed": "Sign-out failed: {message}"
|
"signOutFailed": "Sign-out failed: {message}"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "Appearance",
|
||||||
|
"description": "Set the mode and accent colour used across the app. Saved to this device — try it, it changes live.",
|
||||||
|
"mode": "Mode",
|
||||||
|
"useMode": "Use {mode} mode",
|
||||||
|
"active": "Active",
|
||||||
|
"accentColor": "Accent color",
|
||||||
|
"useTheme": "Use {name} theme"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"title": "Login & security",
|
||||||
|
"description": "Change your password and sign out of your devices. These keep your account safe."
|
||||||
|
},
|
||||||
|
"apiKeys": {
|
||||||
|
"title": "API keys",
|
||||||
|
"description": "Keys authenticate the public REST API (<apiCode>/api/v1</apiCode>) so you can build your own automations. Send them as <headerCode>Authorization: Bearer <key></headerCode>.",
|
||||||
|
"newApiKey": "New API key",
|
||||||
|
"noApiKeys": "No API keys yet.",
|
||||||
|
"createOneHint": "Click <bold>New API key</bold> to create one.",
|
||||||
|
"askAdminHint": "Ask an admin to create one.",
|
||||||
|
"revoked": "Revoked",
|
||||||
|
"expired": "Expired",
|
||||||
|
"noScopes": "No scopes",
|
||||||
|
"created": "Created {date}",
|
||||||
|
"lastUsed": "last used {date}",
|
||||||
|
"neverUsed": "never used",
|
||||||
|
"expires": "expires {date}",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"revokeSuccess": "Revoked \"{name}\"",
|
||||||
|
"revokeFailed": "Failed to revoke key",
|
||||||
|
"loadFailed": "Failed to load API keys",
|
||||||
|
"createError": "Failed to create key",
|
||||||
|
"nameRequired": "Give the key a name",
|
||||||
|
"nameLabel": "Name",
|
||||||
|
"namePlaceholder": "e.g. Zapier automation",
|
||||||
|
"scopesLabel": "Scopes",
|
||||||
|
"scopesHint": "A key with no scopes can still call <code className=\"text-[11px]\">GET /api/v1/me</code> to verify it works.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"creating": "Creating…",
|
||||||
|
"createKey": "Create key",
|
||||||
|
"copyTitle": "Copy your API key",
|
||||||
|
"copyDesc": "This is the only time the full key is shown. Store it somewhere safe — if you lose it, revoke it and create a new one.",
|
||||||
|
"apiKeyLabel": "API key",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copySuccess": "API key copied",
|
||||||
|
"copyFailed": "Copy failed — select and copy manually",
|
||||||
|
"newKeyTitle": "New API key",
|
||||||
|
"newKeyDesc": "Name it after the integration that will use it, and grant only the scopes it needs.",
|
||||||
|
"done": "Done",
|
||||||
|
"networkError": "Could not reach the server"
|
||||||
|
},
|
||||||
|
"deals": {
|
||||||
|
"title": "Deals & currency",
|
||||||
|
"description": "The currency used for new deals and for pipeline and dashboard totals.",
|
||||||
|
"defaultCurrency": "Default currency",
|
||||||
|
"defaultCurrencyDesc": "New deals default to this currency, and pipeline and dashboard totals are shown in it. Existing deals keep the currency they were saved with.",
|
||||||
|
"currencyLabel": "Currency",
|
||||||
|
"adminOnlyHint": "Only account admins can change the default currency.",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"saveFailed": "Failed to save default currency",
|
||||||
|
"saveSuccess": "Default currency updated"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ import {
|
|||||||
SCOPE_DESCRIPTIONS,
|
SCOPE_DESCRIPTIONS,
|
||||||
type ApiScope,
|
type ApiScope,
|
||||||
} from '@/lib/api-keys/scopes';
|
} from '@/lib/api-keys/scopes';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { SettingsPanelHead } from './settings-panel-head';
|
import { SettingsPanelHead } from './settings-panel-head';
|
||||||
|
|
||||||
interface ApiKey {
|
interface ApiKey {
|
||||||
@ -70,6 +71,7 @@ function keyStatus(k: ApiKey): 'active' | 'revoked' | 'expired' {
|
|||||||
|
|
||||||
export function ApiKeysSettings() {
|
export function ApiKeysSettings() {
|
||||||
const { canEditSettings } = useAuth();
|
const { canEditSettings } = useAuth();
|
||||||
|
const t = useTranslations('Settings.apiKeys');
|
||||||
|
|
||||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -81,14 +83,14 @@ export function ApiKeysSettings() {
|
|||||||
const res = await fetch('/api/account/api-keys', { cache: 'no-store' });
|
const res = await fetch('/api/account/api-keys', { cache: 'no-store' });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const payload = await res.json().catch(() => ({}));
|
const payload = await res.json().catch(() => ({}));
|
||||||
toast.error(payload.error || 'Failed to load API keys');
|
toast.error(payload.error || t('loadFailed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = (await res.json()) as { keys: ApiKey[] };
|
const data = (await res.json()) as { keys: ApiKey[] };
|
||||||
setKeys(data.keys);
|
setKeys(data.keys);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ApiKeysSettings] load error:', err);
|
console.error('[ApiKeysSettings] load error:', err);
|
||||||
toast.error('Could not reach the server');
|
toast.error(t('networkError'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -106,10 +108,10 @@ export function ApiKeysSettings() {
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const payload = await res.json().catch(() => ({}));
|
const payload = await res.json().catch(() => ({}));
|
||||||
toast.error(payload.error || 'Failed to revoke key');
|
toast.error(payload.error || t('revokeFailed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(`Revoked "${key.name}"`);
|
toast.success(t('revokeSuccess', { name: key.name }));
|
||||||
// Reflect the revoke locally without a refetch.
|
// Reflect the revoke locally without a refetch.
|
||||||
setKeys((prev) =>
|
setKeys((prev) =>
|
||||||
prev.map((k) =>
|
prev.map((k) =>
|
||||||
@ -118,7 +120,7 @@ export function ApiKeysSettings() {
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ApiKeysSettings] revoke error:', err);
|
console.error('[ApiKeysSettings] revoke error:', err);
|
||||||
toast.error('Could not reach the server');
|
toast.error(t('networkError'));
|
||||||
} finally {
|
} finally {
|
||||||
setRevoking(null);
|
setRevoking(null);
|
||||||
}
|
}
|
||||||
@ -135,20 +137,18 @@ export function ApiKeysSettings() {
|
|||||||
return (
|
return (
|
||||||
<section className="animate-in fade-in-50 space-y-6 duration-200">
|
<section className="animate-in fade-in-50 space-y-6 duration-200">
|
||||||
<SettingsPanelHead
|
<SettingsPanelHead
|
||||||
title="API keys"
|
title={t('title')}
|
||||||
description={
|
description={
|
||||||
<>
|
t.rich('description', {
|
||||||
Keys authenticate the public REST API (
|
apiCode: (chunks: React.ReactNode) => <code className="text-xs">{chunks}</code>,
|
||||||
<code className="text-xs">/api/v1</code>) so you can build your own
|
headerCode: (chunks: React.ReactNode) => <code className="text-xs">{chunks}</code>
|
||||||
automations. Send them as{' '}
|
})
|
||||||
<code className="text-xs">Authorization: Bearer <key></code>.
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
<RequireRole min="admin">
|
<RequireRole min="admin">
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New API key
|
{t('newApiKey')}
|
||||||
</Button>
|
</Button>
|
||||||
</RequireRole>
|
</RequireRole>
|
||||||
}
|
}
|
||||||
@ -159,16 +159,19 @@ export function ApiKeysSettings() {
|
|||||||
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
|
||||||
<KeyRound className="text-muted-foreground size-6" />
|
<KeyRound className="text-muted-foreground size-6" />
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
No API keys yet.
|
{t('noApiKeys')}
|
||||||
</p>
|
</p>
|
||||||
{canEditSettings ? (
|
{canEditSettings ? (
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
Click <span className="text-foreground">New API key</span> to
|
{t.rich('createOneHint', {
|
||||||
create one.
|
bold: (chunks: React.ReactNode) => (
|
||||||
|
<span className="text-foreground">{chunks}</span>
|
||||||
|
),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
Ask an admin to create one.
|
{t('askAdminHint')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -198,12 +201,12 @@ export function ApiKeysSettings() {
|
|||||||
</span>
|
</span>
|
||||||
{status === 'revoked' && (
|
{status === 'revoked' && (
|
||||||
<Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase">
|
<Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||||
Revoked
|
{t('revoked')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{status === 'expired' && (
|
{status === 'expired' && (
|
||||||
<Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase">
|
<Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||||
Expired
|
{t('expired')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -213,7 +216,7 @@ export function ApiKeysSettings() {
|
|||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{k.scopes.length === 0 ? (
|
{k.scopes.length === 0 ? (
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
No scopes
|
{t('noScopes')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
k.scopes.map((s) => (
|
k.scopes.map((s) => (
|
||||||
@ -227,13 +230,13 @@ export function ApiKeysSettings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mt-1.5 text-xs">
|
<p className="text-muted-foreground mt-1.5 text-xs">
|
||||||
Created {fmtDate(k.created_at)}
|
{t('created', { date: fmtDate(k.created_at) })}
|
||||||
{' · '}
|
{' · '}
|
||||||
{k.last_used_at
|
{k.last_used_at
|
||||||
? `last used ${fmtDate(k.last_used_at)}`
|
? t('lastUsed', { date: fmtDate(k.last_used_at) })
|
||||||
: 'never used'}
|
: t('neverUsed')}
|
||||||
{k.expires_at && status !== 'expired'
|
{k.expires_at && status !== 'expired'
|
||||||
? ` · expires ${fmtDate(k.expires_at)}`
|
? ` · ${t('expires', { date: fmtDate(k.expires_at) })}`
|
||||||
: ''}
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -252,7 +255,7 @@ export function ApiKeysSettings() {
|
|||||||
) : (
|
) : (
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
)}
|
)}
|
||||||
Revoke
|
{t('revoke')}
|
||||||
</Button>
|
</Button>
|
||||||
</RequireRole>
|
</RequireRole>
|
||||||
)}
|
)}
|
||||||
@ -286,6 +289,7 @@ function CreateKeyDialog({
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onCreated: () => void;
|
onCreated: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations('Settings.apiKeys');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [scopes, setScopes] = useState<ApiScope[]>([]);
|
const [scopes, setScopes] = useState<ApiScope[]>([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@ -308,7 +312,7 @@ function CreateKeyDialog({
|
|||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
toast.error('Give the key a name');
|
toast.error(t('nameRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@ -320,14 +324,14 @@ function CreateKeyDialog({
|
|||||||
});
|
});
|
||||||
const payload = await res.json().catch(() => ({}));
|
const payload = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error(payload.error || 'Failed to create key');
|
toast.error(payload.error || t('createError'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCreatedKey(payload.plaintext as string);
|
setCreatedKey(payload.plaintext as string);
|
||||||
onCreated();
|
onCreated();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CreateKeyDialog] create error:', err);
|
console.error('[CreateKeyDialog] create error:', err);
|
||||||
toast.error('Could not reach the server');
|
toast.error(t('networkError'));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -337,9 +341,9 @@ function CreateKeyDialog({
|
|||||||
if (!createdKey) return;
|
if (!createdKey) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(createdKey);
|
await navigator.clipboard.writeText(createdKey);
|
||||||
toast.success('API key copied');
|
toast.success(t('copySuccess'));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Copy failed — select and copy manually');
|
toast.error(t('copyFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -356,16 +360,15 @@ function CreateKeyDialog({
|
|||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-popover-foreground">
|
<DialogTitle className="text-popover-foreground">
|
||||||
Copy your API key
|
{t('copyTitle')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground">
|
<DialogDescription className="text-muted-foreground">
|
||||||
This is the only time the full key is shown. Store it somewhere
|
{t('copyDesc')}
|
||||||
safe — if you lose it, revoke it and create a new one.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-muted-foreground">API key</Label>
|
<Label className="text-muted-foreground">{t('apiKeyLabel')}</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
@ -375,7 +378,7 @@ function CreateKeyDialog({
|
|||||||
/>
|
/>
|
||||||
<Button type="button" variant="outline" onClick={copyKey}>
|
<Button type="button" variant="outline" onClick={copyKey}>
|
||||||
<Copy className="size-4" />
|
<Copy className="size-4" />
|
||||||
Copy
|
{t('copy')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -387,7 +390,7 @@ function CreateKeyDialog({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Done
|
{t('done')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
@ -395,30 +398,29 @@ function CreateKeyDialog({
|
|||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-popover-foreground">
|
<DialogTitle className="text-popover-foreground">
|
||||||
New API key
|
{t('newKeyTitle')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground">
|
<DialogDescription className="text-muted-foreground">
|
||||||
Name it after the integration that will use it, and grant only
|
{t('newKeyDesc')}
|
||||||
the scopes it needs.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="api-key-name" className="text-muted-foreground">
|
<Label htmlFor="api-key-name" className="text-muted-foreground">
|
||||||
Name
|
{t('nameLabel')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="api-key-name"
|
id="api-key-name"
|
||||||
value={name}
|
value={name}
|
||||||
maxLength={80}
|
maxLength={80}
|
||||||
placeholder="e.g. Zapier automation"
|
placeholder={t('namePlaceholder')}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-muted-foreground">Scopes</Label>
|
<Label className="text-muted-foreground">{t('scopesLabel')}</Label>
|
||||||
<div className="border-border space-y-2 rounded-md border p-3">
|
<div className="border-border space-y-2 rounded-md border p-3">
|
||||||
{API_SCOPES.map((scope) => (
|
{API_SCOPES.map((scope) => (
|
||||||
<label
|
<label
|
||||||
@ -444,9 +446,11 @@ function CreateKeyDialog({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
A key with no scopes can still call{' '}
|
{t.rich('scopesHint', {
|
||||||
<code className="text-[11px]">GET /api/v1/me</code> to verify
|
code: (chunks: React.ReactNode) => (
|
||||||
it works.
|
<code className="text-[11px]">{chunks}</code>
|
||||||
|
),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -460,16 +464,16 @@ function CreateKeyDialog({
|
|||||||
}}
|
}}
|
||||||
className="border-border text-muted-foreground hover:bg-muted"
|
className="border-border text-muted-foreground hover:bg-muted"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreate} disabled={submitting}>
|
<Button onClick={handleCreate} disabled={submitting}>
|
||||||
{submitting ? (
|
{submitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Creating…
|
{t('creating')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Create key'
|
t('createKey')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Check, Moon, Palette, SunMoon, Sun } from "lucide-react";
|
|||||||
import { useTheme } from "@/hooks/use-theme";
|
import { useTheme } from "@/hooks/use-theme";
|
||||||
import { MODES, THEMES, type Mode, type ThemeId } from "@/lib/themes";
|
import { MODES, THEMES, type Mode, type ThemeId } from "@/lib/themes";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { SettingsPanelHead } from "./settings-panel-head";
|
import { SettingsPanelHead } from "./settings-panel-head";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -21,17 +22,19 @@ import { SettingsPanelHead } from "./settings-panel-head";
|
|||||||
*/
|
*/
|
||||||
export function AppearancePanel() {
|
export function AppearancePanel() {
|
||||||
const { theme, setTheme, mode, setMode } = useTheme();
|
const { theme, setTheme, mode, setMode } = useTheme();
|
||||||
|
const t = useTranslations("Settings.appearance");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="max-w-3xl animate-in fade-in-50 duration-200">
|
<section className="max-w-3xl animate-in fade-in-50 duration-200">
|
||||||
<SettingsPanelHead
|
<SettingsPanelHead
|
||||||
title="Appearance"
|
title={t("title")}
|
||||||
description="Set the mode and accent colour used across the app. Saved to this device — try it, it changes live."
|
description={t("description")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
<SunMoon className="size-4 text-muted-foreground" />
|
<SunMoon className="size-4 text-muted-foreground" />
|
||||||
Mode
|
{t("mode")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -53,19 +56,19 @@ export function AppearancePanel() {
|
|||||||
<div className="mt-8 space-y-4">
|
<div className="mt-8 space-y-4">
|
||||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
<Palette className="size-4 text-muted-foreground" />
|
<Palette className="size-4 text-muted-foreground" />
|
||||||
Accent color
|
{t("accentColor")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{THEMES.map((t) => (
|
{THEMES.map((tObj) => (
|
||||||
<ThemeCard
|
<ThemeCard
|
||||||
key={t.id}
|
key={tObj.id}
|
||||||
id={t.id}
|
id={tObj.id}
|
||||||
name={t.name}
|
name={tObj.name}
|
||||||
tagline={t.tagline}
|
tagline={tObj.tagline}
|
||||||
swatch={t.swatch}
|
swatch={tObj.swatch}
|
||||||
isActive={t.id === theme}
|
isActive={tObj.id === theme}
|
||||||
onPick={() => setTheme(t.id)}
|
onPick={() => setTheme(tObj.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -83,6 +86,7 @@ function ModeCard({
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onPick: () => void;
|
onPick: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("Settings.appearance");
|
||||||
const isLight = mode === "light";
|
const isLight = mode === "light";
|
||||||
const Icon = isLight ? Sun : Moon;
|
const Icon = isLight ? Sun : Moon;
|
||||||
return (
|
return (
|
||||||
@ -91,7 +95,7 @@ function ModeCard({
|
|||||||
role="radio"
|
role="radio"
|
||||||
onClick={onPick}
|
onClick={onPick}
|
||||||
aria-checked={isActive}
|
aria-checked={isActive}
|
||||||
aria-label={`Use ${mode} mode`}
|
aria-label={t("useMode", { mode })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
|
"flex items-center gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
|
||||||
isActive
|
isActive
|
||||||
@ -111,7 +115,7 @@ function ModeCard({
|
|||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary">
|
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary">
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
Active
|
{t("active")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -133,12 +137,13 @@ function ThemeCard({
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onPick: () => void;
|
onPick: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("Settings.appearance");
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onPick}
|
onClick={onPick}
|
||||||
aria-pressed={isActive}
|
aria-pressed={isActive}
|
||||||
aria-label={`Use ${name} theme`}
|
aria-label={t("useTheme", { name })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
|
"flex flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
|
||||||
isActive
|
isActive
|
||||||
@ -158,7 +163,7 @@ function ThemeCard({
|
|||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary">
|
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary">
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
Active
|
{t("active")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { SettingsPanelHead } from "./settings-panel-head";
|
import { SettingsPanelHead } from "./settings-panel-head";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -39,6 +40,7 @@ export function DealsSettings() {
|
|||||||
|
|
||||||
const [selected, setSelected] = useState(defaultCurrency);
|
const [selected, setSelected] = useState(defaultCurrency);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const t = useTranslations("Settings.deals");
|
||||||
|
|
||||||
// Keep the select in sync once the profile (and its account default)
|
// Keep the select in sync once the profile (and its account default)
|
||||||
// resolves, and after a save round-trips through refreshProfile.
|
// resolves, and after a save round-trips through refreshProfile.
|
||||||
@ -56,7 +58,7 @@ export function DealsSettings() {
|
|||||||
.update({ default_currency: selected })
|
.update({ default_currency: selected })
|
||||||
.eq("id", accountId);
|
.eq("id", accountId);
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error("Failed to save default currency");
|
toast.error(t("saveFailed"));
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -64,30 +66,28 @@ export function DealsSettings() {
|
|||||||
// and every total pick it up without a full reload.
|
// and every total pick it up without a full reload.
|
||||||
await refreshProfile();
|
await refreshProfile();
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
toast.success("Default currency updated");
|
toast.success(t("saveSuccess"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="max-w-2xl animate-in fade-in-50 duration-200">
|
<section className="max-w-2xl animate-in fade-in-50 duration-200">
|
||||||
<SettingsPanelHead
|
<SettingsPanelHead
|
||||||
title="Deals & currency"
|
title={t("title")}
|
||||||
description="The currency used for new deals and for pipeline and dashboard totals."
|
description={t("description")}
|
||||||
/>
|
/>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-foreground">
|
<CardTitle className="flex items-center gap-2 text-foreground">
|
||||||
<Coins className="size-4 text-primary" />
|
<Coins className="size-4 text-primary" />
|
||||||
Default currency
|
{t("defaultCurrency")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-muted-foreground">
|
<CardDescription className="text-muted-foreground">
|
||||||
New deals default to this currency, and pipeline and
|
{t("defaultCurrencyDesc")}
|
||||||
dashboard totals are shown in it. Existing deals keep the
|
|
||||||
currency they were saved with.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-2 sm:max-w-xs">
|
<div className="grid gap-2 sm:max-w-xs">
|
||||||
<Label className="text-muted-foreground">Currency</Label>
|
<Label className="text-muted-foreground">{t("currencyLabel")}</Label>
|
||||||
<select
|
<select
|
||||||
value={selected}
|
value={selected}
|
||||||
onChange={(e) => setSelected(e.target.value)}
|
onChange={(e) => setSelected(e.target.value)}
|
||||||
@ -102,7 +102,7 @@ export function DealsSettings() {
|
|||||||
</select>
|
</select>
|
||||||
{!canEditSettings && (
|
{!canEditSettings && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Only account admins can change the default currency.
|
{t("adminOnlyHint")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -116,10 +116,10 @@ export function DealsSettings() {
|
|||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Saving...
|
{t("saving")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Save"
|
t("save")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -3,17 +3,19 @@
|
|||||||
import { PasswordForm } from './password-form';
|
import { PasswordForm } from './password-form';
|
||||||
import { SessionsCard } from './sessions-card';
|
import { SessionsCard } from './sessions-card';
|
||||||
import { SettingsPanelHead } from './settings-panel-head';
|
import { SettingsPanelHead } from './settings-panel-head';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Login & security" section — groups the former Profile-tab password
|
* "Login & security" section — groups the former Profile-tab password
|
||||||
* and active-sessions cards into their own dedicated home.
|
* and active-sessions cards into their own dedicated home.
|
||||||
*/
|
*/
|
||||||
export function SecurityPanel() {
|
export function SecurityPanel() {
|
||||||
|
const t = useTranslations('Settings.security');
|
||||||
return (
|
return (
|
||||||
<section className="max-w-2xl animate-in fade-in-50 duration-200">
|
<section className="max-w-2xl animate-in fade-in-50 duration-200">
|
||||||
<SettingsPanelHead
|
<SettingsPanelHead
|
||||||
title="Login & security"
|
title={t('title')}
|
||||||
description="Change your password and sign out of your devices. These keep your account safe."
|
description={t('description')}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PasswordForm />
|
<PasswordForm />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user