Rework of campaign structure; locking
This commit is contained in:
@@ -1,91 +1,82 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import MetricCard from "../../components/MetricCard";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import FormField from "../../components/FormField";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import { createNewCampaign, publishCampaignVersion, updateCampaignVersion } from "../../api/campaigns";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import { publishCampaignVersion, updateCampaignMetadata, type CampaignVersionListItem } from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import {
|
||||
asArray,
|
||||
asRecord,
|
||||
cloneCampaignJsonForCopy,
|
||||
getCampaignJson,
|
||||
getString,
|
||||
isAuditLockedVersion,
|
||||
summaryValue,
|
||||
timestampSlug,
|
||||
versionLockReason
|
||||
} from "./utils/campaignView";
|
||||
import { addressesFromValue } from "../../utils/emailAddresses";
|
||||
import { canUnlockValidationVersion, formatDateTime, isFinalLockedVersion, isUserLockedVersion, isVersionReadyForDelivery, summaryValue } from "./utils/campaignView";
|
||||
|
||||
const campaignModeOptions = ["draft", "test", "send"];
|
||||
|
||||
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [locking, setLocking] = useState(false);
|
||||
const campaign = data.campaign;
|
||||
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
|
||||
const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" });
|
||||
const [identityDirty, setIdentityDirty] = useState(false);
|
||||
const [savingIdentity, setSavingIdentity] = useState(false);
|
||||
const [lockingVersion, setLockingVersion] = useState<CampaignVersionListItem | null>(null);
|
||||
const [lockBusy, setLockBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const campaign = data.campaign;
|
||||
const currentVersion = data.currentVersion;
|
||||
const campaignJson = getCampaignJson(currentVersion);
|
||||
const locked = isAuditLockedVersion(currentVersion);
|
||||
const cards = data.summary?.cards;
|
||||
const overviewFacts = getOverviewFacts(campaignJson, campaign);
|
||||
useEffect(() => {
|
||||
if (!campaign || identityDirty) return;
|
||||
setIdentity({
|
||||
external_id: campaign.external_id ?? "",
|
||||
name: campaign.name ?? "",
|
||||
status: campaign.status ?? "",
|
||||
description: campaign.description ?? ""
|
||||
});
|
||||
}, [campaign, identityDirty]);
|
||||
|
||||
async function copyCampaign() {
|
||||
if (!currentVersion) return;
|
||||
setCopying(true);
|
||||
function patchIdentity(key: keyof typeof identity, value: string) {
|
||||
setIdentity((current) => ({ ...current, [key]: value }));
|
||||
setIdentityDirty(true);
|
||||
setMessage("");
|
||||
setError("");
|
||||
try {
|
||||
const copy = cloneCampaignJsonForCopy(campaignJson, campaign, timestampSlug());
|
||||
const created = await createNewCampaign(settings, {
|
||||
external_id: copy.externalId,
|
||||
name: copy.name,
|
||||
description: copy.description,
|
||||
current_flow: "manual",
|
||||
current_step: "copied"
|
||||
});
|
||||
await updateCampaignVersion(settings, created.campaign.id, created.version.id, {
|
||||
campaign_json: copy.rawJson,
|
||||
current_flow: "manual",
|
||||
current_step: null,
|
||||
workflow_state: "editing",
|
||||
is_complete: false,
|
||||
editor_state: {
|
||||
copied_from_campaign_id: campaignId,
|
||||
copied_from_version_id: currentVersion.id
|
||||
}
|
||||
});
|
||||
navigate(`/campaigns/${created.campaign.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function lockCampaign() {
|
||||
if (!currentVersion || locked) return;
|
||||
const confirmed = window.confirm(
|
||||
"Lock this campaign version for audit-safe use? The current version should no longer be edited afterwards; create a copy if you need a new working version."
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setLocking(true);
|
||||
setMessage("");
|
||||
async function saveIdentity() {
|
||||
if (!campaign || savingIdentity || !identityDirty) return;
|
||||
setSavingIdentity(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
await publishCampaignVersion(settings, campaignId, currentVersion.id);
|
||||
setMessage("Campaign version locked as the current audit-safe version.");
|
||||
await updateCampaignMetadata(settings, campaign.id, {
|
||||
external_id: identity.external_id,
|
||||
name: identity.name,
|
||||
status: identity.status,
|
||||
description: identity.description
|
||||
});
|
||||
setIdentityDirty(false);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLocking(false);
|
||||
setSavingIdentity(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function lockAuditSnapshot() {
|
||||
const version = lockingVersion;
|
||||
if (!version || lockBusy) return;
|
||||
setLockBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
await publishCampaignVersion(settings, campaignId, version.id);
|
||||
setMessage(`Version #${version.version_number} locked as audit-safe snapshot.`);
|
||||
setLockingVersion(null);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLockBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,14 +85,12 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>{campaign?.name || "Overview"}</PageTitle>
|
||||
<p className="mono-small">{campaign?.external_id || campaignId}</p>
|
||||
<p className="mono-small">Campaign overview · version-independent identity and version history</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button onClick={copyCampaign} disabled={!currentVersion || copying}>{copying ? "Copying…" : "Copy campaign"}</Button>
|
||||
<Button variant="primary" onClick={lockCampaign} disabled={!currentVersion || locked || locking}>
|
||||
{locking ? "Locking…" : locked ? "Locked" : "Lock campaign"}
|
||||
</Button>
|
||||
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>Reload</Button>
|
||||
<Link to="wizard/create"><Button>Edit with wizard</Button></Link>
|
||||
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "Saving…" : "Save"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,133 +98,113 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
{message && <div className="alert success">{message}</div>}
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading campaign overview…">
|
||||
<Card title="Campaign identity">
|
||||
<div className="form-grid campaign-identity-grid">
|
||||
<FormField label="Campaign ID">
|
||||
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Mode">
|
||||
<select value={identity.status} onChange={(event) => patchIdentity("status", event.target.value)}>
|
||||
{campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Name">
|
||||
<input value={identity.name} onChange={(event) => patchIdentity("name", event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Description">
|
||||
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{locked && (
|
||||
<div className="alert info">
|
||||
This version is audit-safe and should be treated as read-only. {versionLockReason(currentVersion)} Only workflow state should change from here.
|
||||
</div>
|
||||
)}
|
||||
<Card title="Version history">
|
||||
<div className="app-table-wrap">
|
||||
<table className="app-table version-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>State</th>
|
||||
<th>Lock</th>
|
||||
<th>Validation</th>
|
||||
<th>Build</th>
|
||||
<th>Updated</th>
|
||||
<th aria-label="Actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{versions.length === 0 && (
|
||||
<tr><td colSpan={7} className="muted">No versions found.</td></tr>
|
||||
)}
|
||||
{versions.map((version) => {
|
||||
const isCurrentVersion = version.id === data.currentVersion?.id;
|
||||
return (
|
||||
<tr key={version.id} className={isCurrentVersion ? "current-version-row" : undefined}>
|
||||
<td>#{version.version_number}</td>
|
||||
<td><StatusBadge status={version.workflow_state ?? "editing"} /></td>
|
||||
<td>{versionLockLabel(version)}</td>
|
||||
<td>{validationLabel(version)}</td>
|
||||
<td>{buildLabel(version)}</td>
|
||||
<td>{formatDateTime(version.updated_at)}</td>
|
||||
<td className="table-action-cell">
|
||||
<div className="button-row compact-actions">
|
||||
<Link to={`review?version=${version.id}`}><Button variant="primary">Open</Button></Link>
|
||||
<Button
|
||||
onClick={() => setLockingVersion(version)}
|
||||
disabled={isUserLockedVersion(version) || isFinalLockedVersion(version) || canUnlockValidationVersion(version)}
|
||||
>Lock</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="metric-grid">
|
||||
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Built and ready or warning" />
|
||||
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review before sending" />
|
||||
<MetricCard label="Sent" value={cards?.sent ?? "—"} tone="info" detail="SMTP success" />
|
||||
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
||||
</div>
|
||||
|
||||
<Card title="Guided actions" actions={<span className="muted small-note">Wizards change or advance the campaign; data pages display and edit the current working draft.</span>}>
|
||||
<div className="wizard-action-grid">
|
||||
<WizardAction
|
||||
title={locked ? "Create a new working copy" : "Edit campaign structure"}
|
||||
description={locked ? "This version is locked. Copy the campaign before editing structural data." : "Open the structured create/edit wizard for overview, recipients, template and attachments."}
|
||||
to="wizard/create"
|
||||
label={locked ? "Open wizard read-only" : "Open Create Campaign"}
|
||||
/>
|
||||
<WizardAction
|
||||
title="Resolve review issues"
|
||||
description="Use a guided flow for validation issues, missing recipients or attachment decisions."
|
||||
to="wizard/review"
|
||||
label="Open Review Wizard"
|
||||
/>
|
||||
<WizardAction
|
||||
title="Prepare sending"
|
||||
description="Use the sending wizard for dry runs, rate limits, test sending and queue preparation."
|
||||
to="wizard/send"
|
||||
label="Open Send Wizard"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="overview-config-grid">
|
||||
<ConfigShortcutCard
|
||||
title="General"
|
||||
description="Name, sender and global recipients."
|
||||
facts={overviewFacts.campaignSettings}
|
||||
actions={[{ to: "data", label: "General" }]}
|
||||
/>
|
||||
<ConfigShortcutCard
|
||||
title="Global settings"
|
||||
description="Policies, opt-ins and delivery defaults."
|
||||
facts={overviewFacts.globalSettings}
|
||||
actions={[{ to: "global-settings", label: "Global settings" }]}
|
||||
/>
|
||||
<ConfigShortcutCard
|
||||
title="Fields"
|
||||
description="Field definitions and global values."
|
||||
facts={overviewFacts.fields}
|
||||
actions={[{ to: "fields", label: "Fields" }]}
|
||||
/>
|
||||
<ConfigShortcutCard
|
||||
title="Recipients"
|
||||
description="Recipient list and per-recipient values."
|
||||
facts={overviewFacts.recipients}
|
||||
actions={[{ to: "recipients", label: "Recipients" }]}
|
||||
/>
|
||||
<ConfigShortcutCard
|
||||
title="Template"
|
||||
description="Message content, preview and field usage."
|
||||
facts={overviewFacts.template}
|
||||
actions={[{ to: "template", label: "Template" }]}
|
||||
/>
|
||||
<ConfigShortcutCard
|
||||
title="Attachments"
|
||||
description="Global attachments and per-recipient rules."
|
||||
facts={overviewFacts.files}
|
||||
actions={[{ to: "files", label: "Attachments" }]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card title="Validation and build state">
|
||||
<div className="summary-grid overview-summary-grid">
|
||||
<SummaryTile label="Validation errors" value={summaryValue(currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
||||
<SummaryTile label="Warnings" value={summaryValue(currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
||||
<SummaryTile label="Built messages" value={summaryValue(currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
||||
<SummaryTile label="Jobs total" value={cards?.jobs_total ?? "—"} />
|
||||
</div>
|
||||
<p className="muted">Review and sending are displayed on their own data pages; use the guided buttons above to change state.</p>
|
||||
</Card>
|
||||
<Card title="Current version state">
|
||||
<div className="summary-grid overview-summary-grid">
|
||||
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
||||
<SummaryTile label="Warnings" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
||||
<SummaryTile label="Built messages" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
||||
<SummaryTile label="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} />
|
||||
</div>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(lockingVersion)}
|
||||
title="Lock audit-safe snapshot?"
|
||||
message="This is a user-requested final lock. The version cannot be validated, unlocked, built, dry-run or sent afterwards. Create an editable copy for future changes."
|
||||
confirmLabel="Lock snapshot"
|
||||
tone="danger"
|
||||
busy={lockBusy}
|
||||
onCancel={() => setLockingVersion(null)}
|
||||
onConfirm={() => void lockAuditSnapshot()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type OverviewFact = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
function versionLockLabel(version: CampaignVersionListItem): string {
|
||||
if (isUserLockedVersion(version)) return "User locked";
|
||||
if (isFinalLockedVersion(version)) return "Final";
|
||||
if (canUnlockValidationVersion(version)) return "Validation lock";
|
||||
if (version.locked_at) return "Locked";
|
||||
return "Editable";
|
||||
}
|
||||
|
||||
function ConfigShortcutCard({
|
||||
title,
|
||||
description,
|
||||
facts,
|
||||
actions
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
facts: OverviewFact[];
|
||||
actions: Array<{ to: string; label: string }>;
|
||||
}) {
|
||||
return (
|
||||
<section className="overview-config-card">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
<dl className="overview-config-facts">
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="overview-config-actions">
|
||||
{actions.map((action) => (
|
||||
<Link key={action.to} to={action.to}>
|
||||
<Button>{action.label}</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
function validationLabel(version: CampaignVersionListItem): string {
|
||||
const validation = version.validation_summary ?? {};
|
||||
if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed";
|
||||
if (validation.ok === false) return "Needs attention";
|
||||
if (validation.ok === true) return "Invalid for delivery";
|
||||
return "Not validated";
|
||||
}
|
||||
|
||||
function buildLabel(version: CampaignVersionListItem): string {
|
||||
const build = version.build_summary ?? {};
|
||||
return String(build.built_count ?? build.ready_count ?? "Not built");
|
||||
}
|
||||
|
||||
function SummaryTile({ label, value }: { label: string; value: string | number }) {
|
||||
@@ -246,98 +215,3 @@ function SummaryTile({ label, value }: { label: string; value: string | number }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardAction({ title, description, to, label }: { title: string; description: string; to: string; label: string }) {
|
||||
return (
|
||||
<section className="wizard-action-card">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
<Link to={to}><Button>{label}</Button></Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function getOverviewFacts(rawJson: Record<string, unknown>, campaign: { name?: string; external_id?: string; id?: string; status?: string } | null) {
|
||||
const campaignSection = asRecord(rawJson.campaign);
|
||||
const recipients = asRecord(rawJson.recipients);
|
||||
const attachments = asRecord(rawJson.attachments);
|
||||
const template = asRecord(rawJson.template);
|
||||
const entries = asRecord(rawJson.entries);
|
||||
const validationPolicy = asRecord(rawJson.validation_policy);
|
||||
const delivery = asRecord(rawJson.delivery);
|
||||
const fields = asArray(rawJson.fields).map(asRecord);
|
||||
const globalValues = asRecord(rawJson.global_values);
|
||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||
const entrySource = asRecord(entries.source);
|
||||
const globalAttachmentRules = asArray(attachments.global).map(asRecord);
|
||||
const individualAttachmentRules = inlineEntries.reduce((count, entry) => count + asArray(entry.attachments).length, 0);
|
||||
const globalRecipients = ["to", "cc", "bcc"].reduce((count, key) => count + addressesFromValue(recipients[key]).length, 0);
|
||||
|
||||
return {
|
||||
campaignSettings: [
|
||||
{ label: "Name", value: getString(campaignSection, "name", campaign?.name || "—") },
|
||||
{ label: "Campaign ID", value: getString(campaignSection, "id", campaign?.external_id || campaign?.id || "—") },
|
||||
{ label: "Sender", value: formatMailbox(recipients.from) }
|
||||
],
|
||||
globalSettings: [
|
||||
{ label: "Mode", value: getString(campaignSection, "mode", campaign?.status || "draft") },
|
||||
{ label: "Attachment policy", value: `${getString(attachments, "missing_behavior", "ask")} / ${getString(attachments, "ambiguous_behavior", "ask")}` },
|
||||
{ label: "Delivery", value: getString(delivery, "mode", getString(validationPolicy, "send_without_attachments", "standard")) }
|
||||
],
|
||||
fields: [
|
||||
{ label: "Fields", value: fields.length },
|
||||
{ label: "Global values", value: Object.keys(globalValues).length },
|
||||
{ label: "Required", value: fields.filter((field) => field.required === true).length }
|
||||
],
|
||||
recipients: [
|
||||
{ label: "Recipients", value: recipientSummary(inlineEntries, entrySource) },
|
||||
{ label: "Global recipients", value: globalRecipients },
|
||||
{ label: "Source", value: sourceSummary(entrySource) }
|
||||
],
|
||||
template: [
|
||||
{ label: "Subject", value: getString(template, "subject", "Not configured") },
|
||||
{ label: "Source", value: templateSourceSummary(template) },
|
||||
{ label: "Placeholders", value: countTemplatePlaceholders(template) }
|
||||
],
|
||||
files: [
|
||||
{ label: "Base path", value: getString(attachments, "base_path", ".") },
|
||||
{ label: "Global files", value: globalAttachmentRules.length },
|
||||
{ label: "Individual rules", value: individualAttachmentRules }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function formatMailbox(value: unknown): string {
|
||||
const [address] = addressesFromValue(value);
|
||||
if (!address) return "Not configured";
|
||||
return address.name ? `${address.name} <${address.email}>` : address.email;
|
||||
}
|
||||
|
||||
function recipientSummary(inlineEntries: Record<string, unknown>[], source: Record<string, unknown>): string {
|
||||
if (inlineEntries.length) return `${inlineEntries.length} inline`;
|
||||
if (Object.keys(source).length) return "External source";
|
||||
return "Not configured";
|
||||
}
|
||||
|
||||
function sourceSummary(source: Record<string, unknown>): string {
|
||||
if (!Object.keys(source).length) return "Inline / manual";
|
||||
return getString(source, "type", getString(source, "path", "External"));
|
||||
}
|
||||
|
||||
function templateSourceSummary(template: Record<string, unknown>): string {
|
||||
const libraryId = getString(template, "library_id", "");
|
||||
const templateId = getString(template, "template_id", "");
|
||||
const source = getString(template, "source", "");
|
||||
if (libraryId) return `Library: ${libraryId}`;
|
||||
if (templateId) return `Library: ${templateId}`;
|
||||
if (source) return source;
|
||||
return "Inline campaign template";
|
||||
}
|
||||
|
||||
function countTemplatePlaceholders(template: Record<string, unknown>): number {
|
||||
const text = `${getString(template, "subject", "")}
|
||||
${getString(template, "text", "")}
|
||||
${getString(template, "html", "")}`;
|
||||
const matches = text.match(/\{\{\s*[\w.-]+\s*\}\}/g) ?? [];
|
||||
return new Set(matches.map((item) => item.replace(/[{}\s]/g, ""))).size;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user