refactor(notifications): collapse 6 per-workflow toggles into 2 categories

Settings → Bildirimler had grown a 6-row list (welcome / trial-ending /
referral / referral-qualified / referral-reward / win-back) that read like
an internal cron schedule rather than a user choice. Users care about
e-mail vs mobile, not which Novu trigger fires the day-3 nudge.

Replaces the per-workflow UI with two switches:

  • E-posta bildirimleri  — bundles all six marketing/lifecycle workflows
                             above, off = mute all
  • Mobil bildirim         — placeholder for the not-yet-shipped mobile
                             app push channel; the preference is stored
                             so it Just Works when push ships

Auth + payment mail remain unaffected — the server-side OPTIONAL_WORKFLOWS
filter is still the canonical opt-out gate.

API
---
Same path (`/api/email/preferences`), category-shaped payload:

  GET  → `[{category, label, description, optedOut}, …]`  (two rows)
  POST → body `{category, optedOut}`                       (toggles every
                                                            workflow in
                                                            the bundle)

UnsubscribeController is untouched — one-click List-Unsubscribe URLs in
mail still address a single workflow (we don't want clicking the welcome-
mail unsub link to also kill the trial-ending nudge a week later).

Service
-------
New `NOTIFICATION_CATEGORIES` const + `getCategoryState()` /
`setCategoryState()` on EmailPreferencesService. `mobile_push` added to
OPTIONAL_WORKFLOWS so the same row-presence guard works for it.

UI
--
NotificationsCard renders two rows (or two skeletons) — keys are stable
so the skeletons match the final layout. Category copy comes from the
API; static FALLBACK_CATEGORY_COPY avoids a flash of untitled rows
before GET resolves.

PostHog events renamed from `email_workflow_opted_in/out` to
`notifications_category_opted_in/out` since the per-workflow event was
never going to be useful.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude (notifications categorize)
2026-06-04 23:45:35 +03:00
parent e979116738
commit aaa96d82e3
3 changed files with 183 additions and 96 deletions

View File

@@ -54,50 +54,38 @@ const TAB_ITEMS = [
] as const;
/**
* Per-workflow opt-out labels for the Notifications tab. Order matters —
* it's the order the user sees. Auth + payment workflows are deliberately
* NOT here (transactional → must always reach the user). Kept in sync with
* apps/api/src/notifications/email-preferences.service.ts OPTIONAL_WORKFLOWS.
* UI categories rendered by the Notifications tab. Each maps to a bundle of
* underlying workflows on the server — see
* `apps/api/src/notifications/email-preferences.service.ts
* NOTIFICATION_CATEGORIES`. We render two switches, not six, because users
* care about "e-mail vs mobile" not "did the day-3 referral mail fire".
*
* The API also returns these (`label`, `description`) so the server is the
* source of truth for copy; the static fallback here just avoids a flash of
* untitled rows before the GET completes.
*/
const NOTIFICATION_WORKFLOWS: ReadonlyArray<{
workflow: string;
title: string;
description: string;
}> = [
{
workflow: "welcome",
title: "Hoş geldin maili",
description: "Kayıt olduktan hemen sonra gelen kısa karşılama.",
type NotificationCategoryKey = "email_marketing" | "mobile_push";
const FALLBACK_CATEGORY_COPY: Record<
NotificationCategoryKey,
{ label: string; description: string }
> = {
email_marketing: {
label: "E-posta bildirimleri",
description:
"Hoş geldin, deneme bitişi, davet hatırlatması, ödül ve geri kazanma mailleri. Hesap güvenliği ve ödeme bildirimleri her zaman gelir.",
},
{
workflow: "trial-ending",
title: "Deneme bitiş hatırlatması",
description: "Deneme süresinin son birkaç gününde gönderilen yükseltme önerisi.",
mobile_push: {
label: "Mobil bildirim",
description:
"Mobil uygulama push bildirimleri. Mobil uygulama yayınlandığında bu tercih kullanılır.",
},
{
workflow: "referral",
title: "Davet hatırlatması",
description: "Kayıt olduktan 3 gün sonra arkadaşını davet etme hatırlatması.",
},
{
workflow: "referral-qualified",
title: "Davet niteliği bildirimi",
description: "Davet ettiğin biri e-postasını doğrulayıp niteliklendiğinde haber alıyorsun.",
},
{
workflow: "referral-reward",
title: "Ödül bildirimi",
description: "Davet ödülü kazandığında (7 / 14 gün) bilgilendirme.",
},
{
workflow: "win-back",
title: "Geri kazanma maili",
description: "Uzun süre pasif kaldığında tek seferlik dönüş daveti.",
},
];
};
interface NotificationPref {
workflow: string;
category: NotificationCategoryKey;
label: string;
description: string;
optedOut: boolean;
}
@@ -685,7 +673,7 @@ export function SettingsContent({
function NotificationsCard() {
const { t } = useTranslation();
const [prefs, setPrefs] = useState<NotificationPref[] | null>(null);
const [pending, setPending] = useState<Set<string>>(new Set());
const [pending, setPending] = useState<Set<NotificationCategoryKey>>(new Set());
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -703,37 +691,44 @@ function NotificationsCard() {
};
}, []);
async function toggle(workflow: string, currentlyOptedOut: boolean) {
const next = !currentlyOptedOut;
async function toggle(category: NotificationCategoryKey, currentlyOptedOut: boolean) {
const nextOptedOut = !currentlyOptedOut;
// Optimistic update — flip the local row immediately so the switch feels
// instant; revert on failure.
setPrefs((cur) =>
cur ? cur.map((p) => (p.workflow === workflow ? { ...p, optedOut: next } : p)) : cur,
cur
? cur.map((p) => (p.category === category ? { ...p, optedOut: nextOptedOut } : p))
: cur,
);
setPending((s) => new Set(s).add(workflow));
setPending((s) => new Set(s).add(category));
try {
await api.post("/email/preferences", { workflow, optedOut: next });
capture(next ? "email_workflow_opted_out" : "email_workflow_opted_in", { workflow });
toast.success(next ? "Bildirim kapatıldı." : "Bildirim açıldı.");
await api.post("/email/preferences", { category, optedOut: nextOptedOut });
capture(nextOptedOut ? "notifications_category_opted_out" : "notifications_category_opted_in", {
category,
});
toast.success(nextOptedOut ? "Bildirim kapatıldı." : "Bildirim açıldı.");
} catch (err) {
// Revert + surface the error.
setPrefs((cur) =>
cur
? cur.map((p) =>
p.workflow === workflow ? { ...p, optedOut: currentlyOptedOut } : p,
p.category === category ? { ...p, optedOut: currentlyOptedOut } : p,
)
: cur,
);
toast.error((err as Error).message || "Güncellenemedi");
} finally {
setPending((s) => {
const next = new Set(s);
next.delete(workflow);
return next;
const ns = new Set(s);
ns.delete(category);
return ns;
});
}
}
// Show two rows regardless of API state: keys are stable, so we render the
// skeleton in their slots until the GET resolves.
const categoryKeys: NotificationCategoryKey[] = ["email_marketing", "mobile_push"];
return (
<Card>
<CardHeader>
@@ -743,34 +738,35 @@ function NotificationsCard() {
<CardContent className="space-y-4">
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : prefs === null ? (
<div className="space-y-3">
{NOTIFICATION_WORKFLOWS.map((w) => (
<Skeleton key={w.workflow} className="h-16 w-full" />
))}
</div>
) : (
NOTIFICATION_WORKFLOWS.map((w) => {
const row = prefs.find((p) => p.workflow === w.workflow);
categoryKeys.map((key) => {
const row = prefs?.find((p) => p.category === key);
const isLoading = prefs === null;
if (isLoading) {
return <Skeleton key={key} className="h-20 w-full" />;
}
const fallback = FALLBACK_CATEGORY_COPY[key];
const label = row?.label ?? fallback.label;
const description = row?.description ?? fallback.description;
const optedOut = row?.optedOut ?? false;
const isPending = pending.has(w.workflow);
const isPending = pending.has(key);
return (
<div
key={w.workflow}
key={key}
className="flex items-start justify-between gap-4 rounded-lg border p-4"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{w.title}</p>
<p className="mt-1 text-sm text-muted-foreground">{w.description}</p>
<p className="text-sm font-medium">{label}</p>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
<Button
type="button"
variant={optedOut ? "outline" : "default"}
size="sm"
disabled={isPending}
onClick={() => toggle(w.workflow, optedOut)}
onClick={() => toggle(key, optedOut)}
aria-pressed={!optedOut}
aria-label={`${w.title}: ${optedOut ? "kapalı" : "açık"}`}
aria-label={`${label}: ${optedOut ? "kapalı" : "açık"}`}
>
{isPending ? "…" : optedOut ? "Kapalı" : "Açık"}
</Button>