From 2baae0f09d4735e70a531679fcfeae90ba85226b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 10:50:13 +0300 Subject: [PATCH 01/17] feat: add broadcast data layer --- src/hooks/api/use-broadcasts.ts | 42 ++++++++++++ src/lib/api/endpoints/broadcasts.ts | 28 ++++++++ src/lib/types/announcement.ts | 102 ++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 src/hooks/api/use-broadcasts.ts create mode 100644 src/lib/api/endpoints/broadcasts.ts create mode 100644 src/lib/types/announcement.ts diff --git a/src/hooks/api/use-broadcasts.ts b/src/hooks/api/use-broadcasts.ts new file mode 100644 index 0000000..c517b34 --- /dev/null +++ b/src/hooks/api/use-broadcasts.ts @@ -0,0 +1,42 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { broadcastsApi } from "@/lib/api/endpoints/broadcasts"; +import type { BroadcastCreate, BroadcastListParams } from "@/lib/types/announcement"; + +export const broadcastKeys = { + all: ["broadcasts"] as const, + list: (params?: BroadcastListParams) => ["broadcasts", "list", params ?? {}] as const, + templates: ["broadcasts", "templates"] as const, + activeAnnouncement: ["announcements", "active"] as const, +}; + +export const useBroadcasts = (params?: BroadcastListParams) => + useQuery({ + queryKey: broadcastKeys.list(params), + queryFn: () => broadcastsApi.list(params), + placeholderData: keepPreviousData, + }); + +export const useBroadcastTemplates = () => + useQuery({ + queryKey: broadcastKeys.templates, + queryFn: () => broadcastsApi.templates(), + staleTime: 1000 * 60 * 60, // catalog is static — cache for an hour + }); + +export const useActiveAnnouncement = () => + useQuery({ + queryKey: broadcastKeys.activeAnnouncement, + queryFn: () => broadcastsApi.active(), + }); + +// Success toast is driven by the backend `message` via the axios interceptor. +export const useSendBroadcast = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: BroadcastCreate) => broadcastsApi.send(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: broadcastKeys.all }); + }, + }); +}; diff --git a/src/lib/api/endpoints/broadcasts.ts b/src/lib/api/endpoints/broadcasts.ts new file mode 100644 index 0000000..86ac065 --- /dev/null +++ b/src/lib/api/endpoints/broadcasts.ts @@ -0,0 +1,28 @@ +import api from "@/lib/api/api"; +import { pruneParams } from "@/lib/api/endpoints/admin"; +import type { + ActiveBannerResponse, + AnnouncementListResponse, + BroadcastCreate, + BroadcastCreateResponse, + BroadcastListParams, + BroadcastTemplateCatalogResponse, +} from "@/lib/types/announcement"; + +export const broadcastsApi = { + send: (payload: BroadcastCreate): Promise => + api.post("/admin/broadcasts", payload), + + list: (params?: BroadcastListParams): Promise => + api.get("/admin/broadcasts", { + params: pruneParams(params), + }), + + templates: (): Promise => + api.get( + "/admin/broadcast-templates", + ), + + active: (): Promise => + api.get("/announcements/active"), +}; diff --git a/src/lib/types/announcement.ts b/src/lib/types/announcement.ts new file mode 100644 index 0000000..cf6811b --- /dev/null +++ b/src/lib/types/announcement.ts @@ -0,0 +1,102 @@ +import type { SystemRole } from "@/lib/types/user"; + +// --- Enumerations (string unions mirroring the backend StrEnums) ----------- + +export type AnnouncementKind = "template" | "custom"; +export type AnnouncementLevel = "info" | "warning" | "critical"; +export type AnnouncementAudience = "all" | "active" | "role"; +export type AnnouncementVariableType = "text" | "datetime"; + +// Supported content languages (mirrors the backend Language enum). +export type AnnouncementLanguage = "en" | "tr"; + +// --- Content & variables ---------------------------------------------------- + +export interface AnnouncementContent { + title: string; + body: string; +} + +// A submitted variable value: a single ISO datetime string, or a per-language +// map for free text. The template variable's type decides which shape applies. +export type AnnouncementVariableValue = string | Partial>; + +// --- Template catalog ------------------------------------------------------- + +export interface TemplateVariable { + name: string; + type: AnnouncementVariableType; +} + +export interface BroadcastTemplate { + key: string; + variables: TemplateVariable[]; + translations: Partial>; +} + +export interface BroadcastTemplateCatalogResponse { + templates: BroadcastTemplate[]; +} + +// --- Create (send) payload -------------------------------------------------- + +export interface BroadcastCreate { + kind: AnnouncementKind; + template_key?: string | null; + variables?: Record; + translations?: Partial>; + level?: AnnouncementLevel; + audience?: AnnouncementAudience; + role_filter?: SystemRole | null; + show_banner?: boolean; + send_email?: boolean; +} + +// --- Read shapes ------------------------------------------------------------ + +export interface AnnouncementCreator { + id: string; + email: string; + first_name?: string | null; + last_name?: string | null; +} + +export interface AnnouncementRead { + id: string; + kind: AnnouncementKind; + template_key?: string | null; + variables: Record; + translations: Partial>; + level: AnnouncementLevel; + audience: AnnouncementAudience; + role_filter?: SystemRole | null; + show_banner: boolean; + send_email: boolean; + created_by?: string | null; + creator?: AnnouncementCreator | null; + created_at: string; +} + +export interface AnnouncementListResponse { + data: AnnouncementRead[]; + total: number; + skip: number; + limit: number; +} + +export interface BroadcastCreateResponse { + announcement: AnnouncementRead; + recipients: number; + message: string; +} + +export interface ActiveBannerResponse { + announcement: AnnouncementRead | null; +} + +// --- Query params ----------------------------------------------------------- + +export interface BroadcastListParams { + skip?: number; + limit?: number; +} From d4e7a5513b816bf250e64abe3be514cbb65fb612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 11:07:39 +0300 Subject: [PATCH 02/17] feat: add broadcasts i18n namespace --- src/i18n/config.ts | 5 +++ src/i18n/locales/en/broadcasts.json | 62 +++++++++++++++++++++++++++++ src/i18n/locales/tr/broadcasts.json | 62 +++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 src/i18n/locales/en/broadcasts.json create mode 100644 src/i18n/locales/tr/broadcasts.json diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 0a6388a..afa30a2 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -9,6 +9,7 @@ import enAbout from "./locales/en/about.json"; import enAccount from "./locales/en/account.json"; import enAdmin from "./locales/en/admin.json"; import enAuth from "./locales/en/auth.json"; +import enBroadcasts from "./locales/en/broadcasts.json"; import enCommon from "./locales/en/common.json"; import enDashboard from "./locales/en/dashboard.json"; import enErrors from "./locales/en/errors.json"; @@ -24,6 +25,7 @@ import trAbout from "./locales/tr/about.json"; import trAccount from "./locales/tr/account.json"; import trAdmin from "./locales/tr/admin.json"; import trAuth from "./locales/tr/auth.json"; +import trBroadcasts from "./locales/tr/broadcasts.json"; import trCommon from "./locales/tr/common.json"; import trDashboard from "./locales/tr/dashboard.json"; import trErrors from "./locales/tr/errors.json"; @@ -53,6 +55,7 @@ const resources = { notifications: enNotifications, support: enSupport, upload: enUpload, + broadcasts: enBroadcasts, }, tr: { common: trCommon, @@ -70,6 +73,7 @@ const resources = { notifications: trNotifications, support: trSupport, upload: trUpload, + broadcasts: trBroadcasts, }, }; @@ -98,6 +102,7 @@ i18n "notifications", "support", "upload", + "broadcasts", ], interpolation: { diff --git a/src/i18n/locales/en/broadcasts.json b/src/i18n/locales/en/broadcasts.json new file mode 100644 index 0000000..0bed2c4 --- /dev/null +++ b/src/i18n/locales/en/broadcasts.json @@ -0,0 +1,62 @@ +{ + "templates": { + "maintenance": { + "title": "Scheduled Maintenance", + "body": "The system will be under maintenance from {{starts_at}} to {{ends_at}}; brief interruptions may occur." + }, + "welcome": { + "title": "Welcome", + "body": "Welcome aboard! Your account is ready — complete your profile whenever you like." + }, + "new_feature": { + "title": "New Feature", + "body": "A new feature is now live: {{feature}}. Check it out!" + }, + "security_notice": { + "title": "Security Notice", + "body": "For your account's safety, we recommend reviewing your password and using a strong one." + } + }, + "admin": { + "title": "Announcements", + "subtitle": "Compose and send an announcement to your users.", + "compose": "New announcement", + "form": { + "modeLabel": "Type", + "modeTemplate": "Template", + "modeCustom": "Custom", + "templateLabel": "Template", + "templatePlaceholder": "Choose a template", + "titleLabel": "Title", + "bodyLabel": "Message", + "levelLabel": "Importance", + "audienceLabel": "Audience", + "roleLabel": "Role", + "showBanner": "Also show as a top banner", + "sendEmail": "Also send as email", + "submit": "Send announcement", + "cancel": "Cancel", + "level": { "info": "Info", "warning": "Warning", "critical": "Critical" }, + "audience": { "all": "All users", "active": "Active users", "role": "By role" } + }, + "vars": { + "starts_at": "Starts at", + "ends_at": "Ends at", + "feature": "Feature name" + }, + "history": { + "title": "Sent announcements", + "empty": "No announcements sent yet.", + "recipients_one": "{{count}} recipient", + "recipients_other": "{{count}} recipients", + "sentBy": "by {{name}}", + "emailed": "Emailed" + }, + "result_one": "Announcement sent to {{count}} user.", + "result_other": "Announcement sent to {{count}} users." + }, + "banner": { + "details": "Details", + "dismiss": "Dismiss" + } +} diff --git a/src/i18n/locales/tr/broadcasts.json b/src/i18n/locales/tr/broadcasts.json new file mode 100644 index 0000000..60bc4b3 --- /dev/null +++ b/src/i18n/locales/tr/broadcasts.json @@ -0,0 +1,62 @@ +{ + "templates": { + "maintenance": { + "title": "Planlı Bakım", + "body": "Sistem {{starts_at}} – {{ends_at}} arasında bakımda olacak; kısa kesintiler yaşanabilir." + }, + "welcome": { + "title": "Hoş Geldiniz", + "body": "Aramıza hoş geldiniz! Hesabınız hazır; dilediğiniz zaman profilinizi tamamlayabilirsiniz." + }, + "new_feature": { + "title": "Yeni Özellik", + "body": "Yeni bir özellik yayında: {{feature}}. Hemen göz atın!" + }, + "security_notice": { + "title": "Güvenlik Bildirimi", + "body": "Hesabınızın güvenliği için şifrenizi gözden geçirmenizi ve güçlü bir şifre kullanmanızı öneririz." + } + }, + "admin": { + "title": "Duyurular", + "subtitle": "Kullanıcılarınıza duyuru oluşturun ve gönderin.", + "compose": "Yeni duyuru", + "form": { + "modeLabel": "Tür", + "modeTemplate": "Şablon", + "modeCustom": "Özel", + "templateLabel": "Şablon", + "templatePlaceholder": "Bir şablon seçin", + "titleLabel": "Başlık", + "bodyLabel": "Mesaj", + "levelLabel": "Önem", + "audienceLabel": "Hedef kitle", + "roleLabel": "Rol", + "showBanner": "Üst banner olarak da göster", + "sendEmail": "E-posta olarak da gönder", + "submit": "Duyuruyu gönder", + "cancel": "İptal", + "level": { "info": "Bilgi", "warning": "Uyarı", "critical": "Kritik" }, + "audience": { "all": "Tüm kullanıcılar", "active": "Aktif kullanıcılar", "role": "Role göre" } + }, + "vars": { + "starts_at": "Başlangıç", + "ends_at": "Bitiş", + "feature": "Özellik adı" + }, + "history": { + "title": "Gönderilen duyurular", + "empty": "Henüz duyuru gönderilmedi.", + "recipients_one": "{{count}} alıcı", + "recipients_other": "{{count}} alıcı", + "sentBy": "{{name}} tarafından", + "emailed": "E-posta gönderildi" + }, + "result_one": "Duyuru {{count}} kullanıcıya gönderildi.", + "result_other": "Duyuru {{count}} kullanıcıya gönderildi." + }, + "banner": { + "details": "Detay", + "dismiss": "Kapat" + } +} From 984e4c06258840d46e4853a5ff0c5f53a146efc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 11:36:21 +0300 Subject: [PATCH 03/17] feat: add broadcast content form schema --- src/i18n/locales/en/validation.json | 3 ++- src/i18n/locales/tr/validation.json | 3 ++- src/schemas/announcement.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/schemas/announcement.ts diff --git a/src/i18n/locales/en/validation.json b/src/i18n/locales/en/validation.json index ca6ab64..0e986c0 100644 --- a/src/i18n/locales/en/validation.json +++ b/src/i18n/locales/en/validation.json @@ -13,5 +13,6 @@ "deactivateAcknowledgeRequired": "You must confirm that you understand the consequence.", "subjectMin": "Subject must be at least {{count}} characters", "bodyMin": "Message must be at least {{count}} characters", - "messageRequired": "Message cannot be empty" + "messageRequired": "Message cannot be empty", + "titleRequired": "Title is required" } diff --git a/src/i18n/locales/tr/validation.json b/src/i18n/locales/tr/validation.json index 22bd95e..eb9bbc2 100644 --- a/src/i18n/locales/tr/validation.json +++ b/src/i18n/locales/tr/validation.json @@ -13,5 +13,6 @@ "deactivateAcknowledgeRequired": "Sonucu anladığınızı onaylamanız gerekir.", "subjectMin": "Konu en az {{count}} karakter olmalıdır", "bodyMin": "Mesaj en az {{count}} karakter olmalıdır", - "messageRequired": "Mesaj boş olamaz" + "messageRequired": "Mesaj boş olamaz", + "titleRequired": "Başlık gerekli" } diff --git a/src/schemas/announcement.ts b/src/schemas/announcement.ts new file mode 100644 index 0000000..aa7cedd --- /dev/null +++ b/src/schemas/announcement.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +import type { TFunction } from "i18next"; + +// Mirrors the backend per-language content limits (banner stays single-line on +// desktop, readable in the mobile modal). The Ant inputs hard-cap with +// `maxLength`, so these Zod bounds are a safety net; min(1) drives the inline +// "required" error per language field. +export const ANNOUNCEMENT_TITLE_MAX = 120; +export const ANNOUNCEMENT_BODY_MAX = 600; + +export const getAnnouncementContentSchema = (t: TFunction) => + z.object({ + title: z + .string() + .trim() + .min(1, { message: t("validation:titleRequired") }) + .max(ANNOUNCEMENT_TITLE_MAX), + body: z + .string() + .trim() + .min(1, { message: t("validation:messageRequired") }) + .max(ANNOUNCEMENT_BODY_MAX), + }); + +export type AnnouncementContentValues = z.infer>; From d5e4db43b4af671f52c2a70dac33294ca184ca49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 12:01:26 +0300 Subject: [PATCH 04/17] feat: add template variable form fields --- .../broadcasts/TemplateVariableFields.tsx | 59 +++++++++++++++++++ src/i18n/locales/en/validation.json | 3 +- src/i18n/locales/tr/validation.json | 3 +- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 src/components/admin/broadcasts/TemplateVariableFields.tsx diff --git a/src/components/admin/broadcasts/TemplateVariableFields.tsx b/src/components/admin/broadcasts/TemplateVariableFields.tsx new file mode 100644 index 0000000..60bfaff --- /dev/null +++ b/src/components/admin/broadcasts/TemplateVariableFields.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Form, Input, Tabs } from "antd"; +import { useTranslation } from "react-i18next"; + +import type { AnnouncementLanguage, BroadcastTemplate } from "@/lib/types/announcement"; +import { ANNOUNCEMENT_TITLE_MAX } from "@/schemas/announcement"; + +const LANGUAGES: AnnouncementLanguage[] = ["tr", "en"]; + +interface Props { + template: BroadcastTemplate; + disabled?: boolean; +} + +export function TemplateVariableFields({ template, disabled }: Props) { + const { t } = useTranslation("broadcasts"); + const label = (name: string) => t(`admin.vars.${name}`, { defaultValue: name }); + const required = { required: true, message: t("validation:required") }; + + const datetimeVars = template.variables.filter((v) => v.type === "datetime"); + const textVars = template.variables.filter((v) => v.type === "text"); + + return ( + <> + {datetimeVars.map((variable) => ( + + {/* datetime-local: Ant-styled wrapper, native value, no dayjs. The + composer converts the local string to a UTC ISO at submit. */} + + + ))} + + {textVars.length > 0 ? ( + ({ + key: lang, + label: lang.toUpperCase(), + children: textVars.map((variable) => ( + + + + )), + }))} + /> + ) : null} + + ); +} diff --git a/src/i18n/locales/en/validation.json b/src/i18n/locales/en/validation.json index 0e986c0..c7b85f7 100644 --- a/src/i18n/locales/en/validation.json +++ b/src/i18n/locales/en/validation.json @@ -14,5 +14,6 @@ "subjectMin": "Subject must be at least {{count}} characters", "bodyMin": "Message must be at least {{count}} characters", "messageRequired": "Message cannot be empty", - "titleRequired": "Title is required" + "titleRequired": "Title is required", + "required": "This field is required" } diff --git a/src/i18n/locales/tr/validation.json b/src/i18n/locales/tr/validation.json index eb9bbc2..b65f024 100644 --- a/src/i18n/locales/tr/validation.json +++ b/src/i18n/locales/tr/validation.json @@ -14,5 +14,6 @@ "subjectMin": "Konu en az {{count}} karakter olmalıdır", "bodyMin": "Mesaj en az {{count}} karakter olmalıdır", "messageRequired": "Mesaj boş olamaz", - "titleRequired": "Başlık gerekli" + "titleRequired": "Başlık gerekli", + "required": "Bu alan gerekli" } From 026ab624335ab0a4856f880b3c622342cee81f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 12:09:45 +0300 Subject: [PATCH 05/17] feat: add custom + Zod-validate variable fields --- .../admin/broadcasts/CustomLanguageFields.tsx | 58 +++++++++++++++++++ .../broadcasts/TemplateVariableFields.tsx | 15 +++-- src/schemas/announcement.ts | 16 +++++ 3 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 src/components/admin/broadcasts/CustomLanguageFields.tsx diff --git a/src/components/admin/broadcasts/CustomLanguageFields.tsx b/src/components/admin/broadcasts/CustomLanguageFields.tsx new file mode 100644 index 0000000..c64aa91 --- /dev/null +++ b/src/components/admin/broadcasts/CustomLanguageFields.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Form, Input, Tabs } from "antd"; +import { useTranslation } from "react-i18next"; + +import type { AnnouncementLanguage } from "@/lib/types/announcement"; +import { zodFieldRule } from "@/lib/validation/zodToAntdRule"; +import { + ANNOUNCEMENT_BODY_MAX, + ANNOUNCEMENT_TITLE_MAX, + getAnnouncementContentSchema, +} from "@/schemas/announcement"; + +const LANGUAGES: AnnouncementLanguage[] = ["tr", "en"]; + +interface Props { + disabled?: boolean; +} + +export function CustomLanguageFields({ disabled }: Props) { + const { t } = useTranslation("broadcasts"); + const schema = getAnnouncementContentSchema(t); + + return ( + ({ + key: lang, + label: lang.toUpperCase(), + // forceRender mounts both language tabs so every language validates on + // submit even if the admin never opens it (all languages are required). + forceRender: true, + children: ( + <> + + + + + + + + ), + }))} + /> + ); +} diff --git a/src/components/admin/broadcasts/TemplateVariableFields.tsx b/src/components/admin/broadcasts/TemplateVariableFields.tsx index 60bfaff..5dc4052 100644 --- a/src/components/admin/broadcasts/TemplateVariableFields.tsx +++ b/src/components/admin/broadcasts/TemplateVariableFields.tsx @@ -4,7 +4,12 @@ import { Form, Input, Tabs } from "antd"; import { useTranslation } from "react-i18next"; import type { AnnouncementLanguage, BroadcastTemplate } from "@/lib/types/announcement"; -import { ANNOUNCEMENT_TITLE_MAX } from "@/schemas/announcement"; +import { zodFieldRule } from "@/lib/validation/zodToAntdRule"; +import { + ANNOUNCEMENT_TITLE_MAX, + getDatetimeFieldSchema, + getTextVarSchema, +} from "@/schemas/announcement"; const LANGUAGES: AnnouncementLanguage[] = ["tr", "en"]; @@ -16,7 +21,8 @@ interface Props { export function TemplateVariableFields({ template, disabled }: Props) { const { t } = useTranslation("broadcasts"); const label = (name: string) => t(`admin.vars.${name}`, { defaultValue: name }); - const required = { required: true, message: t("validation:required") }; + const datetimeRule = zodFieldRule(getDatetimeFieldSchema(t)); + const textRule = zodFieldRule(getTextVarSchema(t)); const datetimeVars = template.variables.filter((v) => v.type === "datetime"); const textVars = template.variables.filter((v) => v.type === "text"); @@ -28,7 +34,7 @@ export function TemplateVariableFields({ template, disabled }: Props) { key={variable.name} name={["variables", variable.name]} label={label(variable.name)} - rules={[required]} + rules={[datetimeRule]} > {/* datetime-local: Ant-styled wrapper, native value, no dayjs. The composer converts the local string to a UTC ISO at submit. */} @@ -41,12 +47,13 @@ export function TemplateVariableFields({ template, disabled }: Props) { items={LANGUAGES.map((lang) => ({ key: lang, label: lang.toUpperCase(), + forceRender: true, children: textVars.map((variable) => ( diff --git a/src/schemas/announcement.ts b/src/schemas/announcement.ts index aa7cedd..6fcbeb1 100644 --- a/src/schemas/announcement.ts +++ b/src/schemas/announcement.ts @@ -24,3 +24,19 @@ export const getAnnouncementContentSchema = (t: TFunction) => }); export type AnnouncementContentValues = z.infer>; + +// Per-variable field schemas for the template compose form (Zod everywhere). +// A datetime variable holds a `datetime-local` string; a text variable holds a +// short free-text value per language. +export const getDatetimeFieldSchema = (t: TFunction) => + z + .string() + .trim() + .min(1, { message: t("validation:required") }); + +export const getTextVarSchema = (t: TFunction) => + z + .string() + .trim() + .min(1, { message: t("validation:required") }) + .max(ANNOUNCEMENT_TITLE_MAX); From 8430ca1aec872b50a71480e14c76c81c01886702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 12:15:43 +0300 Subject: [PATCH 06/17] feat: add live announcement preview --- .../admin/broadcasts/AnnouncementPreview.tsx | 93 +++++++++++++++++++ src/i18n/locales/en/broadcasts.json | 2 + src/i18n/locales/tr/broadcasts.json | 2 + 3 files changed, 97 insertions(+) create mode 100644 src/components/admin/broadcasts/AnnouncementPreview.tsx diff --git a/src/components/admin/broadcasts/AnnouncementPreview.tsx b/src/components/admin/broadcasts/AnnouncementPreview.tsx new file mode 100644 index 0000000..30f710b --- /dev/null +++ b/src/components/admin/broadcasts/AnnouncementPreview.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useTranslation } from "react-i18next"; + +import { formatDateTime } from "@/lib/format-date"; +import type { + AnnouncementKind, + AnnouncementLanguage, + AnnouncementLevel, + BroadcastTemplate, +} from "@/lib/types/announcement"; + +import type { TFunction } from "i18next"; + +interface Props { + kind: AnnouncementKind; + level: AnnouncementLevel; + template?: BroadcastTemplate; + variables?: Record>; + translations?: Record; +} + +const LEVEL_ACCENT: Record = { + info: "border-l-primary", + warning: "border-l-amber-500", + critical: "border-l-destructive", +}; + +// SYNC NOTE: in-app template text comes from FE i18n (broadcasts.json +// `templates.`); the EMAIL text lives in the backend catalog +// (fastapi-template/app/core/broadcast_templates.py). When a template changes, +// update BOTH and keep keys + variable names identical. +// See memory: broadcast-template-text-dual-source. +function renderTemplate( + t: TFunction, + lang: AnnouncementLanguage, + template: BroadcastTemplate | undefined, + variables: Props["variables"], +): { title: string; body: string } | null { + if (!template) return null; + const values: Record = {}; + for (const variable of template.variables) { + const raw = variables?.[variable.name]; + if (variable.type === "datetime" && typeof raw === "string" && raw) { + values[variable.name] = formatDateTime(raw); // viewer-local (in-app) + } else if (variable.type === "text" && raw && typeof raw === "object") { + values[variable.name] = raw[lang] ?? raw.en ?? raw.tr ?? ""; + } + } + return { + title: t(`templates.${template.key}.title`), + body: t(`templates.${template.key}.body`, values), + }; +} + +function renderCustom( + lang: AnnouncementLanguage, + translations: Props["translations"], +): { title: string; body: string } | null { + const entry = translations?.[lang] ?? translations?.en ?? translations?.tr; + if (!entry?.title && !entry?.body) return null; + return { title: entry.title ?? "", body: entry.body ?? "" }; +} + +export function AnnouncementPreview({ kind, level, template, variables, translations }: Props) { + const { t, i18n } = useTranslation("broadcasts"); + const lang = (i18n.language?.split("-")[0] ?? "en") as AnnouncementLanguage; + + const content = + kind === "template" + ? renderTemplate(t, lang, template, variables) + : renderCustom(lang, translations); + + if (!content) { + return ( +
+ {t("admin.previewEmpty")} +
+ ); + } + + return ( +
+

+ {t("admin.previewLabel")} +

+

{content.title}

+

{content.body}

+
+ ); +} diff --git a/src/i18n/locales/en/broadcasts.json b/src/i18n/locales/en/broadcasts.json index 0bed2c4..ebfebdd 100644 --- a/src/i18n/locales/en/broadcasts.json +++ b/src/i18n/locales/en/broadcasts.json @@ -21,6 +21,8 @@ "title": "Announcements", "subtitle": "Compose and send an announcement to your users.", "compose": "New announcement", + "previewLabel": "Preview", + "previewEmpty": "Fill in the form to see a preview.", "form": { "modeLabel": "Type", "modeTemplate": "Template", diff --git a/src/i18n/locales/tr/broadcasts.json b/src/i18n/locales/tr/broadcasts.json index 60bc4b3..33cb671 100644 --- a/src/i18n/locales/tr/broadcasts.json +++ b/src/i18n/locales/tr/broadcasts.json @@ -21,6 +21,8 @@ "title": "Duyurular", "subtitle": "Kullanıcılarınıza duyuru oluşturun ve gönderin.", "compose": "Yeni duyuru", + "previewLabel": "Önizleme", + "previewEmpty": "Önizleme için formu doldurun.", "form": { "modeLabel": "Tür", "modeTemplate": "Şablon", From ca5bdd393a8c58e2d7e7b545778156578cbb2c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 12:22:35 +0300 Subject: [PATCH 07/17] feat: add broadcast composer form --- .../admin/broadcasts/BroadcastComposer.tsx | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 src/components/admin/broadcasts/BroadcastComposer.tsx diff --git a/src/components/admin/broadcasts/BroadcastComposer.tsx b/src/components/admin/broadcasts/BroadcastComposer.tsx new file mode 100644 index 0000000..1228c05 --- /dev/null +++ b/src/components/admin/broadcasts/BroadcastComposer.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { Form, Segmented, Select, Switch } from "antd"; +import { motion } from "motion/react"; +import { useTranslation } from "react-i18next"; + +import { AnnouncementPreview } from "@/components/admin/broadcasts/AnnouncementPreview"; +import { CustomLanguageFields } from "@/components/admin/broadcasts/CustomLanguageFields"; +import { TemplateVariableFields } from "@/components/admin/broadcasts/TemplateVariableFields"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { useBroadcastTemplates, useSendBroadcast } from "@/hooks/api/use-broadcasts"; +import { scaleIn } from "@/lib/motion/variants"; +import type { + AnnouncementAudience, + AnnouncementKind, + AnnouncementLevel, + AnnouncementVariableValue, + BroadcastCreate, + BroadcastTemplate, +} from "@/lib/types/announcement"; +import { SystemRole } from "@/lib/types/user"; + +interface FormValues { + kind: AnnouncementKind; + template_key?: string; + variables?: Record>; + translations?: Record; + level: AnnouncementLevel; + audience: AnnouncementAudience; + role_filter?: SystemRole; + show_banner: boolean; + send_email: boolean; +} + +// datetime variables come from as a local string +// ("2026-06-14T02:00") → store as UTC ISO; text variables are already {tr,en}. +const buildVariables = ( + template: BroadcastTemplate | undefined, + raw: FormValues["variables"], +): Record => { + const out: Record = {}; + if (!template || !raw) return out; + for (const variable of template.variables) { + const value = raw[variable.name]; + if (value === undefined) continue; + if (variable.type === "datetime" && typeof value === "string") { + out[variable.name] = new Date(value).toISOString(); + } else if (variable.type === "text" && typeof value === "object") { + out[variable.name] = value; + } + } + return out; +}; + +export function BroadcastComposer() { + const { t } = useTranslation("broadcasts"); + const [form] = Form.useForm(); + const { data: catalog } = useBroadcastTemplates(); + const { mutate, isPending } = useSendBroadcast(); + + const kind: AnnouncementKind = Form.useWatch("kind", form) ?? "custom"; + const templateKey = Form.useWatch("template_key", form); + const audience: AnnouncementAudience = Form.useWatch("audience", form) ?? "all"; + const level: AnnouncementLevel = Form.useWatch("level", form) ?? "info"; + const variables = Form.useWatch("variables", form); + const translations = Form.useWatch("translations", form); + const selectedTemplate = catalog?.templates.find((tpl) => tpl.key === templateKey); + + const onFinish = (values: FormValues) => { + const payload: BroadcastCreate = { + kind: values.kind, + level: values.level, + audience: values.audience, + role_filter: values.audience === "role" ? values.role_filter : null, + show_banner: values.show_banner, + send_email: values.send_email, + }; + if (values.kind === "template") { + payload.template_key = values.template_key; + payload.variables = buildVariables(selectedTemplate, values.variables); + } else { + payload.translations = values.translations; + } + mutate(payload, { + onSuccess: () => { + form.resetFields(); + }, + }); + }; + + const levelOptions = (["info", "warning", "critical"] as const).map((v) => ({ + value: v, + label: t(`admin.form.level.${v}`), + })); + const audienceOptions = (["all", "active", "role"] as const).map((v) => ({ + value: v, + label: t(`admin.form.audience.${v}`), + })); + const roleOptions = [SystemRole.USER, SystemRole.ADMIN, SystemRole.SUPERADMIN].map((r) => ({ + value: r, + label: r, + })); + + return ( + + + +
+ + + + + {kind === "template" ? ( + <> + + + + + + + ) : null} + +
+ + + + + + +
+ + + +
+ +
+ +
+
+
+ ); +} From d8923e0c145a9c1eec48385508760bd396be28c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 13:36:25 +0300 Subject: [PATCH 08/17] feat: wire broadcasts route, nav and permissions --- src/app/[locale]/admin/(protected)/AdminShell.tsx | 3 +++ src/app/[locale]/admin/(protected)/admin-nav.ts | 8 ++++++++ src/hooks/use-permissions.ts | 3 +++ src/i18n/locales/en/admin.json | 1 + src/i18n/locales/tr/admin.json | 1 + src/lib/config/routes.ts | 3 +++ src/lib/types/permissions.ts | 2 ++ 7 files changed, 21 insertions(+) diff --git a/src/app/[locale]/admin/(protected)/AdminShell.tsx b/src/app/[locale]/admin/(protected)/AdminShell.tsx index 793962e..9ad8dc4 100644 --- a/src/app/[locale]/admin/(protected)/AdminShell.tsx +++ b/src/app/[locale]/admin/(protected)/AdminShell.tsx @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"; import { useAccountEvents } from "@/hooks/use-account-events"; import { useCanReadActivities, + useCanReadBroadcasts, useCanReadFiles, useCanReadSupport, useCanReadUsers, @@ -54,6 +55,7 @@ export function AdminShell({ children }: AdminShellProps) { const canReadFiles = useCanReadFiles(); const canReadActivities = useCanReadActivities(); const canReadSupport = useCanReadSupport(); + const canReadBroadcasts = useCanReadBroadcasts(); const isSuperadmin = useIsSuperadmin(); const currentLocale = getLocaleFromPath(pathname); const pathWithoutLocale = getPathWithoutLocale(pathname); @@ -105,6 +107,7 @@ export function AdminShell({ children }: AdminShellProps) { canReadFiles, canReadActivities, canReadSupport, + canReadBroadcasts, isSuperadmin, }); diff --git a/src/app/[locale]/admin/(protected)/admin-nav.ts b/src/app/[locale]/admin/(protected)/admin-nav.ts index a1162ec..d3212d3 100644 --- a/src/app/[locale]/admin/(protected)/admin-nav.ts +++ b/src/app/[locale]/admin/(protected)/admin-nav.ts @@ -4,6 +4,7 @@ import { CircleUser, Images, LayoutDashboard, + Megaphone, Ticket, UserCog, Users, @@ -32,6 +33,7 @@ export interface AdminNavCaps { canReadFiles: boolean; canReadActivities: boolean; canReadSupport: boolean; + canReadBroadcasts: boolean; isSuperadmin: boolean; } @@ -47,6 +49,12 @@ export const buildAdminNav = (caps: AdminNavCaps): AdminNavSection[] => { { key: "users", href: ROUTES.adminUsers, icon: Users, show: caps.canReadUsers }, { key: "admins", href: ROUTES.adminAdmins, icon: UserCog, show: caps.isSuperadmin }, { key: "files", href: ROUTES.adminFiles, icon: Images, show: caps.canReadFiles }, + { + key: "broadcasts", + href: ROUTES.adminBroadcasts, + icon: Megaphone, + show: caps.canReadBroadcasts, + }, ], }, { diff --git a/src/hooks/use-permissions.ts b/src/hooks/use-permissions.ts index ab91527..7eecec7 100644 --- a/src/hooks/use-permissions.ts +++ b/src/hooks/use-permissions.ts @@ -61,3 +61,6 @@ export const useCanUpdateSupport = (): boolean => usePermissions().has(Permissio export const useCanReadActivities = (): boolean => usePermissions().has(Permission.ActivitiesRead); export const useCanReadStats = (): boolean => usePermissions().has(Permission.StatsRead); + +export const useCanReadBroadcasts = (): boolean => usePermissions().has(Permission.BroadcastRead); +export const useCanWriteBroadcasts = (): boolean => usePermissions().has(Permission.BroadcastWrite); diff --git a/src/i18n/locales/en/admin.json b/src/i18n/locales/en/admin.json index 8ea164b..17027f2 100644 --- a/src/i18n/locales/en/admin.json +++ b/src/i18n/locales/en/admin.json @@ -128,6 +128,7 @@ "activities": "Activity log", "support": "Support", "notifications": "Notifications", + "broadcasts": "Announcements", "profile": "Profile" }, "signedInAs": "Signed in as", diff --git a/src/i18n/locales/tr/admin.json b/src/i18n/locales/tr/admin.json index ed79a41..9764b60 100644 --- a/src/i18n/locales/tr/admin.json +++ b/src/i18n/locales/tr/admin.json @@ -128,6 +128,7 @@ "activities": "Aktivite günlüğü", "support": "Destek", "notifications": "Bildirimler", + "broadcasts": "Duyurular", "profile": "Profil" }, "signedInAs": "Giriş yapılan hesap", diff --git a/src/lib/config/routes.ts b/src/lib/config/routes.ts index d2e7727..46ea552 100644 --- a/src/lib/config/routes.ts +++ b/src/lib/config/routes.ts @@ -26,6 +26,7 @@ export const ROUTES = { adminActivities: "/admin/activities", adminSupport: "/admin/support", adminNotifications: "/admin/notifications", + adminBroadcasts: "/admin/broadcasts", adminProfile: "/admin/profile", } as const; @@ -51,6 +52,7 @@ export const protectedRoutes = [ ROUTES.adminActivities, ROUTES.adminSupport, ROUTES.adminNotifications, + ROUTES.adminBroadcasts, ROUTES.adminProfile, ROUTES.accountSuspended, ]; @@ -98,6 +100,7 @@ export const adminRoutes = [ ROUTES.adminActivities, ROUTES.adminSupport, ROUTES.adminNotifications, + ROUTES.adminBroadcasts, ROUTES.adminProfile, ]; diff --git a/src/lib/types/permissions.ts b/src/lib/types/permissions.ts index 49c7a67..14e9f06 100644 --- a/src/lib/types/permissions.ts +++ b/src/lib/types/permissions.ts @@ -16,4 +16,6 @@ export enum Permission { SupportUpdate = "support:update", ActivitiesRead = "activities:read", StatsRead = "stats:read", + BroadcastRead = "broadcast:read", + BroadcastWrite = "broadcast:write", } From b75abeb29270e8fe88a12d93e4872c00394743e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 14:19:01 +0300 Subject: [PATCH 09/17] feat: add admin broadcasts page --- .../broadcasts/BroadcastsContent.tsx | 28 +++++ .../admin/(protected)/broadcasts/page.tsx | 23 ++++ .../admin/broadcasts/BroadcastHistory.tsx | 116 ++++++++++++++++++ src/i18n/locales/en/broadcasts.json | 6 +- src/i18n/locales/en/seo.json | 4 + src/i18n/locales/en/success.json | 3 + src/i18n/locales/tr/broadcasts.json | 6 +- src/i18n/locales/tr/seo.json | 4 + src/i18n/locales/tr/success.json | 3 + src/lib/seo/types.ts | 1 + 10 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 src/app/[locale]/admin/(protected)/broadcasts/BroadcastsContent.tsx create mode 100644 src/app/[locale]/admin/(protected)/broadcasts/page.tsx create mode 100644 src/components/admin/broadcasts/BroadcastHistory.tsx diff --git a/src/app/[locale]/admin/(protected)/broadcasts/BroadcastsContent.tsx b/src/app/[locale]/admin/(protected)/broadcasts/BroadcastsContent.tsx new file mode 100644 index 0000000..7a383c4 --- /dev/null +++ b/src/app/[locale]/admin/(protected)/broadcasts/BroadcastsContent.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useTranslation } from "react-i18next"; + +import { BroadcastComposer } from "@/components/admin/broadcasts/BroadcastComposer"; +import { BroadcastHistory } from "@/components/admin/broadcasts/BroadcastHistory"; +import { useCanReadBroadcasts, useCanWriteBroadcasts } from "@/hooks/use-permissions"; + +import { AdminForbidden } from "../AdminForbidden"; + +export function BroadcastsContent() { + const { t } = useTranslation("broadcasts"); + const canRead = useCanReadBroadcasts(); + const canWrite = useCanWriteBroadcasts(); + + if (!canRead) return ; + + return ( +
+
+

{t("admin.title")}

+

{t("admin.subtitle")}

+
+ {canWrite ? : null} + +
+ ); +} diff --git a/src/app/[locale]/admin/(protected)/broadcasts/page.tsx b/src/app/[locale]/admin/(protected)/broadcasts/page.tsx new file mode 100644 index 0000000..75999a0 --- /dev/null +++ b/src/app/[locale]/admin/(protected)/broadcasts/page.tsx @@ -0,0 +1,23 @@ +import { ROUTES } from "@/lib/config/routes"; +import { buildMetadata, validateLocale } from "@/lib/seo/metadata"; + +import { BroadcastsContent } from "./BroadcastsContent"; + +import type { Metadata } from "next"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + return buildMetadata({ + locale: validateLocale(locale), + pageKey: "adminBroadcasts", + pathname: ROUTES.adminBroadcasts, + }); +} + +export default function AdminBroadcastsPage() { + return ; +} diff --git a/src/components/admin/broadcasts/BroadcastHistory.tsx b/src/components/admin/broadcasts/BroadcastHistory.tsx new file mode 100644 index 0000000..1a4e6e4 --- /dev/null +++ b/src/components/admin/broadcasts/BroadcastHistory.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useState } from "react"; + +import { useTranslation } from "react-i18next"; + +import { AdminPagination } from "@/components/admin/Pagination"; +import { DEFAULT_PAGE_SIZE } from "@/components/admin/pagination-config"; +import { StatusBadge } from "@/components/admin/StatusBadge"; +import { Card, CardContent } from "@/components/ui/card"; +import { useBroadcasts } from "@/hooks/api/use-broadcasts"; +import { formatDateTime } from "@/lib/format-date"; +import type { + AnnouncementLanguage, + AnnouncementLevel, + AnnouncementRead, +} from "@/lib/types/announcement"; + +import type { TFunction } from "i18next"; + +const LEVEL_TONE: Record = { + info: "info", + warning: "warning", + critical: "danger", +}; + +// Title from FE i18n for templates (see AnnouncementPreview sync note), from +// stored translations for custom announcements. +function announcementTitle( + t: TFunction, + lang: AnnouncementLanguage, + item: AnnouncementRead, +): string { + if (item.kind === "template" && item.template_key) { + return t(`templates.${item.template_key}.title`); + } + const tr = item.translations; + return tr?.[lang]?.title ?? tr?.en?.title ?? tr?.tr?.title ?? "—"; +} + +function creatorName(item: AnnouncementRead): string | null { + if (!item.creator) return null; + const full = `${item.creator.first_name ?? ""} ${item.creator.last_name ?? ""}`.trim(); + return full || item.creator.email; +} + +export function BroadcastHistory() { + const { t, i18n } = useTranslation("broadcasts"); + const lang = (i18n.language.split("-")[0] ?? "en") as AnnouncementLanguage; + const [skip, setSkip] = useState(0); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const { data, isLoading, isFetching } = useBroadcasts({ skip, limit: pageSize }); + + const rows = data?.data ?? []; + + return ( + + +
+

{t("admin.history.title")}

+
+ + {!isLoading && rows.length === 0 ? ( +

{t("admin.history.empty")}

+ ) : ( +
    + {rows.map((item) => { + const sender = creatorName(item); + return ( +
  • + + {t(`admin.form.level.${item.level}`)} + + {announcementTitle(t, lang, item)} + + {t(`admin.form.audience.${item.audience}`)} + + {sender ? ( + + {t("admin.history.sentBy", { name: sender })} + + ) : null} + {item.send_email ? ( + {t("admin.history.emailed")} + ) : null} + + {formatDateTime(item.created_at)} + +
  • + ); + })} +
+ )} + + {data ? ( +
+ { + setPageSize(next); + setSkip(0); + }} + /> +
+ ) : null} +
+
+ ); +} diff --git a/src/i18n/locales/en/broadcasts.json b/src/i18n/locales/en/broadcasts.json index ebfebdd..27a5eef 100644 --- a/src/i18n/locales/en/broadcasts.json +++ b/src/i18n/locales/en/broadcasts.json @@ -49,13 +49,9 @@ "history": { "title": "Sent announcements", "empty": "No announcements sent yet.", - "recipients_one": "{{count}} recipient", - "recipients_other": "{{count}} recipients", "sentBy": "by {{name}}", "emailed": "Emailed" - }, - "result_one": "Announcement sent to {{count}} user.", - "result_other": "Announcement sent to {{count}} users." + } }, "banner": { "details": "Details", diff --git a/src/i18n/locales/en/seo.json b/src/i18n/locales/en/seo.json index ef829fe..769efca 100644 --- a/src/i18n/locales/en/seo.json +++ b/src/i18n/locales/en/seo.json @@ -87,6 +87,10 @@ "title": "Notifications", "description": "View all your notifications and manage their read status." }, + "adminBroadcasts": { + "title": "Announcements", + "description": "Compose and send announcements to your users." + }, "adminTicketDetail": { "title": "Ticket", "description": "View a support ticket, reply, and update its status." diff --git a/src/i18n/locales/en/success.json b/src/i18n/locales/en/success.json index 54ac032..f29deac 100644 --- a/src/i18n/locales/en/success.json +++ b/src/i18n/locales/en/success.json @@ -48,6 +48,9 @@ "message_sent": "Message sent.", "admin_ticket_updated": "Ticket updated.", "admin_ticket_replied": "Reply sent." + }, + "announcement": { + "sent": "Announcement sent." } } } diff --git a/src/i18n/locales/tr/broadcasts.json b/src/i18n/locales/tr/broadcasts.json index 33cb671..e176ed4 100644 --- a/src/i18n/locales/tr/broadcasts.json +++ b/src/i18n/locales/tr/broadcasts.json @@ -49,13 +49,9 @@ "history": { "title": "Gönderilen duyurular", "empty": "Henüz duyuru gönderilmedi.", - "recipients_one": "{{count}} alıcı", - "recipients_other": "{{count}} alıcı", "sentBy": "{{name}} tarafından", "emailed": "E-posta gönderildi" - }, - "result_one": "Duyuru {{count}} kullanıcıya gönderildi.", - "result_other": "Duyuru {{count}} kullanıcıya gönderildi." + } }, "banner": { "details": "Detay", diff --git a/src/i18n/locales/tr/seo.json b/src/i18n/locales/tr/seo.json index 4601e55..ca06866 100644 --- a/src/i18n/locales/tr/seo.json +++ b/src/i18n/locales/tr/seo.json @@ -87,6 +87,10 @@ "title": "Bildirimler", "description": "Tüm bildirimlerinizi görüntüleyin ve okunma durumlarını yönetin." }, + "adminBroadcasts": { + "title": "Duyurular", + "description": "Kullanıcılarınıza duyuru oluşturun ve gönderin." + }, "adminTicketDetail": { "title": "Talep", "description": "Bir destek talebini görüntüleyin, yanıtlayın ve durumunu güncelleyin." diff --git a/src/i18n/locales/tr/success.json b/src/i18n/locales/tr/success.json index 8b5b801..fe03059 100644 --- a/src/i18n/locales/tr/success.json +++ b/src/i18n/locales/tr/success.json @@ -48,6 +48,9 @@ "message_sent": "Mesaj gönderildi.", "admin_ticket_updated": "Talep güncellendi.", "admin_ticket_replied": "Yanıt gönderildi." + }, + "announcement": { + "sent": "Duyuru gönderildi." } } } diff --git a/src/lib/seo/types.ts b/src/lib/seo/types.ts index c3f8c00..ac97598 100644 --- a/src/lib/seo/types.ts +++ b/src/lib/seo/types.ts @@ -23,6 +23,7 @@ export type SeoPageKey = | "adminActivities" | "adminSupport" | "adminNotifications" + | "adminBroadcasts" | "adminTicketDetail" | "notFound" | "error"; From a2a7fcd020fcc6c1cd28371a52b1435833dcf9c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 14:34:13 +0300 Subject: [PATCH 10/17] feat: render admin_announcement notifications --- .../admin/broadcasts/AnnouncementPreview.tsx | 64 +++++-------------- .../admin/broadcasts/BroadcastHistory.tsx | 17 +---- src/components/common/NotificationRow.tsx | 4 +- src/components/common/notification-text.ts | 15 ++++- src/components/notifications/InboxRow.tsx | 4 +- src/lib/announcement-render.ts | 59 +++++++++++++++++ src/lib/types/notification.ts | 3 +- 7 files changed, 99 insertions(+), 67 deletions(-) create mode 100644 src/lib/announcement-render.ts diff --git a/src/components/admin/broadcasts/AnnouncementPreview.tsx b/src/components/admin/broadcasts/AnnouncementPreview.tsx index 30f710b..f1ae8e1 100644 --- a/src/components/admin/broadcasts/AnnouncementPreview.tsx +++ b/src/components/admin/broadcasts/AnnouncementPreview.tsx @@ -2,7 +2,11 @@ import { useTranslation } from "react-i18next"; -import { formatDateTime } from "@/lib/format-date"; +import { + announcementBody, + announcementTitle, + type AnnouncementFields, +} from "@/lib/announcement-render"; import type { AnnouncementKind, AnnouncementLanguage, @@ -10,8 +14,6 @@ import type { BroadcastTemplate, } from "@/lib/types/announcement"; -import type { TFunction } from "i18next"; - interface Props { kind: AnnouncementKind; level: AnnouncementLevel; @@ -26,52 +28,20 @@ const LEVEL_ACCENT: Record = { critical: "border-l-destructive", }; -// SYNC NOTE: in-app template text comes from FE i18n (broadcasts.json -// `templates.`); the EMAIL text lives in the backend catalog -// (fastapi-template/app/core/broadcast_templates.py). When a template changes, -// update BOTH and keep keys + variable names identical. -// See memory: broadcast-template-text-dual-source. -function renderTemplate( - t: TFunction, - lang: AnnouncementLanguage, - template: BroadcastTemplate | undefined, - variables: Props["variables"], -): { title: string; body: string } | null { - if (!template) return null; - const values: Record = {}; - for (const variable of template.variables) { - const raw = variables?.[variable.name]; - if (variable.type === "datetime" && typeof raw === "string" && raw) { - values[variable.name] = formatDateTime(raw); // viewer-local (in-app) - } else if (variable.type === "text" && raw && typeof raw === "object") { - values[variable.name] = raw[lang] ?? raw.en ?? raw.tr ?? ""; - } - } - return { - title: t(`templates.${template.key}.title`), - body: t(`templates.${template.key}.body`, values), - }; -} - -function renderCustom( - lang: AnnouncementLanguage, - translations: Props["translations"], -): { title: string; body: string } | null { - const entry = translations?.[lang] ?? translations?.en ?? translations?.tr; - if (!entry?.title && !entry?.body) return null; - return { title: entry.title ?? "", body: entry.body ?? "" }; -} - export function AnnouncementPreview({ kind, level, template, variables, translations }: Props) { const { t, i18n } = useTranslation("broadcasts"); - const lang = (i18n.language?.split("-")[0] ?? "en") as AnnouncementLanguage; + const lang = (i18n.language.split("-")[0] ?? "en") as AnnouncementLanguage; - const content = - kind === "template" - ? renderTemplate(t, lang, template, variables) - : renderCustom(lang, translations); + const fields: AnnouncementFields = { + kind, + template_key: template?.key ?? null, + variables, + translations, + }; + const title = announcementTitle(t, lang, fields); + const body = announcementBody(t, lang, fields); - if (!content) { + if (!title && !body) { return (
{t("admin.previewEmpty")} @@ -86,8 +56,8 @@ export function AnnouncementPreview({ kind, level, template, variables, translat

{t("admin.previewLabel")}

-

{content.title}

-

{content.body}

+

{title}

+

{body}

); } diff --git a/src/components/admin/broadcasts/BroadcastHistory.tsx b/src/components/admin/broadcasts/BroadcastHistory.tsx index 1a4e6e4..2cbf813 100644 --- a/src/components/admin/broadcasts/BroadcastHistory.tsx +++ b/src/components/admin/broadcasts/BroadcastHistory.tsx @@ -9,6 +9,7 @@ import { DEFAULT_PAGE_SIZE } from "@/components/admin/pagination-config"; import { StatusBadge } from "@/components/admin/StatusBadge"; import { Card, CardContent } from "@/components/ui/card"; import { useBroadcasts } from "@/hooks/api/use-broadcasts"; +import { announcementTitle } from "@/lib/announcement-render"; import { formatDateTime } from "@/lib/format-date"; import type { AnnouncementLanguage, @@ -16,28 +17,12 @@ import type { AnnouncementRead, } from "@/lib/types/announcement"; -import type { TFunction } from "i18next"; - const LEVEL_TONE: Record = { info: "info", warning: "warning", critical: "danger", }; -// Title from FE i18n for templates (see AnnouncementPreview sync note), from -// stored translations for custom announcements. -function announcementTitle( - t: TFunction, - lang: AnnouncementLanguage, - item: AnnouncementRead, -): string { - if (item.kind === "template" && item.template_key) { - return t(`templates.${item.template_key}.title`); - } - const tr = item.translations; - return tr?.[lang]?.title ?? tr?.en?.title ?? tr?.tr?.title ?? "—"; -} - function creatorName(item: AnnouncementRead): string | null { if (!item.creator) return null; const full = `${item.creator.first_name ?? ""} ${item.creator.last_name ?? ""}`.trim(); diff --git a/src/components/common/NotificationRow.tsx b/src/components/common/NotificationRow.tsx index 96a2149..e772898 100644 --- a/src/components/common/NotificationRow.tsx +++ b/src/components/common/NotificationRow.tsx @@ -34,7 +34,9 @@ export const NotificationRow = ({ item, onClick }: NotificationRowProps) => { )} /> - + {notificationText(t, item)} diff --git a/src/components/common/notification-text.ts b/src/components/common/notification-text.ts index e5b3451..c85d425 100644 --- a/src/components/common/notification-text.ts +++ b/src/components/common/notification-text.ts @@ -1,4 +1,7 @@ +import i18n from "@/i18n/config"; +import { announcementBody, type AnnouncementFields } from "@/lib/announcement-render"; import { ROUTES } from "@/lib/config/routes"; +import type { AnnouncementLanguage } from "@/lib/types/announcement"; import type { NotificationItem } from "@/lib/types/notification"; import type { TFunction } from "i18next"; @@ -30,6 +33,15 @@ export const notificationText = (t: TFunction, item: NotificationItem): string = `notifications:actions.${readNotificationString(item, "action")}`, t("notifications:types.admin_permissions_changed"), ); + case "admin_announcement": { + const lang = (i18n.language.split("-")[0] ?? "en") as AnnouncementLanguage; + return announcementBody(t, lang, { + kind: readNotificationString(item, "kind") || "custom", + template_key: readNotificationString(item, "template_key") || null, + variables: item.data.variables as AnnouncementFields["variables"], + translations: item.data.translations as AnnouncementFields["translations"], + }); + } } }; @@ -38,6 +50,7 @@ export const notificationText = (t: TFunction, item: NotificationItem): string = // non-admin route), so their ticket links must use the admin detail page. export const notificationTargetPath = (item: NotificationItem, isAdmin: boolean): string | null => { const ticketId = readNotificationString(item, "ticket_id"); - if (!ticketId || item.type === "admin_permissions_changed") return null; + if (!ticketId || item.type === "admin_permissions_changed" || item.type === "admin_announcement") + return null; return `${isAdmin ? ROUTES.adminSupport : ROUTES.support}/${ticketId}`; }; diff --git a/src/components/notifications/InboxRow.tsx b/src/components/notifications/InboxRow.tsx index 0f90770..9e18c50 100644 --- a/src/components/notifications/InboxRow.tsx +++ b/src/components/notifications/InboxRow.tsx @@ -29,7 +29,9 @@ export const InboxRow = ({ item, onClick }: InboxRowProps) => { )} /> - + {notificationText(t, item)} diff --git a/src/lib/announcement-render.ts b/src/lib/announcement-render.ts new file mode 100644 index 0000000..0680012 --- /dev/null +++ b/src/lib/announcement-render.ts @@ -0,0 +1,59 @@ +import { formatDateTime } from "@/lib/format-date"; +import type { AnnouncementLanguage, AnnouncementVariableValue } from "@/lib/types/announcement"; + +import type { TFunction } from "i18next"; + +// SYNC NOTE: in-app template text comes from FE i18n (broadcasts.json +// `templates.`); the EMAIL text lives in the backend catalog +// (fastapi-template/app/core/broadcast_templates.py). When a template changes, +// update BOTH and keep keys + variable names identical. +// See memory: broadcast-template-text-dual-source. + +export interface AnnouncementFields { + kind: string; + template_key?: string | null; + variables?: Record; + translations?: Record; +} + +// Resolve a template's variables for the active language: a datetime value (ISO +// string) is formatted viewer-local; a text value ({tr,en}) picks the language. +const interpolation = ( + lang: AnnouncementLanguage, + variables: AnnouncementFields["variables"], +): Record => { + const values: Record = {}; + for (const [name, raw] of Object.entries(variables ?? {})) { + if (typeof raw === "string") values[name] = formatDateTime(raw); + else if (raw) values[name] = raw[lang] ?? raw.en ?? raw.tr ?? ""; + } + return values; +}; + +const render = ( + field: "title" | "body", + t: TFunction, + lang: AnnouncementLanguage, + fields: AnnouncementFields, +): string => { + if (fields.kind === "template" && fields.template_key) { + return t( + `broadcasts:templates.${fields.template_key}.${field}`, + interpolation(lang, fields.variables), + ); + } + const tr = fields.translations; + return tr?.[lang]?.[field] ?? tr?.en?.[field] ?? tr?.tr?.[field] ?? ""; +}; + +export const announcementTitle = ( + t: TFunction, + lang: AnnouncementLanguage, + fields: AnnouncementFields, +): string => render("title", t, lang, fields); + +export const announcementBody = ( + t: TFunction, + lang: AnnouncementLanguage, + fields: AnnouncementFields, +): string => render("body", t, lang, fields); diff --git a/src/lib/types/notification.ts b/src/lib/types/notification.ts index 8e072fd..af80264 100644 --- a/src/lib/types/notification.ts +++ b/src/lib/types/notification.ts @@ -6,7 +6,8 @@ import type { JsonValue } from "@/lib/types/admin"; export type NotificationType = | "support_ticket_replied" | "support_ticket_status_changed" - | "admin_permissions_changed"; + | "admin_permissions_changed" + | "admin_announcement"; export type NotificationEventType = "notification_created"; From 77e87fabe0872f1b24dcf2cdb56e6e3e021cd774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 15:00:50 +0300 Subject: [PATCH 11/17] feat: add active announcement banner --- src/app/[locale]/layout.tsx | 2 + src/components/common/AnnouncementBanner.tsx | 133 +++++++++++++++++++ src/hooks/api/use-broadcasts.ts | 3 +- src/hooks/use-notification-realtime.ts | 7 +- src/lib/announcement-dismissed.ts | 27 ++++ src/lib/motion/variants.ts | 5 + src/schemas/notification.ts | 1 + 7 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 src/components/common/AnnouncementBanner.tsx create mode 100644 src/lib/announcement-dismissed.ts diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 149b114..3155147 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -1,6 +1,7 @@ import { Albert_Sans, Fraunces, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; +import { AnnouncementBanner } from "@/components/common/AnnouncementBanner"; import { AppHeader } from "@/components/common/AppHeader"; import { buildMetadata, validateLocale } from "@/lib/seo/metadata"; import { buildThemeStyleSheet } from "@/lib/theme/colors"; @@ -73,6 +74,7 @@ export default async function RootLayout({ >
+
{children}
diff --git a/src/components/common/AnnouncementBanner.tsx b/src/components/common/AnnouncementBanner.tsx new file mode 100644 index 0000000..6d2d70a --- /dev/null +++ b/src/components/common/AnnouncementBanner.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState } from "react"; + +import { Drawer, Modal } from "antd"; +import { X } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import { usePathname } from "next/navigation"; +import { useTranslation } from "react-i18next"; + +import { useActiveAnnouncement } from "@/hooks/api/use-broadcasts"; +import { useIsMobile } from "@/hooks/useMediaQuery"; +import { dismissAnnouncement, isAnnouncementDismissed } from "@/lib/announcement-dismissed"; +import { announcementBody, announcementTitle } from "@/lib/announcement-render"; +import { getPathWithoutLocale, isAdminPath, matchesRoute, ROUTES } from "@/lib/config/routes"; +import { fadeDown } from "@/lib/motion/variants"; +import type { AnnouncementLevel } from "@/lib/types/announcement"; +import { cn } from "@/lib/utils"; +import { useAuthStore } from "@/stores/auth.store"; + +const LEVEL_BG: Record = { + info: "bg-primary/10", + warning: "bg-amber-500/15", + critical: "bg-destructive/15", +}; + +interface ViewProps { + level: AnnouncementLevel; + title: string; + body: string; + onDismiss: () => void; +} + +function BannerStrip({ level, title, body, onDismiss }: ViewProps) { + const { t } = useTranslation("broadcasts"); + const reduce = useReducedMotion(); + const [detailOpen, setDetailOpen] = useState(false); + return ( + <> + + + {title} + {body ? — {body} : null} + + {body ? ( + + ) : null} + + + { + setDetailOpen(false); + }} + > +

{body}

+
+ + ); +} + +function BannerSheet({ level, title, body, onDismiss }: ViewProps) { + const { t } = useTranslation("broadcasts"); + return ( + +
+

{title}

+

{body}

+
+ +
+ ); +} + +export function AnnouncementBanner() { + const { t, i18n } = useTranslation("broadcasts"); + const pathname = usePathname(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const isMobile = useIsMobile(); + const { data } = useActiveAnnouncement(isAuthenticated); + + // The id the user closed this session; combined with the persisted set below + // so a fresh announcement (new id) reappears while a closed one stays hidden. + const [closedId, setClosedId] = useState(null); + + const announcement = data?.announcement ?? null; + // The admin shell + suspended page own their chrome, so the banner stays off + // those surfaces (mirrors AppHeader). + const path = getPathWithoutLocale(pathname); + const chromeFree = isAdminPath(path) || matchesRoute(path, ROUTES.accountSuspended); + + if (!announcement || chromeFree) return null; + if (closedId === announcement.id || isAnnouncementDismissed(announcement.id)) return null; + + const lang = (i18n.language.split("-")[0] ?? "en") as "en" | "tr"; + const title = announcementTitle(t, lang, announcement); + const body = announcementBody(t, lang, announcement); + const onDismiss = () => { + dismissAnnouncement(announcement.id); + setClosedId(announcement.id); + }; + + const view: ViewProps = { level: announcement.level, title, body, onDismiss }; + return isMobile ? : ; +} diff --git a/src/hooks/api/use-broadcasts.ts b/src/hooks/api/use-broadcasts.ts index c517b34..bf9a52e 100644 --- a/src/hooks/api/use-broadcasts.ts +++ b/src/hooks/api/use-broadcasts.ts @@ -24,10 +24,11 @@ export const useBroadcastTemplates = () => staleTime: 1000 * 60 * 60, // catalog is static — cache for an hour }); -export const useActiveAnnouncement = () => +export const useActiveAnnouncement = (enabled = true) => useQuery({ queryKey: broadcastKeys.activeAnnouncement, queryFn: () => broadcastsApi.active(), + enabled, }); // Success toast is driven by the backend `message` via the axios interceptor. diff --git a/src/hooks/use-notification-realtime.ts b/src/hooks/use-notification-realtime.ts index e42a02c..a90c4e8 100644 --- a/src/hooks/use-notification-realtime.ts +++ b/src/hooks/use-notification-realtime.ts @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { broadcastKeys } from "@/hooks/api/use-broadcasts"; import { notificationKeys } from "@/hooks/api/use-notifications"; import { createNotificationSocket } from "@/lib/websocket/notification-socket"; import { useAuthStore } from "@/stores/auth.store"; @@ -22,8 +23,12 @@ export const useNotificationRealtime = (): void => { useEffect(() => { if (!isAuthenticated) return; - const socket = createNotificationSocket(() => { + const socket = createNotificationSocket((event) => { void queryClient.invalidateQueries({ queryKey: notificationKeys.all }); + // A broadcast also refreshes the active banner so it appears live. + if (event.notification.type === "admin_announcement") { + void queryClient.invalidateQueries({ queryKey: broadcastKeys.activeAnnouncement }); + } }); return () => { diff --git a/src/lib/announcement-dismissed.ts b/src/lib/announcement-dismissed.ts new file mode 100644 index 0000000..3a36cf8 --- /dev/null +++ b/src/lib/announcement-dismissed.ts @@ -0,0 +1,27 @@ +// Per-announcement dismissal, persisted in localStorage so a closed banner stays +// closed across reloads. Stores the set of dismissed announcement ids. + +const KEY = "dismissedAnnouncements"; + +const read = (): string[] => { + if (typeof window === "undefined") return []; + try { + const parsed: unknown = JSON.parse(window.localStorage.getItem(KEY) ?? "[]"); + return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === "string") : []; + } catch { + return []; + } +}; + +export const isAnnouncementDismissed = (id: string): boolean => read().includes(id); + +export const dismissAnnouncement = (id: string): void => { + if (typeof window === "undefined") return; + const ids = read(); + if (ids.includes(id)) return; + try { + window.localStorage.setItem(KEY, JSON.stringify([...ids, id])); + } catch { + // private mode / quota — dismissal just won't persist + } +}; diff --git a/src/lib/motion/variants.ts b/src/lib/motion/variants.ts index 8e666db..8668ff7 100644 --- a/src/lib/motion/variants.ts +++ b/src/lib/motion/variants.ts @@ -16,6 +16,11 @@ export const fadeUp: Variants = { visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: EASE_OUT } }, }; +export const fadeDown: Variants = { + hidden: { opacity: 0, y: -24 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: EASE_OUT } }, +}; + export const fadeIn: Variants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: 0.5, ease: EASE_OUT } }, diff --git a/src/schemas/notification.ts b/src/schemas/notification.ts index 1c7c836..1253108 100644 --- a/src/schemas/notification.ts +++ b/src/schemas/notification.ts @@ -24,6 +24,7 @@ const notificationItemSchema = z.object({ "support_ticket_replied", "support_ticket_status_changed", "admin_permissions_changed", + "admin_announcement", ]), data: z.record(z.string(), jsonValueSchema), read_at: z.string().nullable(), From f73efb93a3c33541ab66e5ae25f08d1e3d90733d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 15:41:41 +0300 Subject: [PATCH 12/17] docs: document broadcast announcements in README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 65b4da0..af0817e 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ The API proxy is pre-configured in `next.config.ts` to forward requests from you - **RBAC / Permission-aware Admin Panel:** A sidebar-shell admin area (`/admin`) with `user` / `admin` / `superadmin` roles, isolated from the user-facing app. A central `usePermissions()` hook (plus per-permission helpers like `useCanDeleteUsers`) drives **nav, route, and action gating** from the `permissions` on `/users/me`; superadmins pass everything by role and get a searchable **permission-matrix** to **create/delete admin accounts** and assign grants — while the **root superadmin** additionally promotes/demotes superadmins and hands over root via **email OTP**. Permission changes apply **instantly** over a WebSocket — no re-login. See [Admin Panel & RBAC](#-admin-panel--rbac). - **Support / Ticketing UI + Realtime:** User and admin ticketing screens (open, reply with attachments, status/priority, assignment) with live thread & queue updates over a self-healing WebSocket. See [Support UI](#-support-ticketing-ui). - **Notification Center:** A header bell (unread badge + dropdown panel) on both the user header and the admin topbar, plus a full paginated inbox (`/notifications`, `/admin/notifications`) with an **All / Unread** filter and mark-(all-)read. Updates **live** over a self-healing WebSocket — a pushed event invalidates the React Query caches so the badge and lists refresh without a reload. Notification copy is rendered from a `type` + `data` payload via i18n (en/tr), and the inbox is reachable on mobile from the drawer / admin sidebar. See [Notification Center](#-notification-center). +- **Announcements / Broadcast:** An admin compose page (`/admin/broadcasts`) to send announcements — **template** or **custom** mode with per-language (tr/en) title/body, live character counters, a **live preview**, audience / importance selectors, and optional banner / email toggles — plus a sent-history list. Recipients see it in the notification center and, when flagged, as a **top banner** (desktop strip + detail modal, mobile bottom-sheet, springy enter/exit) rendered in their active locale, refreshed **live** over the notification WebSocket and dismissible (persisted in `localStorage`); animations respect `prefers-reduced-motion`. Gated by `broadcast:read` / `broadcast:write`. - **Active Sessions / Device Management:** A **Security** tab on the profile lists the devices signed in to the account (browser/OS + last active, current device flagged) and revokes any one — or **all other devices** in one click. Admins get the same list plus a **terminate-all** action on the user detail page, gated by the `users:sessions` permission. Revocation is **live**: an `account` WebSocket `sessions_revoked` event re-validates the device, dropping a kicked tab straight to login without a reload. See [Active Sessions](#-active-sessions). --- From aca9ba53346d3d428c90a90c569486d5935a9583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 15:52:16 +0300 Subject: [PATCH 13/17] feat: animate the active banner --- .../admin/broadcasts/AnnouncementPreview.tsx | 2 +- src/components/common/AnnouncementBanner.tsx | 103 ++++++++++++------ src/lib/motion/variants.ts | 5 - 3 files changed, 70 insertions(+), 40 deletions(-) diff --git a/src/components/admin/broadcasts/AnnouncementPreview.tsx b/src/components/admin/broadcasts/AnnouncementPreview.tsx index f1ae8e1..43d9e36 100644 --- a/src/components/admin/broadcasts/AnnouncementPreview.tsx +++ b/src/components/admin/broadcasts/AnnouncementPreview.tsx @@ -24,7 +24,7 @@ interface Props { const LEVEL_ACCENT: Record = { info: "border-l-primary", - warning: "border-l-amber-500", + warning: "border-l-yellow-500", critical: "border-l-destructive", }; diff --git a/src/components/common/AnnouncementBanner.tsx b/src/components/common/AnnouncementBanner.tsx index 6d2d70a..ce7fe77 100644 --- a/src/components/common/AnnouncementBanner.tsx +++ b/src/components/common/AnnouncementBanner.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { Drawer, Modal } from "antd"; import { X } from "lucide-react"; -import { motion, useReducedMotion } from "motion/react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { usePathname } from "next/navigation"; import { useTranslation } from "react-i18next"; @@ -13,39 +13,51 @@ import { useIsMobile } from "@/hooks/useMediaQuery"; import { dismissAnnouncement, isAnnouncementDismissed } from "@/lib/announcement-dismissed"; import { announcementBody, announcementTitle } from "@/lib/announcement-render"; import { getPathWithoutLocale, isAdminPath, matchesRoute, ROUTES } from "@/lib/config/routes"; -import { fadeDown } from "@/lib/motion/variants"; import type { AnnouncementLevel } from "@/lib/types/announcement"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/stores/auth.store"; -const LEVEL_BG: Record = { - info: "bg-primary/10", - warning: "bg-amber-500/15", - critical: "bg-destructive/15", +import type { Transition } from "motion/react"; + +// Solid, high-contrast fills so every level reads clearly in light AND dark mode +// (a faint tint disappears on a light background). Text colour rides along so +// the inner spans can simply inherit it. +const LEVEL_STYLE: Record = { + info: "bg-primary text-primary-foreground", + warning: "bg-yellow-400 text-yellow-950", + critical: "bg-destructive text-destructive-foreground", }; -interface ViewProps { +// Springy entrance/exit for the desktop strip — a gentle, unhurried overshoot +// in, sliding back up on dismiss. Lower stiffness = slower; mass adds weight. +const STRIP_SPRING: Transition = { type: "spring", stiffness: 170, damping: 20, mass: 1.1 }; + +interface StripProps { level: AnnouncementLevel; title: string; body: string; onDismiss: () => void; } -function BannerStrip({ level, title, body, onDismiss }: ViewProps) { +function BannerStrip({ level, title, body, onDismiss }: StripProps) { const { t } = useTranslation("broadcasts"); const reduce = useReducedMotion(); const [detailOpen, setDetailOpen] = useState(false); return ( <> - {title} - {body ? — {body} : null} + {title} + {body ? — {body} : null} {body ? ( @@ -62,7 +74,7 @@ function BannerStrip({ level, title, body, onDismiss }: ViewProps) { type="button" onClick={onDismiss} aria-label={t("banner.dismiss")} - className="shrink-0 rounded-md p-1 transition-colors hover:bg-foreground/10" + className="shrink-0 rounded-md p-1 transition-colors hover:bg-black/10" > @@ -71,6 +83,7 @@ function BannerStrip({ level, title, body, onDismiss }: ViewProps) { open={detailOpen} title={title} footer={null} + centered onCancel={() => { setDetailOpen(false); }} @@ -81,13 +94,26 @@ function BannerStrip({ level, title, body, onDismiss }: ViewProps) { ); } -function BannerSheet({ level, title, body, onDismiss }: ViewProps) { +interface SheetProps extends StripProps { + open: boolean; + onClosed: () => void; +} + +function BannerSheet({ open, level, title, body, onDismiss, onClosed }: SheetProps) { const { t } = useTranslation("broadcasts"); return ( - -
-

{title}

-

{body}

+ { + if (!o) onClosed(); + }} + > +
+

{title}

+

{body}

-
- ); -} -``` - -### Component Rules: - -- Use TypeScript interfaces for props (not types) -- Export as named export -- If using translations, pass `t` from parent via props -- Use Tailwind classes only (no inline styles) -- Keep max complexity manageable (aim for 50-100 lines) - ---- - -## Creating a New API Call - -### Steps: - -1. Create/update schema in `src/schemas/` -2. Create/update service function in `src/lib/api/endpoints/{feature}.ts` -3. Use React Query hooks in components (`useQuery`, `useMutation`) -4. Type all payloads and responses with Zod - -### Example API Flow: - -**1. Schema** (`src/schemas/user.ts`): - -```typescript -import { z } from "zod"; - -export const updateProfileSchema = z.object({ - name: z.string().min(1, "Name required"), - email: z.string().email(), -}); - -export type UpdateProfilePayload = z.infer; -``` - -**2. API Service** (`src/lib/api/endpoints/users.ts`): - -```typescript -import { updateProfileSchema } from "@/schemas/user"; -import type { UpdateProfilePayload } from "@/schemas/user"; -import api from "../api"; - -export const userService = { - updateProfile: async (payload: UpdateProfilePayload) => { - return api.patch("/users/me", payload); - }, -}; -``` - -**3. Component Usage**: - -```typescript -import { useMutation } from "@tanstack/react-query"; -import { userService } from "@/lib/api/endpoints/users"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { updateProfileSchema } from "@/schemas/user"; - -const { mutate, isPending } = useMutation({ - mutationFn: userService.updateProfile, -}); - -const form = useForm({ - resolver: zodResolver(updateProfileSchema), -}); - -onSubmit={(data) => mutate(data)}; -``` - ---- - -## Creating a New Store (Zustand) - -### Steps: - -1. Create `src/stores/{feature}.store.ts` -2. Define TypeScript interface for state -3. Use `persist` middleware for localStorage -4. Export as named store -5. Use in components with `const { state } = useStore()` - -### Store Template: - -```typescript -import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import type { User } from "@/lib/types/user"; - -interface ProfileState { - preferences: { - theme: "light" | "dark"; - language: string; - }; - updatePreferences: (prefs: Partial) => void; -} - -export const useProfileStore = create()( - persist( - (set) => ({ - preferences: { - theme: "light", - language: "en", - }, - updatePreferences: (prefs) => - set((state) => ({ - preferences: { ...state.preferences, ...prefs }, - })), - }), - { - name: "profile-storage", - }, - ), -); -``` - -### Store Rules: - -- Never hold API response data that React Query manages -- Use for UI state (theme, sidebar open/closed), auth state only -- Prefer React Query for server state - ---- - -## Writing E2E Tests - -### Steps: - -1. Create `tests-e2e/{feature}/{action}.spec.ts` -2. Use Base Test from `base-test.ts` for mocked API -3. Follow Page Object pattern for reusability -4. Mock API responses explicitly in each test - -### Test Template: - -```typescript -import { test, expect } from "@/tests-e2e/base-test"; -import { LoginPage } from "@/tests-e2e/pages/LoginPage"; - -test("user can log in", async ({ page }) => { - const loginPage = new LoginPage(page); - - // Mock API response - await page.route("**/api/v1/auth/login", async (route) => { - await route.fulfill({ - status: 200, - body: JSON.stringify({ access_token: "mock_token" }), - }); - }); - - // Test - await loginPage.goto(); - await loginPage.fillEmail("user@example.com"); - await loginPage.fillPassword("password"); - await loginPage.clickLogin(); - - await expect(page).toHaveURL("/en/dashboard"); -}); -``` - -### Page Object Pattern: - -```typescript -import { type Page } from "@playwright/test"; - -export class LoginPage { - constructor(private page: Page) {} - - async goto() { - await this.page.goto("/en/login"); - } - - async fillEmail(email: string) { - await this.page.fill('input[name="email"]', email); - } - - async clickLogin() { - await this.page.click("button:has-text('Log In')"); - } -} -``` - -### Test Rules: - -- Always mock API calls (never hit real backend) -- Use Page Objects for reusable test helpers -- Test happy path + error cases -- Run with `pnpm test:e2e` - ---- - -## Modifying Existing Files - -### Before Changes: - -1. Read the entire file to understand structure -2. Check for related tests -3. Check if changes affect other files (search usage) - -### After Changes: - -1. Run `pnpm lint` to check for errors -2. Run `pnpm format` to auto-fix formatting -3. Add/update types and schemas if needed -4. Update tests if behavior changed - ---- - -## Adding Translations - -### Process: - -1. Identify namespace (auth, errors, success, profile, common, etc.) -2. Add key to BOTH `en/{namespace}.json` and `tr/{namespace}.json` -3. Use in component: `const { t } = useTranslation('namespace'); t('key')` -4. Never hardcode strings in JSX - -### Example: - -```json -// src/i18n/locales/en/errors.json -{ "email_required": "Email is required", "password_short": "Password must be at least 8 characters" } - -// src/i18n/locales/tr/errors.json -{ "email_required": "E-posta gerekli", "password_short": "Şifre en az 8 karakter olmalı" } -``` - -Usage in component: - -```typescript -const { t } = useTranslation("errors"); -{t("email_required")} -``` - ---- - -## Forbidden Actions - -- ❌ Do NOT create `middleware.ts` — use `src/proxy.ts` instead -- ❌ Do NOT use default exports -- ❌ Do NOT call `fetch()` or `axios` directly — use API abstraction -- ❌ Do NOT hardcode URLs — use env vars -- ❌ Do NOT create CSS files — use Tailwind only -- ❌ Do NOT add webpack config — Turbopack is default -- ❌ Do NOT skip Zod validation — all API payloads must have schemas -- ❌ Do NOT use `useEffect` for external API calls — use React Query -- ❌ Do NOT commit `.env` files - ---- - -## Quick Reference - -| Task | Where? | Pattern | -| -------------- | ----------------------------- | ------------------------- | -| State (client) | `src/stores/` | Zustand + persist | -| State (server) | Components | React Query hooks | -| API calls | `src/lib/api/endpoints/` | Typed service functions | -| Validation | `src/schemas/` | Zod schemas | -| UI component | `src/components/common/` | Named export + TypeScript | -| Auth component | `src/components/auth/` | Reusable auth flows | -| Page | `src/app/[locale]/{feature}/` | Page + translations | -| Translation | `src/i18n/locales/{en,tr}/` | Namespace + key | -| E2E test | `tests-e2e/{feature}/` | Page Object pattern | - ---- - -## Development Workflow - -1. **Create schema** → Define types with Zod -2. **Create API service** → Function that calls endpoint -3. **Create component** → Use React Query or Zustand -4. **Add translations** → Both en + tr -5. **Test locally** → `pnpm dev` -6. **Write E2E test** → Playwright -7. **Lint & format** → `pnpm lint && pnpm format` -8. **Commit** → Conventional commit message diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index cbb3d7f..0000000 --- a/.cursorrules +++ /dev/null @@ -1,151 +0,0 @@ -# NextJS-Template Cursor Rules - -## Stack - -- **Framework**: Next.js 16 (App Router with `[locale]` routing) -- **Runtime**: React 19 -- **Language**: TypeScript (strict mode enforced) -- **Styling**: Tailwind CSS 4 + shadcn/ui + Radix UI -- **Forms**: React Hook Form + Zod validation -- **State**: Zustand (global) + React Query (server state) -- **i18n**: i18next with namespaces (en, tr) -- **API**: Axios with custom abstraction layer -- **Testing**: Playwright E2E + Vitest unit tests -- **Bundler**: Turbopack (default, do NOT add webpack) - -## Code Style & Exports - -- **Exports**: Named exports ONLY. Never use default exports - - **Exception**: `page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx` files require default exports (Next.js App Router requirement) -- **Types**: Required for all variables, parameters, and returns. No `any` type -- **Styling**: Tailwind utility classes exclusively. No separate CSS files -- **Path imports**: Always use `@/` alias (maps to `src/`). Never use relative `../` paths -- **Naming**: `camelCase` for functions/vars, `PascalCase` for components/types - -## Architecture Rules - -### API Calls - -- **ONLY** call API through `src/lib/api/` abstraction -- Never use `fetch()` or `axios` directly in components -- Example: `import { authService } from '@/lib/api/endpoints/auth'` -- Service pattern: create typed functions in endpoint files with Zod schemas - -### Global State Management - -- **Zustand**: For client-side global state (auth, UI state) in `src/stores/` -- **React Query**: For server state (API cache, sync) using hooks in components -- Do NOT duplicate state between Zustand and React Query - -### Validation Schemas - -- All input validation uses Zod (`src/schemas/`) -- Both API payloads and form inputs must have Zod schemas -- Reference existing schemas before creating new ones - -### Components - -- **Shared/reusable**: Place in `src/components/common/` -- **Auth-related**: Place in `src/components/auth/` -- **Page-specific**: Co-locate with page folder or in relevant subfolder -- **shadcn/ui**: Check if component exists: `pnpm dlx shadcn@latest add ` -- **UI patterns**: Use shadcn/ui over building custom components - -### Internationalization (i18n) - -- All user-facing strings use `useLanguage()` hook or `useTranslation()` -- Translation files: `src/i18n/locales/{en,tr}/{namespace}.json` -- Access: `useTranslation('namespace')` → `t('key')` -- Keep translations modular by namespace (auth, errors, success, common, etc.) - -### Routing & Locales - -- All routes nested under `src/app/[locale]/` -- URL format: `/en/dashboard`, `/tr/login` (locale-first) -- Use `getLocalizedPath()` utility for navigation -- Protected routes: defined in `src/lib/config/routes.ts` - -### Forms - -- Use React Hook Form + Zod for validation -- Example: `const form = useForm({ resolver: zodResolver(loginSchema) })` -- Reuse `AuthEmailField`, `AuthPasswordField` components for auth -- Place form components in `src/components/` - -## Next.js 16 Specifics - -- **NO `middleware.ts`**: Use `src/proxy.ts` for routing logic -- **NO webpack config**: Turbopack is default bundler -- **APP Router only**: No pages/ directory -- **Server Components by default**: Use `"use client"` only when needed -- **Locale routing**: All routes under `[locale]` segment - -## Environment Variables - -- Never hardcode URLs or config -- Use: `NEXT_PUBLIC_API_URL` (backend base), `NEXT_PUBLIC_API_PREFIX` (default `/api/v1`) -- App name: `NEXT_PUBLIC_APP_NAME` -- Never commit `.env` file — always use `.env.example` - -## TypeScript & Linting - -- **Strict mode**: All compiler options enabled in `tsconfig.json` -- **ESLint**: Strict + React + React Hooks + type-checked -- Fix errors: `pnpm lint` -- Format code: `pnpm format` -- Max file: 300 lines (skip blank/comments), max function: 100 lines - -## Conventional Commits - -All commits must follow this format: `type: description` - -**Examples:** - -- `feat: add profile settings page with theme preference` -- `feat: implement password reset flow with email verification` -- `feat: add Turkish translations for auth pages` -- `feat: integrate React Query for user profile caching` -- `feat: create reusable FormField component for auth` -- `feat: add Zod schema validation for reset password form` -- `fix: correct email validation regex in auth schema` -- `fix: prevent duplicate API calls on form submission` -- `fix: resolve locale routing issue on language switch` -- `refactor: extract API error handling to custom hook` -- `refactor: split large auth store into feature stores` -- `refactor: migrate from useState to React Query for server state` -- `chore: update Next.js to v16.2.1` -- `chore: bump shadcn/ui components to latest` -- `docs: add component creation guidelines to README` -- `docs: update API proxy configuration documentation` -- `test: add E2E tests for forgot password flow` -- `test: cover edge cases in password validation schema` - -## Testing - -- **E2E**: Playwright in `tests-e2e/` with `*.spec.ts` files -- **Unit**: Vitest in `src/test/` with `*.test.ts` files -- **API Mocking**: Use MSW for HTTP mocking in tests -- Run: `pnpm test:e2e` (E2E), `pnpm lint` (lint) - -## Forbidden - -- ❌ Default exports -- ❌ `fetch()` or direct `axios` calls (use API abstraction) -- ❌ Hardcoded URLs or API paths -- ❌ CSS files (use Tailwind only) -- ❌ `middleware.ts` (use `proxy.ts`) -- ❌ webpack config -- ❌ Committing `.env` files -- ❌ `any` type without `// @ts-expect-error` comment -- ❌ Relative imports in imports (always use `@/`) - -## Commands - -```bash -pnpm dev # Dev server (localhost:3000) -pnpm build # Production build -pnpm lint # Check linting -pnpm format # Format code -pnpm test:e2e # Run Playwright tests -pnpm test:e2e:report # View test report -``` From 18b1a84051fb094e4d680a07bdb5b5c36fe3e405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sun, 14 Jun 2026 18:44:36 +0300 Subject: [PATCH 17/17] fix: address review findings --- .../admin/broadcasts/AnnouncementPreview.tsx | 10 +++++-- .../admin/broadcasts/BroadcastComposer.tsx | 6 ++-- .../admin/broadcasts/CustomLanguageFields.tsx | 4 +-- .../broadcasts/TemplateVariableFields.tsx | 4 +-- src/components/common/AnnouncementBanner.tsx | 8 ++--- src/components/common/notification-text.ts | 14 +++++++-- src/hooks/api/use-broadcasts.ts | 3 ++ src/schemas/announcement.ts | 29 +++++++++++++++++++ 8 files changed, 63 insertions(+), 15 deletions(-) diff --git a/src/components/admin/broadcasts/AnnouncementPreview.tsx b/src/components/admin/broadcasts/AnnouncementPreview.tsx index 43d9e36..a75d3c7 100644 --- a/src/components/admin/broadcasts/AnnouncementPreview.tsx +++ b/src/components/admin/broadcasts/AnnouncementPreview.tsx @@ -14,7 +14,7 @@ import type { BroadcastTemplate, } from "@/lib/types/announcement"; -interface Props { +interface AnnouncementPreviewProps { kind: AnnouncementKind; level: AnnouncementLevel; template?: BroadcastTemplate; @@ -28,7 +28,13 @@ const LEVEL_ACCENT: Record = { critical: "border-l-destructive", }; -export function AnnouncementPreview({ kind, level, template, variables, translations }: Props) { +export function AnnouncementPreview({ + kind, + level, + template, + variables, + translations, +}: AnnouncementPreviewProps) { const { t, i18n } = useTranslation("broadcasts"); const lang = (i18n.language.split("-")[0] ?? "en") as AnnouncementLanguage; diff --git a/src/components/admin/broadcasts/BroadcastComposer.tsx b/src/components/admin/broadcasts/BroadcastComposer.tsx index 1228c05..a31548a 100644 --- a/src/components/admin/broadcasts/BroadcastComposer.tsx +++ b/src/components/admin/broadcasts/BroadcastComposer.tsx @@ -20,6 +20,8 @@ import type { BroadcastTemplate, } from "@/lib/types/announcement"; import { SystemRole } from "@/lib/types/user"; +import { zodFieldRule } from "@/lib/validation/zodToAntdRule"; +import { getRoleFilterSchema, getTemplateKeySchema } from "@/schemas/announcement"; interface FormValues { kind: AnnouncementKind; @@ -133,7 +135,7 @@ export function BroadcastComposer() { diff --git a/src/components/admin/broadcasts/CustomLanguageFields.tsx b/src/components/admin/broadcasts/CustomLanguageFields.tsx index c64aa91..772da97 100644 --- a/src/components/admin/broadcasts/CustomLanguageFields.tsx +++ b/src/components/admin/broadcasts/CustomLanguageFields.tsx @@ -13,11 +13,11 @@ import { const LANGUAGES: AnnouncementLanguage[] = ["tr", "en"]; -interface Props { +interface CustomLanguageFieldsProps { disabled?: boolean; } -export function CustomLanguageFields({ disabled }: Props) { +export function CustomLanguageFields({ disabled }: CustomLanguageFieldsProps) { const { t } = useTranslation("broadcasts"); const schema = getAnnouncementContentSchema(t); diff --git a/src/components/admin/broadcasts/TemplateVariableFields.tsx b/src/components/admin/broadcasts/TemplateVariableFields.tsx index 5dc4052..6e49924 100644 --- a/src/components/admin/broadcasts/TemplateVariableFields.tsx +++ b/src/components/admin/broadcasts/TemplateVariableFields.tsx @@ -13,12 +13,12 @@ import { const LANGUAGES: AnnouncementLanguage[] = ["tr", "en"]; -interface Props { +interface TemplateVariableFieldsProps { template: BroadcastTemplate; disabled?: boolean; } -export function TemplateVariableFields({ template, disabled }: Props) { +export function TemplateVariableFields({ template, disabled }: TemplateVariableFieldsProps) { const { t } = useTranslation("broadcasts"); const label = (name: string) => t(`admin.vars.${name}`, { defaultValue: name }); const datetimeRule = zodFieldRule(getDatetimeFieldSchema(t)); diff --git a/src/components/common/AnnouncementBanner.tsx b/src/components/common/AnnouncementBanner.tsx index ce7fe77..44c3e72 100644 --- a/src/components/common/AnnouncementBanner.tsx +++ b/src/components/common/AnnouncementBanner.tsx @@ -32,14 +32,14 @@ const LEVEL_STYLE: Record = { // in, sliding back up on dismiss. Lower stiffness = slower; mass adds weight. const STRIP_SPRING: Transition = { type: "spring", stiffness: 170, damping: 20, mass: 1.1 }; -interface StripProps { +interface BannerStripProps { level: AnnouncementLevel; title: string; body: string; onDismiss: () => void; } -function BannerStrip({ level, title, body, onDismiss }: StripProps) { +function BannerStrip({ level, title, body, onDismiss }: BannerStripProps) { const { t } = useTranslation("broadcasts"); const reduce = useReducedMotion(); const [detailOpen, setDetailOpen] = useState(false); @@ -94,12 +94,12 @@ function BannerStrip({ level, title, body, onDismiss }: StripProps) { ); } -interface SheetProps extends StripProps { +interface BannerSheetProps extends BannerStripProps { open: boolean; onClosed: () => void; } -function BannerSheet({ open, level, title, body, onDismiss, onClosed }: SheetProps) { +function BannerSheet({ open, level, title, body, onDismiss, onClosed }: BannerSheetProps) { const { t } = useTranslation("broadcasts"); return ( { mutationFn: (payload: BroadcastCreate) => broadcastsApi.send(payload), onSuccess: () => { queryClient.invalidateQueries({ queryKey: broadcastKeys.all }); + // Distinct prefix from `all` — refresh the banner so a show_banner send + // appears immediately instead of waiting for the WS event / staleTime. + queryClient.invalidateQueries({ queryKey: broadcastKeys.activeAnnouncement }); }, }); }; diff --git a/src/schemas/announcement.ts b/src/schemas/announcement.ts index 6fcbeb1..3c2363e 100644 --- a/src/schemas/announcement.ts +++ b/src/schemas/announcement.ts @@ -40,3 +40,32 @@ export const getTextVarSchema = (t: TFunction) => .trim() .min(1, { message: t("validation:required") }) .max(ANNOUNCEMENT_TITLE_MAX); + +// A template must be chosen before sending (template mode); the value is the +// catalog key string. +export const getTemplateKeySchema = (t: TFunction) => + z + .string() + .trim() + .min(1, { message: t("validation:required") }); + +// When the audience is "by role", a role must be selected; the value is a +// SystemRole string. +export const getRoleFilterSchema = (t: TFunction) => + z + .string() + .trim() + .min(1, { message: t("validation:required") }); + +// Narrowing schemas for an announcement notification's stored payload, which +// arrives as untrusted JsonValue. A variable is either a datetime ISO string or +// a per-language text map; translations are a per-language {title, body} map. +// Used to safeParse the payload instead of force-casting it. +const announcementVariableValueSchema = z.union([z.string(), z.record(z.string(), z.string())]); + +export const announcementVariablesSchema = z.record(z.string(), announcementVariableValueSchema); + +export const announcementTranslationsSchema = z.record( + z.string(), + z.object({ title: z.string().optional(), body: z.string().optional() }), +);