feat(admin): add never-subscribed broadcast target with audience counts

Add a broadcast audience for users who registered but never had any subscription or trial (no Subscription rows at all), backed by a new get_user_ids_without_any_subscription DAL helper and a 'never' target in the webapp broadcast route.

Add GET /api/admin/broadcast/audience-counts so the audience dropdown shows the recipient count next to each option, with graceful fallback when counts are unavailable.
This commit is contained in:
3252a8
2026-06-01 23:37:59 +03:00
parent 619a13128c
commit 56796d9f22
9 changed files with 84 additions and 4 deletions
@@ -1,16 +1,27 @@
<script>
import { Textarea } from "$components/ui/index.js";
import { Send } from "$components/ui/icons.js";
import { getContext } from "svelte";
import { getContext, onMount } from "svelte";
import { Label } from "$components/ui/primitives.js";
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
export let at;
const broadcastStore = getContext("broadcastStore");
$: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult } = $broadcastStore);
$: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult, broadcastCounts } =
$broadcastStore);
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
// Append the resolved audience size to each option once counts are loaded.
$: targetOptions = BROADCAST_TARGET_OPTIONS.map((option) => {
const count = broadcastCounts?.[option.value];
return count == null ? option : { ...option, label: `${option.label} (${count})` };
});
onMount(() => {
broadcastStore.loadCounts();
});
</script>
<div class="admin-card">
@@ -24,7 +35,7 @@
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
<AdminSelect
value={broadcastTarget}
items={BROADCAST_TARGET_OPTIONS}
items={targetOptions}
ariaLabel={at("broadcast_label_audience", {}, "Аудитория")}
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
/>
@@ -6,6 +6,7 @@ export function createBroadcastStore({ api, onToast, at }) {
broadcastText: "",
broadcastBusy: false,
broadcastResult: null,
broadcastCounts: null,
});
const BROADCAST_TARGET_OPTIONS = [
@@ -13,8 +14,23 @@ export function createBroadcastStore({ api, onToast, at }) {
{ value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
{ value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
{ value: "expired", label: at("broadcast_target_expired", {}, "Expired subscription") },
{
value: "never",
label: at("broadcast_target_never", {}, "Без подписки и без истории"),
},
];
async function loadCounts() {
try {
const res = await api("/admin/broadcast/audience-counts");
if (res?.ok && res.counts) {
state.update((s) => ({ ...s, broadcastCounts: res.counts }));
}
} catch {
// Counts are advisory; ignore failures and keep plain labels.
}
}
async function runBroadcast() {
let text = "";
let target = "";
@@ -56,6 +72,7 @@ export function createBroadcastStore({ api, onToast, at }) {
update: state.update,
runBroadcast,
updateField,
loadCounts,
BROADCAST_TARGET_OPTIONS,
};
}
+6
View File
@@ -658,6 +658,12 @@ function demoApiResponse(path, cleanPath, options, context) {
const params = queryParams(path);
if (cleanPath === "/admin/stats") return clone(DEMO_DATASET.stats);
if (cleanPath === "/admin/broadcast/audience-counts") {
return {
ok: true,
counts: { all: 1280, active: 742, inactive: 538, expired: 311, never: 227 },
};
}
if (cleanPath === "/admin/sync") return { ok: true, status: "queued" };
if (cleanPath === "/admin/payments") {