~3,470 new keys (5,770 total, EN=ES), ~115 more files wired. - nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets (sort/trust/weather), spot-type label helper. - placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications, collectives, crew chat/coordinator. settingsUi: settings, auth/signup, onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints, app shop, newsletter, community stats, leaderboards, profile. - Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Bell, Loader2, Radio, ShieldCheck, TriangleAlert } from 'lucide-react';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
|
|
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
|
import { useBackgroundDelivery } from '@/hooks/useBackgroundDelivery';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs';
|
|
import {
|
|
getNotificationPermission,
|
|
requestNotificationPermission,
|
|
showOsNotification,
|
|
type OsPermission,
|
|
} from '@/lib/osNotify';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
/**
|
|
* Notification settings — the opt-in surface for OS notifications.
|
|
*
|
|
* Turns on nothing until the user asks, and explains the one honest limitation
|
|
* up front: notifications are delivered by the device itself while the app is
|
|
* running (e.g. a background browser tab). A fully closed app needs a push
|
|
* server, which SkateSpots deliberately does not run.
|
|
*/
|
|
export function NotificationSettingsCard() {
|
|
const [prefs, setPrefs] = useNotificationPrefs();
|
|
const [permission, setPermission] = useState<OsPermission | 'loading'>('loading');
|
|
const [busy, setBusy] = useState(false);
|
|
const push = usePushNotifications();
|
|
const background = useBackgroundDelivery();
|
|
const { toast } = useToast();
|
|
const { t } = useTranslation();
|
|
|
|
// When the category choices change, re-send them to whatever channel is on, so
|
|
// a closed-app push respects the toggles (the server filters by category).
|
|
const categoriesKey = push.categories.join(',');
|
|
const firstCategoriesRun = useRef(true);
|
|
useEffect(() => {
|
|
if (firstCategoriesRun.current) {
|
|
firstCategoriesRun.current = false;
|
|
return;
|
|
}
|
|
if (push.state === 'on') void push.resubscribe();
|
|
if (background.mode !== 'off') void background.resubscribe();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [categoriesKey]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void getNotificationPermission().then((result) => {
|
|
if (!cancelled) setPermission(result);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const enable = async () => {
|
|
setBusy(true);
|
|
try {
|
|
const result = await requestNotificationPermission();
|
|
setPermission(result);
|
|
if (result === 'granted') {
|
|
setPrefs((prev) => ({ ...prev, enabled: true }));
|
|
}
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const toggleEnabled = (next: boolean) => {
|
|
if (!next) {
|
|
setPrefs((prev) => ({ ...prev, enabled: false }));
|
|
// One kill switch: turning the master off also stops push + the native poller.
|
|
void push.disable();
|
|
void background.disable();
|
|
return;
|
|
}
|
|
if (permission === 'granted') {
|
|
setPrefs((prev) => ({ ...prev, enabled: true }));
|
|
return;
|
|
}
|
|
void enable();
|
|
};
|
|
|
|
const setCategory = (key: (typeof NOTIFICATION_CATEGORIES)[number]['key'], value: boolean) => {
|
|
setPrefs((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const sendTest = () => {
|
|
void showOsNotification({
|
|
id: `test:${Date.now()}`,
|
|
title: t('settingsUi.notify.testTitle'),
|
|
body: t('settingsUi.notify.testBody'),
|
|
path: '/',
|
|
});
|
|
};
|
|
|
|
const unsupported = permission === 'unsupported';
|
|
const denied = permission === 'denied';
|
|
const granted = permission === 'granted';
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Master switch */}
|
|
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
|
|
<div className="flex items-center gap-3 px-4 py-3.5">
|
|
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-[#ccff00]/15">
|
|
<Bell className="h-4 w-4 text-[#ccff00]" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-bold text-white">{t('settingsUi.notify.enableTitle')}</p>
|
|
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
|
|
{t('settingsUi.notify.enableDesc')}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={prefs.enabled && granted}
|
|
disabled={unsupported || busy}
|
|
onCheckedChange={toggleEnabled}
|
|
/>
|
|
</div>
|
|
|
|
{permission === 'loading' && (
|
|
<p className="flex items-center gap-2 border-t border-white/5 px-4 py-3 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('settingsUi.notify.checkingPermission')}
|
|
</p>
|
|
)}
|
|
|
|
{unsupported && (
|
|
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs text-amber-400">
|
|
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
|
{t('settingsUi.notify.unsupported')}
|
|
</p>
|
|
)}
|
|
|
|
{denied && (
|
|
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs text-amber-400">
|
|
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
|
{t('settingsUi.notify.denied')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Background push (closed-app) */}
|
|
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
|
|
<div className="flex items-center gap-3 px-4 py-3.5">
|
|
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-[#ccff00]/15">
|
|
<Radio className="h-4 w-4 text-[#ccff00]" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-bold text-white">{t('settingsUi.notify.backgroundTitle')}</p>
|
|
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
|
|
{t('settingsUi.notify.backgroundDesc')}
|
|
</p>
|
|
</div>
|
|
{push.state === 'working' ? (
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
) : (
|
|
<Switch
|
|
checked={push.state === 'on'}
|
|
disabled={push.state === 'unsupported'}
|
|
onCheckedChange={(value) => {
|
|
if (value) void push.enable();
|
|
else void push.disable();
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{push.state === 'unsupported' && (
|
|
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs text-amber-400">
|
|
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
|
{t('settingsUi.notify.pushUnsupported')}
|
|
</p>
|
|
)}
|
|
|
|
{push.state === 'error' && push.error && (
|
|
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs text-amber-400">
|
|
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
|
{push.error}
|
|
</p>
|
|
)}
|
|
|
|
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs leading-relaxed text-muted-foreground">
|
|
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#ccff00]" />
|
|
<span>
|
|
{t('settingsUi.notify.pushPrivacy')}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
{/* Background delivery (Android only — no Google services). */}
|
|
{background.supported && (
|
|
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
|
|
<div className="px-4 py-3.5">
|
|
<p className="text-sm font-bold text-white">{t('settingsUi.notify.bgDeliveryTitle')}</p>
|
|
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
|
|
{t('settingsUi.notify.bgDeliveryDesc')}
|
|
</p>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<Button size="sm" variant={background.mode === 'off' ? 'default' : 'outline'} onClick={() => void background.disable()}>
|
|
{t('settingsUi.notify.off')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={background.mode === 'standard' ? 'default' : 'outline'}
|
|
onClick={() => {
|
|
void background.enable('standard').catch((e) => {
|
|
toast({ title: t('settingsUi.notify.couldNotEnable'), description: e instanceof Error ? e.message : t('common.tryAgain'), variant: 'destructive' });
|
|
});
|
|
}}
|
|
>
|
|
{t('settingsUi.notify.standard')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={background.mode === 'fast' ? 'default' : 'outline'}
|
|
onClick={() => {
|
|
void background.enable('fast').catch((e) => {
|
|
toast({ title: t('settingsUi.notify.couldNotEnable'), description: e instanceof Error ? e.message : t('common.tryAgain'), variant: 'destructive' });
|
|
});
|
|
}}
|
|
>
|
|
{t('settingsUi.notify.fast')}
|
|
</Button>
|
|
</div>
|
|
<p className="mt-2 text-[10px] leading-relaxed text-gray-600">
|
|
{t('settingsUi.notify.bgDeliveryNote')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Per-category toggles */}
|
|
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
|
|
<p className="border-b border-white/5 px-4 py-2.5 text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">
|
|
{t('settingsUi.notify.notifyMeAbout')}
|
|
</p>
|
|
{NOTIFICATION_CATEGORIES.map(({ key }) => (
|
|
<div
|
|
key={key}
|
|
className="flex items-center gap-3 border-b border-white/5 px-4 py-3 last:border-b-0"
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-bold text-white">{t(`settingsUi.notify.cat_${key}_label`)}</p>
|
|
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">{t(`settingsUi.notify.cat_${key}_hint`)}</p>
|
|
</div>
|
|
<Switch
|
|
checked={prefs.enabled && granted && prefs[key]}
|
|
disabled={!prefs.enabled || !granted}
|
|
onCheckedChange={(value) => setCategory(key, value)}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Test + privacy note */}
|
|
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
|
|
<div className="px-4 py-3.5">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={sendTest}
|
|
disabled={!prefs.enabled || !granted}
|
|
className="w-full"
|
|
>
|
|
{t('settingsUi.notify.sendTest')}
|
|
</Button>
|
|
</div>
|
|
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs leading-relaxed text-muted-foreground">
|
|
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#ccff00]" />
|
|
<span>
|
|
{t('settingsUi.notify.footerBefore')}{' '}
|
|
<strong className="text-white">{t('settingsUi.notify.backgroundTitle')}</strong> {t('settingsUi.notify.footerAfter')}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|