Refine PitchFast campaign visual identity
This commit is contained in:
@@ -3,17 +3,31 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { FunctionReturnType } from "convex/server";
|
||||
import { Clock3, MapPin, Pencil, Play, RefreshCcw, Plus, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
CalendarClock,
|
||||
Clock3,
|
||||
MapPin,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Id } from "@/convex/_generated/dataModel";
|
||||
import { campaignFormDefaults } from "@/lib/campaign-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { CampaignFormDialog } from "@/components/campaigns/campaign-form-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CampaignsListResult = FunctionReturnType<typeof api.campaigns.list>;
|
||||
type CampaignRunsListResult = FunctionReturnType<typeof api.runs.list>;
|
||||
@@ -91,6 +105,38 @@ const formatNiche = (campaign: CampaignRow): string => {
|
||||
: campaign.category;
|
||||
};
|
||||
|
||||
const formatRunStatus = (value: string): string => {
|
||||
return statusLabel[value] ?? value;
|
||||
};
|
||||
|
||||
const getRunStatusClassName = (value: string): string => {
|
||||
if (value === "running" || value === "pending") {
|
||||
return "evidence-surface border-transparent";
|
||||
}
|
||||
|
||||
if (value === "succeeded") {
|
||||
return "safe-surface border-transparent";
|
||||
}
|
||||
|
||||
if (value === "failed" || value === "canceled") {
|
||||
return "border-transparent bg-[var(--danger-soft)] text-destructive";
|
||||
}
|
||||
|
||||
return "border-border/75 bg-muted/55 text-muted-foreground";
|
||||
};
|
||||
|
||||
const getRunStepClassName = (value?: string | null): string => {
|
||||
if (value === "campaign_cron_skipped" || value === "campaign_cron_stale_pending") {
|
||||
return "review-surface border-transparent";
|
||||
}
|
||||
|
||||
if (value === "lead_discovery" || value === "campaign_cron_queued") {
|
||||
return "evidence-surface border-transparent";
|
||||
}
|
||||
|
||||
return "border-border/75 bg-background/60";
|
||||
};
|
||||
|
||||
export function CampaignsBoard() {
|
||||
const campaigns = useQuery(api.campaigns.list, { limit: 100 });
|
||||
const recentCampaignRuns = useQuery(api.runs.list, {
|
||||
@@ -147,6 +193,15 @@ export function CampaignsBoard() {
|
||||
return recentCampaignRuns ?? [];
|
||||
}, [recentCampaignRuns]);
|
||||
|
||||
const activeCampaignCount = campaignsSorted.filter(
|
||||
(campaign) => campaign.status === "active",
|
||||
).length;
|
||||
const pausedCampaignCount = campaignsSorted.length - activeCampaignCount;
|
||||
const nextScheduledCampaign = [...campaignsSorted]
|
||||
.filter((campaign) => campaign.nextRunAt)
|
||||
.sort((a, b) => (a.nextRunAt ?? Infinity) - (b.nextRunAt ?? Infinity))
|
||||
.at(0);
|
||||
|
||||
const closeDialog = () => {
|
||||
setEditingCampaign(null);
|
||||
setIsFormOpen(false);
|
||||
@@ -260,11 +315,11 @@ export function CampaignsBoard() {
|
||||
<div>
|
||||
<p className="agency-kicker">Controlled Sourcing</p>
|
||||
<h1 className="mt-2 font-heading text-3xl font-semibold tracking-normal">
|
||||
Kampagnen
|
||||
Kontrollierte Suchläufe
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Plane Suchläufe mit festen Limits, sauberem Radius und manuellen
|
||||
Prüfstationen vor Audit und Outreach.
|
||||
Kampagnen bleiben bewusst limitiert: Suchgebiet, Cadence, Run-Status
|
||||
und manuelle Freigaben stehen vor jeder Outreach-Bewegung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -278,153 +333,321 @@ export function CampaignsBoard() {
|
||||
{rowError ? <p className="text-sm text-destructive" role="alert">{rowError}</p> : null}
|
||||
{actionLabel ? <p className="text-sm" role="status">{actionLabel}</p> : null}
|
||||
|
||||
{campaignsSorted.length === 0 ? (
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>Keine Kampagnen</CardTitle>
|
||||
<CardDescription>
|
||||
Lege zuerst eine Kampagne mit Kategorie, PLZ und Limits an.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{campaignsSorted.map((campaign) => {
|
||||
const campaignTitleId = `campaign-title-${campaign._id}`;
|
||||
|
||||
return (
|
||||
<Card
|
||||
aria-labelledby={campaignTitleId}
|
||||
className="agency-panel overflow-hidden"
|
||||
key={campaign._id}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate" id={campaignTitleId}>
|
||||
{campaign.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate">
|
||||
{formatNiche(campaign)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
variant={campaign.status === "active" ? "default" : "secondary"}
|
||||
>
|
||||
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3 text-sm">
|
||||
<div className="evidence-surface flex flex-wrap items-center justify-between gap-3 rounded-md px-3 py-2">
|
||||
<div className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<MapPin className="size-3" />
|
||||
<span>{campaign.postalCode}</span>
|
||||
</div>
|
||||
<span className="font-semibold">{campaign.radiusKm} km</span>
|
||||
</div>
|
||||
<Separator className="bg-border" />
|
||||
<div className="grid gap-2 rounded-md border border-border/75 bg-background/60 p-3">
|
||||
<p className="inline-flex items-center gap-2 font-medium">
|
||||
<Clock3 className="size-3.5 text-primary" />
|
||||
Cadence: {recurrenceLabel[campaign.recurrence]}
|
||||
</p>
|
||||
<p className="inline-flex items-center gap-2 font-medium">
|
||||
<ShieldCheck className="size-3.5 text-primary" />
|
||||
Lead-Limit: {campaign.maxNewLeadsPerRun}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1 rounded-md bg-muted/45 p-3">
|
||||
<p className="text-muted-foreground">
|
||||
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Nächster Lauf: {formatDateTime(campaign.nextRunAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Run-Status:{" "}
|
||||
{statusLabel[campaign.currentRunStatus] ??
|
||||
campaign.currentRunStatus}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openEditDialog(campaign)}
|
||||
disabled={actionBusyId === campaign._id}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => toggleCampaign(campaign)}
|
||||
disabled={actionBusyId === campaign._id}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<RefreshCcw className="size-4" />
|
||||
{campaign.status === "active" ? "Pausieren" : "Fortfahren"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => runCampaign(campaign)}
|
||||
disabled={actionBusyId === campaign._id}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Jetzt ausführen
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>Aktuelle Run-Logs</CardTitle>
|
||||
<CardDescription>
|
||||
Letzte Kampagnenläufe inklusive Cron-Skips und Fehlerhinweisen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 text-sm">
|
||||
{recentCampaignRuns === undefined ? (
|
||||
<Skeleton className="h-16 rounded-lg" />
|
||||
) : visibleRuns.length === 0 ? (
|
||||
<p className="text-muted-foreground">Noch keine Kampagnenläufe.</p>
|
||||
<div className="grid gap-4 2xl:grid-cols-[minmax(0,1fr)_21rem]">
|
||||
<div className="min-w-0">
|
||||
{campaignsSorted.length === 0 ? (
|
||||
<Card className="agency-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>Keine Kampagnen</CardTitle>
|
||||
<CardDescription>
|
||||
Lege zuerst eine Kampagne mit Kategorie, PLZ und Limits an.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
visibleRuns.map((run) => (
|
||||
<div className="rounded-md border border-border/75 bg-background/60 p-3" key={run._id}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-medium">
|
||||
{statusLabel[run.status] ?? run.status}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDateTime(run.updatedAt)}
|
||||
</p>
|
||||
<>
|
||||
<div className="campaign-ledger hidden 2xl:block">
|
||||
<div role="table" aria-label="Kampagnen-Ledger">
|
||||
<div role="rowgroup">
|
||||
<div
|
||||
className="campaign-ledger-row campaign-ledger-header"
|
||||
role="row"
|
||||
>
|
||||
<div role="columnheader">Kampagne</div>
|
||||
<div role="columnheader">Suchgebiet</div>
|
||||
<div role="columnheader">Status</div>
|
||||
<div role="columnheader">Cadence</div>
|
||||
<div role="columnheader">Limits</div>
|
||||
<div role="columnheader">Läufe</div>
|
||||
<div role="columnheader">Run</div>
|
||||
<div role="columnheader">Aktionen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div role="rowgroup">
|
||||
{campaignsSorted.map((campaign) => {
|
||||
const campaignTitleId = `campaign-title-${campaign._id}`;
|
||||
const isBusy = actionBusyId === campaign._id;
|
||||
const isActive = campaign.status === "active";
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-labelledby={campaignTitleId}
|
||||
className="campaign-ledger-row"
|
||||
key={campaign._id}
|
||||
role="row"
|
||||
>
|
||||
<div className="min-w-0" role="cell">
|
||||
<h2
|
||||
className="truncate font-heading text-sm font-semibold"
|
||||
id={campaignTitleId}
|
||||
>
|
||||
{campaign.name}
|
||||
</h2>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{formatNiche(campaign)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1 text-sm" role="cell">
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
<MapPin className="size-3.5 text-primary" />
|
||||
{campaign.postalCode}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Radius {campaign.radiusKm} km
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-1" role="cell">
|
||||
<label className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onCheckedChange={() => toggleCampaign(campaign)}
|
||||
disabled={isBusy}
|
||||
aria-label={`${campaign.name} Status ändern`}
|
||||
/>
|
||||
{isActive ? "Aktiv" : "Pausiert"}
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-1 text-sm" role="cell">
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
<Clock3 className="size-3.5 text-primary" />
|
||||
{recurrenceLabel[campaign.recurrence]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-1 text-sm" role="cell">
|
||||
<span>Lead-Limit: {campaign.maxNewLeadsPerRun}</span>
|
||||
<span>Audit-Limit: {campaign.maxAuditsPerRun}</span>
|
||||
</div>
|
||||
<div className="grid gap-1 text-xs text-muted-foreground" role="cell">
|
||||
<span>Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</span>
|
||||
<span>Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</span>
|
||||
</div>
|
||||
<div className="grid gap-1" role="cell">
|
||||
<Badge
|
||||
className={getRunStatusClassName(
|
||||
campaign.currentRunStatus,
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
{formatRunStatus(campaign.currentRunStatus)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1" role="cell">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => openEditDialog(campaign)}
|
||||
disabled={isBusy}
|
||||
aria-label={`${campaign.name} bearbeiten`}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => runCampaign(campaign)}
|
||||
disabled={isBusy}
|
||||
size="sm"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
Suchlauf starten
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{stepLabel[run.currentStep ?? ""] ?? run.currentStep ?? "Schritt offen"}
|
||||
</p>
|
||||
{run.currentStep === "campaign_cron_skipped" ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Cron wurde übersprungen, weil bereits ein Agentenlauf aktiv war.
|
||||
</p>
|
||||
) : null}
|
||||
{run.errorSummary ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
{run.errorSummary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
|
||||
<div className="campaign-mobile-records grid gap-3 2xl:hidden">
|
||||
{campaignsSorted.map((campaign) => {
|
||||
const campaignTitleId = `campaign-title-mobile-${campaign._id}`;
|
||||
const isBusy = actionBusyId === campaign._id;
|
||||
const isActive = campaign.status === "active";
|
||||
|
||||
return (
|
||||
<article
|
||||
aria-labelledby={campaignTitleId}
|
||||
className="campaign-mobile-record"
|
||||
key={campaign._id}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2
|
||||
className="truncate font-heading text-base font-semibold"
|
||||
id={campaignTitleId}
|
||||
>
|
||||
{campaign.name}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{formatNiche(campaign)} · {campaign.postalCode}
|
||||
</p>
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onCheckedChange={() => toggleCampaign(campaign)}
|
||||
disabled={isBusy}
|
||||
aria-label={`${campaign.name} Status ändern`}
|
||||
/>
|
||||
{isActive ? "Aktiv" : "Pausiert"}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<p className="rounded-md bg-muted/50 px-3 py-2">
|
||||
Radius {campaign.radiusKm} km · {recurrenceLabel[campaign.recurrence]}
|
||||
</p>
|
||||
<p className="rounded-md bg-muted/50 px-3 py-2">
|
||||
Lead-Limit: {campaign.maxNewLeadsPerRun} · Audit-Limit:{" "}
|
||||
{campaign.maxAuditsPerRun}
|
||||
</p>
|
||||
<p className="rounded-md bg-muted/50 px-3 py-2">
|
||||
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
|
||||
</p>
|
||||
<p className="rounded-md bg-muted/50 px-3 py-2">
|
||||
Nächster Lauf: {formatDateTime(campaign.nextRunAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<Badge
|
||||
className={getRunStatusClassName(
|
||||
campaign.currentRunStatus,
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
Run: {formatRunStatus(campaign.currentRunStatus)}
|
||||
</Badge>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(campaign)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => runCampaign(campaign)}
|
||||
disabled={isBusy}
|
||||
size="sm"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
Suchlauf starten
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<aside className="campaign-status-rail agency-panel grid gap-3 p-4">
|
||||
<div>
|
||||
<p className="agency-kicker">Sourcing Status</p>
|
||||
<h2 className="mt-2 font-heading text-lg font-semibold">
|
||||
Kampagnenkontrolle
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="safe-surface rounded-md p-3">
|
||||
<p className="text-2xl font-semibold">{activeCampaignCount}</p>
|
||||
<p className="text-xs font-medium">aktiv</p>
|
||||
</div>
|
||||
<div className="review-surface rounded-md p-3">
|
||||
<p className="text-2xl font-semibold">{pausedCampaignCount}</p>
|
||||
<p className="text-xs font-medium">pausiert</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/75 bg-background/60 p-3 text-sm">
|
||||
<p className="inline-flex items-center gap-2 font-semibold">
|
||||
<CalendarClock className="size-4 text-primary" />
|
||||
Nächster geplanter Lauf
|
||||
</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{nextScheduledCampaign
|
||||
? `${nextScheduledCampaign.name}: ${formatDateTime(nextScheduledCampaign.nextRunAt)}`
|
||||
: "Kein Lauf geplant"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="safe-surface rounded-md p-3 text-sm">
|
||||
<p className="inline-flex items-center gap-2 font-semibold">
|
||||
<ShieldCheck className="size-4" />
|
||||
Versand nur nach Freigabe
|
||||
</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Kampagnen erzeugen Recherche und Audits. Outreach bleibt manuell
|
||||
geprüft.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-heading text-base font-semibold">
|
||||
Aktuelle Run-Logs
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Cron-Skips, Fehler und letzte Kampagnenläufe.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-sm">
|
||||
{recentCampaignRuns === undefined ? (
|
||||
<Skeleton className="h-16 rounded-lg" />
|
||||
) : visibleRuns.length === 0 ? (
|
||||
<p className="text-muted-foreground">
|
||||
Noch keine Kampagnenläufe.
|
||||
</p>
|
||||
) : (
|
||||
visibleRuns.map((run) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border p-3",
|
||||
getRunStatusClassName(run.status),
|
||||
getRunStepClassName(run.currentStep),
|
||||
)}
|
||||
key={run._id}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-medium">
|
||||
{formatRunStatus(run.status)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDateTime(run.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{stepLabel[run.currentStep ?? ""] ??
|
||||
run.currentStep ??
|
||||
"Schritt offen"}
|
||||
</p>
|
||||
{run.currentStep === "campaign_cron_skipped" ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Cron wurde übersprungen, weil bereits ein Agentenlauf
|
||||
aktiv war.
|
||||
</p>
|
||||
) : null}
|
||||
{run.errorSummary ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
{run.errorSummary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user