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:
@@ -2,22 +2,27 @@ import { BadRequestException, Body, Controller, Get, Logger, Post } from "@nestj
|
|||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import {
|
import {
|
||||||
EmailPreferencesService,
|
EmailPreferencesService,
|
||||||
OPTIONAL_WORKFLOWS,
|
NOTIFICATION_CATEGORIES,
|
||||||
|
isNotificationCategory,
|
||||||
} from "./email-preferences.service";
|
} from "./email-preferences.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticated self-service preferences endpoint — paired with
|
* Authenticated self-service preferences endpoint — paired with
|
||||||
* UnsubscribeController which handles the unauthenticated one-click flow.
|
* UnsubscribeController which handles the unauthenticated one-click flow.
|
||||||
*
|
*
|
||||||
* GET /api/email/preferences — current state (all optional
|
* GET /api/email/preferences — returns the two UI categories
|
||||||
* workflows, with `optedOut: bool`).
|
* (`email_marketing`, `mobile_push`) with
|
||||||
* POST /api/email/preferences — body `{workflow, optedOut}`;
|
* current opt-out boolean and the
|
||||||
* true → insert opt-out row,
|
* static `label` + `description`.
|
||||||
* false → delete it.
|
* POST /api/email/preferences — body `{category, optedOut}`; toggles every
|
||||||
|
* underlying workflow at once.
|
||||||
*
|
*
|
||||||
* Backs the `/dashboard/settings?tab=notifications` UI. Auth and payment
|
* The endpoint is category-shaped (not per-workflow) because the dashboard UI
|
||||||
* workflows are deliberately not exposed: they're transactional and the
|
* only exposes two switches. UnsubscribeController is still per-workflow —
|
||||||
* service-level `OPTIONAL_WORKFLOWS` set is the single source of truth.
|
* one-click List-Unsubscribe URLs in mail can only mute the workflow that
|
||||||
|
* produced the click (we don't want clicking the welcome-mail unsub link to
|
||||||
|
* also kill the trial-ending nudge a week later). mailAudit.md §9.3 #14
|
||||||
|
* follow-on (category-rework 2026-06-05).
|
||||||
*/
|
*/
|
||||||
@Controller("email/preferences")
|
@Controller("email/preferences")
|
||||||
export class EmailPreferencesController {
|
export class EmailPreferencesController {
|
||||||
@@ -26,42 +31,40 @@ export class EmailPreferencesController {
|
|||||||
constructor(private readonly preferences: EmailPreferencesService) {}
|
constructor(private readonly preferences: EmailPreferencesService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the per-workflow opt-out state for the calling user. Always
|
* Returns one row per UI category — both keys are always present, so the
|
||||||
* includes every optional workflow — caller renders one row per — so a
|
* caller can render the switches without a guard.
|
||||||
* missing DB row is just `{optedOut: false}`.
|
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
async list(
|
async list(
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
): Promise<Array<{ workflow: string; optedOut: boolean }>> {
|
): Promise<Array<{ category: string; label: string; description: string; optedOut: boolean }>> {
|
||||||
const workflows = Array.from(OPTIONAL_WORKFLOWS);
|
return Promise.all(
|
||||||
const optedOutFlags = await Promise.all(
|
NOTIFICATION_CATEGORIES.map(async (cat) => ({
|
||||||
workflows.map((w) => this.preferences.isOptedOut(user.id, w)),
|
category: cat.key,
|
||||||
|
label: cat.label,
|
||||||
|
description: cat.description,
|
||||||
|
optedOut: await this.preferences.getCategoryState(user.id, cat.key),
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
return workflows.map((workflow, i) => ({ workflow, optedOut: optedOutFlags[i] }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggle a single workflow's opt-out state from the settings UI. */
|
/** Toggle a whole category's opt-out state from the settings UI. */
|
||||||
@Post()
|
@Post()
|
||||||
async update(
|
async update(
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
@Body() body: { workflow?: string; optedOut?: boolean },
|
@Body() body: { category?: string; optedOut?: boolean },
|
||||||
): Promise<{ workflow: string; optedOut: boolean }> {
|
): Promise<{ category: string; optedOut: boolean }> {
|
||||||
const { workflow, optedOut } = body;
|
const { category, optedOut } = body;
|
||||||
if (!workflow || typeof workflow !== "string" || !OPTIONAL_WORKFLOWS.has(workflow)) {
|
if (!isNotificationCategory(category)) {
|
||||||
throw new BadRequestException("invalid workflow");
|
throw new BadRequestException("invalid category");
|
||||||
}
|
}
|
||||||
if (typeof optedOut !== "boolean") {
|
if (typeof optedOut !== "boolean") {
|
||||||
throw new BadRequestException("optedOut must be boolean");
|
throw new BadRequestException("optedOut must be boolean");
|
||||||
}
|
}
|
||||||
if (optedOut) {
|
await this.preferences.setCategoryState(user.id, category, optedOut, "settings_page");
|
||||||
await this.preferences.optOut(user.id, workflow, "settings_page");
|
|
||||||
} else {
|
|
||||||
await this.preferences.optIn(user.id, workflow);
|
|
||||||
}
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[email-prefs] user=${user.id} workflow=${workflow} → ${optedOut ? "opt-out" : "opt-in"}`,
|
`[email-prefs] user=${user.id} category=${category} → ${optedOut ? "opt-out" : "opt-in"}`,
|
||||||
);
|
);
|
||||||
return { workflow, optedOut };
|
return { category, optedOut };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import * as schema from "../database/schema/core";
|
|||||||
* NOT in this set — they're transactional and must reach the user (the
|
* NOT in this set — they're transactional and must reach the user (the
|
||||||
* compliance argument is the same as Stripe's "we still send receipts even
|
* compliance argument is the same as Stripe's "we still send receipts even
|
||||||
* if you unsubscribed from marketing").
|
* if you unsubscribed from marketing").
|
||||||
|
*
|
||||||
|
* `mobile_push` is a pseudo-workflow — we don't ship push notifications yet
|
||||||
|
* but we accept the opt-out preference now so the toggle in the settings UI
|
||||||
|
* means something when mobile lands.
|
||||||
*/
|
*/
|
||||||
export const OPTIONAL_WORKFLOWS = new Set<string>([
|
export const OPTIONAL_WORKFLOWS = new Set<string>([
|
||||||
"welcome",
|
"welcome",
|
||||||
@@ -17,8 +21,54 @@ export const OPTIONAL_WORKFLOWS = new Set<string>([
|
|||||||
"referral",
|
"referral",
|
||||||
"referral-qualified",
|
"referral-qualified",
|
||||||
"referral-reward",
|
"referral-reward",
|
||||||
|
"mobile_push",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-facing notification categories. Each category bundles one or more
|
||||||
|
* underlying workflow names — the settings UI shows ONE toggle per category
|
||||||
|
* (not per workflow), because users care about "e-mail vs mobile" not "did
|
||||||
|
* the day-3 referral nudge fire". The category is opted-out when EVERY
|
||||||
|
* underlying workflow is opted-out; toggling it OFF inserts an opt-out row
|
||||||
|
* for each workflow, toggling ON deletes them all. mailAudit.md §9.3 #14
|
||||||
|
* follow-on.
|
||||||
|
*
|
||||||
|
* Order matters — that's the order the settings UI renders. Keep
|
||||||
|
* `email_marketing` first since it's the bigger lever today.
|
||||||
|
*/
|
||||||
|
export const NOTIFICATION_CATEGORIES = [
|
||||||
|
{
|
||||||
|
key: "email_marketing" as const,
|
||||||
|
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.",
|
||||||
|
workflows: [
|
||||||
|
"welcome",
|
||||||
|
"trial-ending",
|
||||||
|
"win-back",
|
||||||
|
"referral",
|
||||||
|
"referral-qualified",
|
||||||
|
"referral-reward",
|
||||||
|
] as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mobile_push" as const,
|
||||||
|
label: "Mobil bildirim",
|
||||||
|
description:
|
||||||
|
"Mobil uygulama push bildirimleri. Mobil uygulama yayınlandığında bu tercih kullanılır.",
|
||||||
|
workflows: ["mobile_push"] as const,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type NotificationCategoryKey = (typeof NOTIFICATION_CATEGORIES)[number]["key"];
|
||||||
|
|
||||||
|
export function isNotificationCategory(value: unknown): value is NotificationCategoryKey {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
NOTIFICATION_CATEGORIES.some((c) => c.key === (value as NotificationCategoryKey))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stateless HMAC token in the List-Unsubscribe URL — no DB lookup needed to
|
* Stateless HMAC token in the List-Unsubscribe URL — no DB lookup needed to
|
||||||
* validate. Anyone holding the token can opt out, but only the server can
|
* validate. Anyone holding the token can opt out, but only the server can
|
||||||
@@ -100,4 +150,42 @@ export class EmailPreferencesService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the opt-out state for a UI-facing category. A category is treated
|
||||||
|
* as "off" only when EVERY underlying workflow has been opted out — that
|
||||||
|
* way a stale per-workflow row from an earlier UI version doesn't make the
|
||||||
|
* category look off when it isn't.
|
||||||
|
*/
|
||||||
|
async getCategoryState(userId: string, key: NotificationCategoryKey): Promise<boolean> {
|
||||||
|
const cat = NOTIFICATION_CATEGORIES.find((c) => c.key === key);
|
||||||
|
if (!cat) return false;
|
||||||
|
const states = await Promise.all(
|
||||||
|
cat.workflows.map((w) => this.isOptedOut(userId, w)),
|
||||||
|
);
|
||||||
|
return states.length > 0 && states.every(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the user's category toggle to every underlying workflow. We don't
|
||||||
|
* try to be cute about "the user only toggled the category once, only some
|
||||||
|
* workflows are opted out" edge cases — toggling off means off for all,
|
||||||
|
* toggling on means on for all.
|
||||||
|
*/
|
||||||
|
async setCategoryState(
|
||||||
|
userId: string,
|
||||||
|
key: NotificationCategoryKey,
|
||||||
|
optedOut: boolean,
|
||||||
|
source: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const cat = NOTIFICATION_CATEGORIES.find((c) => c.key === key);
|
||||||
|
if (!cat) {
|
||||||
|
this.logger.warn(`refusing setCategoryState on unknown category ${key}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const w of cat.workflows) {
|
||||||
|
if (optedOut) await this.optOut(userId, w, source);
|
||||||
|
else await this.optIn(userId, w);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,50 +54,38 @@ const TAB_ITEMS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-workflow opt-out labels for the Notifications tab. Order matters —
|
* UI categories rendered by the Notifications tab. Each maps to a bundle of
|
||||||
* it's the order the user sees. Auth + payment workflows are deliberately
|
* underlying workflows on the server — see
|
||||||
* NOT here (transactional → must always reach the user). Kept in sync with
|
* `apps/api/src/notifications/email-preferences.service.ts
|
||||||
* apps/api/src/notifications/email-preferences.service.ts OPTIONAL_WORKFLOWS.
|
* 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<{
|
type NotificationCategoryKey = "email_marketing" | "mobile_push";
|
||||||
workflow: string;
|
|
||||||
title: string;
|
const FALLBACK_CATEGORY_COPY: Record<
|
||||||
description: string;
|
NotificationCategoryKey,
|
||||||
}> = [
|
{ label: string; description: string }
|
||||||
{
|
> = {
|
||||||
workflow: "welcome",
|
email_marketing: {
|
||||||
title: "Hoş geldin maili",
|
label: "E-posta bildirimleri",
|
||||||
description: "Kayıt olduktan hemen sonra gelen kısa karşılama.",
|
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.",
|
||||||
},
|
},
|
||||||
{
|
mobile_push: {
|
||||||
workflow: "trial-ending",
|
label: "Mobil bildirim",
|
||||||
title: "Deneme bitiş hatırlatması",
|
description:
|
||||||
description: "Deneme süresinin son birkaç gününde gönderilen yükseltme önerisi.",
|
"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 {
|
interface NotificationPref {
|
||||||
workflow: string;
|
category: NotificationCategoryKey;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
optedOut: boolean;
|
optedOut: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,7 +673,7 @@ export function SettingsContent({
|
|||||||
function NotificationsCard() {
|
function NotificationsCard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [prefs, setPrefs] = useState<NotificationPref[] | null>(null);
|
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);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -703,37 +691,44 @@ function NotificationsCard() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function toggle(workflow: string, currentlyOptedOut: boolean) {
|
async function toggle(category: NotificationCategoryKey, currentlyOptedOut: boolean) {
|
||||||
const next = !currentlyOptedOut;
|
const nextOptedOut = !currentlyOptedOut;
|
||||||
// Optimistic update — flip the local row immediately so the switch feels
|
// Optimistic update — flip the local row immediately so the switch feels
|
||||||
// instant; revert on failure.
|
// instant; revert on failure.
|
||||||
setPrefs((cur) =>
|
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 {
|
try {
|
||||||
await api.post("/email/preferences", { workflow, optedOut: next });
|
await api.post("/email/preferences", { category, optedOut: nextOptedOut });
|
||||||
capture(next ? "email_workflow_opted_out" : "email_workflow_opted_in", { workflow });
|
capture(nextOptedOut ? "notifications_category_opted_out" : "notifications_category_opted_in", {
|
||||||
toast.success(next ? "Bildirim kapatıldı." : "Bildirim açıldı.");
|
category,
|
||||||
|
});
|
||||||
|
toast.success(nextOptedOut ? "Bildirim kapatıldı." : "Bildirim açıldı.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Revert + surface the error.
|
|
||||||
setPrefs((cur) =>
|
setPrefs((cur) =>
|
||||||
cur
|
cur
|
||||||
? cur.map((p) =>
|
? cur.map((p) =>
|
||||||
p.workflow === workflow ? { ...p, optedOut: currentlyOptedOut } : p,
|
p.category === category ? { ...p, optedOut: currentlyOptedOut } : p,
|
||||||
)
|
)
|
||||||
: cur,
|
: cur,
|
||||||
);
|
);
|
||||||
toast.error((err as Error).message || "Güncellenemedi");
|
toast.error((err as Error).message || "Güncellenemedi");
|
||||||
} finally {
|
} finally {
|
||||||
setPending((s) => {
|
setPending((s) => {
|
||||||
const next = new Set(s);
|
const ns = new Set(s);
|
||||||
next.delete(workflow);
|
ns.delete(category);
|
||||||
return next;
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -743,34 +738,35 @@ function NotificationsCard() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
<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) => {
|
categoryKeys.map((key) => {
|
||||||
const row = prefs.find((p) => p.workflow === w.workflow);
|
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 optedOut = row?.optedOut ?? false;
|
||||||
const isPending = pending.has(w.workflow);
|
const isPending = pending.has(key);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={w.workflow}
|
key={key}
|
||||||
className="flex items-start justify-between gap-4 rounded-lg border p-4"
|
className="flex items-start justify-between gap-4 rounded-lg border p-4"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium">{w.title}</p>
|
<p className="text-sm font-medium">{label}</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{w.description}</p>
|
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={optedOut ? "outline" : "default"}
|
variant={optedOut ? "outline" : "default"}
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => toggle(w.workflow, optedOut)}
|
onClick={() => toggle(key, optedOut)}
|
||||||
aria-pressed={!optedOut}
|
aria-pressed={!optedOut}
|
||||||
aria-label={`${w.title}: ${optedOut ? "kapalı" : "açık"}`}
|
aria-label={`${label}: ${optedOut ? "kapalı" : "açık"}`}
|
||||||
>
|
>
|
||||||
{isPending ? "…" : optedOut ? "Kapalı" : "Açık"}
|
{isPending ? "…" : optedOut ? "Kapalı" : "Açık"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user