Improve shop hub: CurrencyIcon on packages/vouchers, DataTable for transactions.
Local Build and Deploy / deploy (push) Successful in 1m3s
Local Build and Deploy / deploy (push) Successful in 1m3s
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
a2ca9b3c23
commit
cbdea70f83
8 files changed
+657
-349
No files matched your search
@@ -1,30 +1,42 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { positiveBigInt } from "@/lib/api";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
actionError,
|
||||||
|
actionOk,
|
||||||
|
type ActionResult,
|
||||||
|
} from "@/lib/safe-action-shared";
|
||||||
import { logServerError } from "@/lib/server-log";
|
import { logServerError } from "@/lib/server-log";
|
||||||
|
|
||||||
export async function createVoucher(formData: FormData): Promise<void> {
|
export async function createVoucher(input: {
|
||||||
|
code: string;
|
||||||
|
amount: number;
|
||||||
|
maxUses: number;
|
||||||
|
expiresAt?: string;
|
||||||
|
}): Promise<ActionResult<{ id: string }>> {
|
||||||
await requirePermission(PERMS.SHOP_EDIT);
|
await requirePermission(PERMS.SHOP_EDIT);
|
||||||
|
|
||||||
const code = String(formData.get("code") ?? "")
|
const code = String(input.code ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255);
|
.slice(0, 255);
|
||||||
const amount = Number(formData.get("amount"));
|
const amount = Number(input.amount);
|
||||||
const maxUsesRaw = Number(formData.get("maxUses"));
|
const maxUsesRaw = Number(input.maxUses);
|
||||||
const maxUses =
|
const maxUses =
|
||||||
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
|
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
|
||||||
|
|
||||||
if (!code || !(amount > 0)) return;
|
if (!code || !(amount > 0)) {
|
||||||
|
return actionError("Code and a positive amount are required");
|
||||||
|
}
|
||||||
|
|
||||||
const expiresRaw = String(formData.get("expiresAt") ?? "")
|
let expiresAt: Date | null = null;
|
||||||
|
const expiresRaw = String(input.expiresAt ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
let expiresAt: Date | null = null;
|
|
||||||
if (expiresRaw) {
|
if (expiresRaw) {
|
||||||
const parsed = new Date(expiresRaw);
|
const parsed = new Date(expiresRaw);
|
||||||
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
|
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
|
||||||
@@ -33,7 +45,7 @@ export async function createVoucher(formData: FormData): Promise<void> {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteShopVouchers.create({
|
const created = await prisma.websiteShopVouchers.create({
|
||||||
data: {
|
data: {
|
||||||
code,
|
code,
|
||||||
amount: Math.floor(amount),
|
amount: Math.floor(amount),
|
||||||
@@ -44,29 +56,30 @@ export async function createVoucher(formData: FormData): Promise<void> {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
revalidatePath("/admin/vouchers");
|
||||||
|
return actionOk({ id: String(created.id) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logServerError("admin.voucher_create_failed", error);
|
logServerError("admin.voucher_create_failed", error);
|
||||||
// Unique constraint on `code` (or DB unavailable) — swallow and re-render.
|
return actionError("Could not create voucher (code may already exist)");
|
||||||
return;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/vouchers");
|
export async function deleteVoucher(input: {
|
||||||
}
|
id: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
export async function deleteVoucher(formData: FormData): Promise<void> {
|
|
||||||
await requirePermission(PERMS.SHOP_EDIT);
|
await requirePermission(PERMS.SHOP_EDIT);
|
||||||
|
|
||||||
const id = formPositiveBigInt(formData, "id");
|
const id = positiveBigInt(String(input.id ?? "").trim());
|
||||||
if (!id) return;
|
if (!id) return actionError("Missing voucher id");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.websiteShopVouchers.delete({ where: { id } });
|
await prisma.websiteShopVouchers.delete({ where: { id } });
|
||||||
|
revalidatePath("/admin/vouchers");
|
||||||
|
return actionOk();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logServerError("admin.voucher_delete_failed", error, {
|
logServerError("admin.voucher_delete_failed", error, {
|
||||||
voucherId: String(id),
|
voucherId: String(id),
|
||||||
});
|
});
|
||||||
return;
|
return actionError("Could not delete voucher");
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/vouchers");
|
|
||||||
}
|
}
|
||||||
+41
-14
File diff suppressed because it is too large.
Load diff
+126
-120
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { DataTable } from "@/components/admin/data-table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||||
|
|
||||||
|
export interface TransactionRow {
|
||||||
|
id: number;
|
||||||
|
payer: string;
|
||||||
|
payerId: number;
|
||||||
|
amount: string;
|
||||||
|
package: string;
|
||||||
|
status: string;
|
||||||
|
statusKind: "ok" | "danger" | "neutral";
|
||||||
|
transactionId: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusVariant(
|
||||||
|
kind: TransactionRow["statusKind"],
|
||||||
|
): "default" | "destructive" | "secondary" {
|
||||||
|
if (kind === "ok") return "default";
|
||||||
|
if (kind === "danger") return "destructive";
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransactionsTable({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: PaginatedResult<TransactionRow>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("pages.admin.transactions");
|
||||||
|
|
||||||
|
const columns: DataTableColumn<TransactionRow>[] = [
|
||||||
|
{ key: "id", label: t("colId"), sortable: true },
|
||||||
|
{
|
||||||
|
key: "payer",
|
||||||
|
label: t("colPayer"),
|
||||||
|
sortable: true,
|
||||||
|
render: (_value, row) => (
|
||||||
|
<Link
|
||||||
|
href={`/admin/users/${row.payerId}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{row.payer}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "amount", label: t("colAmount"), sortable: true },
|
||||||
|
{ key: "package", label: t("colPackage"), sortable: true },
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: t("colStatus"),
|
||||||
|
sortable: true,
|
||||||
|
filterKey: "filter_status",
|
||||||
|
filterOptions: [
|
||||||
|
{ label: t("statusCompleted"), value: "paid" },
|
||||||
|
{ label: t("statusPending"), value: "pending" },
|
||||||
|
{ label: t("statusFailed"), value: "failed" },
|
||||||
|
],
|
||||||
|
render: (_value, row) =>
|
||||||
|
row.status ? (
|
||||||
|
<Badge
|
||||||
|
variant={statusVariant(row.statusKind)}
|
||||||
|
className="font-mono text-[0.7rem]"
|
||||||
|
>
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "transactionId", label: t("colTransaction") },
|
||||||
|
{ key: "date", label: t("colDate"), sortable: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
data={data}
|
||||||
|
columns={columns}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
presets={[
|
||||||
|
{ label: t("presetPaid"), params: { filter_status: "paid" } },
|
||||||
|
{ label: t("presetFailed"), params: { filter_status: "failed" } },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+33
-195
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1229,6 +1229,9 @@
|
|||||||
"subtitle": "Manage packages and orders",
|
"subtitle": "Manage packages and orders",
|
||||||
"packages": "Packages",
|
"packages": "Packages",
|
||||||
"orders": "Orders",
|
"orders": "Orders",
|
||||||
|
"recentOrders": "Recent orders",
|
||||||
|
"paidOrders": "Paid orders",
|
||||||
|
"ordersHint": "Latest PayPal transactions",
|
||||||
"newPackage": "+ New package",
|
"newPackage": "+ New package",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
@@ -1239,6 +1242,11 @@
|
|||||||
"colId": "ID",
|
"colId": "ID",
|
||||||
"colName": "Name",
|
"colName": "Name",
|
||||||
"colPrice": "Price",
|
"colPrice": "Price",
|
||||||
|
"colCredits": "Credits",
|
||||||
|
"colDuckets": "Duckets",
|
||||||
|
"colDiamonds": "Diamonds",
|
||||||
|
"colRank": "Rank",
|
||||||
|
"colPosition": "Position",
|
||||||
"colCurrency": "Currency",
|
"colCurrency": "Currency",
|
||||||
"colType": "Type",
|
"colType": "Type",
|
||||||
"colSold": "Sold",
|
"colSold": "Sold",
|
||||||
@@ -1247,6 +1255,9 @@
|
|||||||
"colOrderId": "Order ID",
|
"colOrderId": "Order ID",
|
||||||
"colCustomer": "Customer",
|
"colCustomer": "Customer",
|
||||||
"colPackage": "Package",
|
"colPackage": "Package",
|
||||||
|
"colTransaction": "Transaction",
|
||||||
|
"colUser": "User",
|
||||||
|
"colDescription": "Description",
|
||||||
"colAmount": "Amount",
|
"colAmount": "Amount",
|
||||||
"colStatus": "Status",
|
"colStatus": "Status",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
@@ -1283,12 +1294,20 @@
|
|||||||
"title": "Transactions",
|
"title": "Transactions",
|
||||||
"subtitle": "PayPal payment log",
|
"subtitle": "PayPal payment log",
|
||||||
"noTransactions": "No transactions yet",
|
"noTransactions": "No transactions yet",
|
||||||
|
"totalTransactions": "Total transactions",
|
||||||
|
"pagePaid": "Paid (this page)",
|
||||||
|
"pageFailed": "Failed (this page)",
|
||||||
|
"searchPlaceholder": "Search by user, transaction ID or package…",
|
||||||
|
"presetPaid": "Paid",
|
||||||
|
"presetFailed": "Failed",
|
||||||
"colId": "ID",
|
"colId": "ID",
|
||||||
"colUser": "User",
|
"colUser": "User",
|
||||||
|
"colPayer": "Payer",
|
||||||
"colAmount": "Amount",
|
"colAmount": "Amount",
|
||||||
"colCurrency": "Currency",
|
"colCurrency": "Currency",
|
||||||
"colPackage": "Package",
|
"colPackage": "Package",
|
||||||
"colStatus": "Status",
|
"colStatus": "Status",
|
||||||
|
"colTransaction": "Transaction ID",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"colActions": "Actions",
|
"colActions": "Actions",
|
||||||
"statusCompleted": "Completed",
|
"statusCompleted": "Completed",
|
||||||
@@ -1307,16 +1326,30 @@
|
|||||||
"vouchers": {
|
"vouchers": {
|
||||||
"title": "Shop Vouchers",
|
"title": "Shop Vouchers",
|
||||||
"subtitle": "Manage redeemable voucher codes",
|
"subtitle": "Manage redeemable voucher codes",
|
||||||
|
"vouchers": "Vouchers",
|
||||||
|
"active": "Active",
|
||||||
|
"usedUp": "Used up",
|
||||||
|
"expired": "Expired",
|
||||||
"newVoucher": "+ New voucher",
|
"newVoucher": "+ New voucher",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"noVouchers": "No vouchers yet",
|
"noVouchers": "No vouchers yet",
|
||||||
|
"confirmDeleteTitle": "Delete voucher",
|
||||||
"confirmDelete": "Delete voucher \"{code}\"?",
|
"confirmDelete": "Delete voucher \"{code}\"?",
|
||||||
|
"created": "Voucher created",
|
||||||
|
"deleted": "Voucher deleted",
|
||||||
|
"createError": "Could not create voucher",
|
||||||
|
"deleteError": "Could not delete voucher",
|
||||||
|
"statusActive": "Active",
|
||||||
|
"statusUsedUp": "Used up",
|
||||||
|
"statusExpired": "Expired",
|
||||||
"colId": "ID",
|
"colId": "ID",
|
||||||
"colCode": "Code",
|
"colCode": "Code",
|
||||||
"colValue": "Value",
|
"colValue": "Value",
|
||||||
|
"colAmount": "Amount",
|
||||||
"colType": "Type",
|
"colType": "Type",
|
||||||
"colUses": "Uses / Max",
|
"colUses": "Uses / Max",
|
||||||
|
"colStatus": "Status",
|
||||||
"colExpires": "Expires",
|
"colExpires": "Expires",
|
||||||
"colCreated": "Created",
|
"colCreated": "Created",
|
||||||
"colActions": "Actions",
|
"colActions": "Actions",
|
||||||
@@ -1324,6 +1357,8 @@
|
|||||||
"code": "Code",
|
"code": "Code",
|
||||||
"codePlaceholder": "e.g. SUMMER2025",
|
"codePlaceholder": "e.g. SUMMER2025",
|
||||||
"value": "Value",
|
"value": "Value",
|
||||||
|
"amount": "Credits amount",
|
||||||
|
"amountPlaceholder": "e.g. 100",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"typeCredits": "Credits",
|
"typeCredits": "Credits",
|
||||||
"typeDuckets": "Duckets",
|
"typeDuckets": "Duckets",
|
||||||
@@ -1338,6 +1373,7 @@
|
|||||||
"maxUsesPlaceholder": "0 = unlimited",
|
"maxUsesPlaceholder": "0 = unlimited",
|
||||||
"expiresAt": "Expires at",
|
"expiresAt": "Expires at",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
|
"create": "Create voucher",
|
||||||
"save": "Save voucher",
|
"save": "Save voucher",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel"
|
||||||
|
|||||||
@@ -1193,6 +1193,9 @@
|
|||||||
"subtitle": "Gestisci pacchetti e ordini",
|
"subtitle": "Gestisci pacchetti e ordini",
|
||||||
"packages": "Pacchetti",
|
"packages": "Pacchetti",
|
||||||
"orders": "Ordini",
|
"orders": "Ordini",
|
||||||
|
"recentOrders": "Ordini recenti",
|
||||||
|
"paidOrders": "Ordini pagati",
|
||||||
|
"ordersHint": "Ultime transazioni PayPal",
|
||||||
"newPackage": "+ Nuovo pacchetto",
|
"newPackage": "+ Nuovo pacchetto",
|
||||||
"edit": "Modifica",
|
"edit": "Modifica",
|
||||||
"delete": "Elimina",
|
"delete": "Elimina",
|
||||||
@@ -1203,6 +1206,11 @@
|
|||||||
"colId": "ID",
|
"colId": "ID",
|
||||||
"colName": "Nome",
|
"colName": "Nome",
|
||||||
"colPrice": "Prezzo",
|
"colPrice": "Prezzo",
|
||||||
|
"colCredits": "Crediti",
|
||||||
|
"colDuckets": "Duckets",
|
||||||
|
"colDiamonds": "Diamanti",
|
||||||
|
"colRank": "Grado",
|
||||||
|
"colPosition": "Posizione",
|
||||||
"colCurrency": "Valuta",
|
"colCurrency": "Valuta",
|
||||||
"colType": "Tipo",
|
"colType": "Tipo",
|
||||||
"colSold": "Venduti",
|
"colSold": "Venduti",
|
||||||
@@ -1211,6 +1219,9 @@
|
|||||||
"colOrderId": "ID ordine",
|
"colOrderId": "ID ordine",
|
||||||
"colCustomer": "Cliente",
|
"colCustomer": "Cliente",
|
||||||
"colPackage": "Pacchetto",
|
"colPackage": "Pacchetto",
|
||||||
|
"colTransaction": "Transazione",
|
||||||
|
"colUser": "Utente",
|
||||||
|
"colDescription": "Descrizione",
|
||||||
"colAmount": "Importo",
|
"colAmount": "Importo",
|
||||||
"colStatus": "Stato",
|
"colStatus": "Stato",
|
||||||
"colDate": "Data",
|
"colDate": "Data",
|
||||||
@@ -1247,12 +1258,20 @@
|
|||||||
"title": "Transazioni",
|
"title": "Transazioni",
|
||||||
"subtitle": "Registro pagamenti PayPal",
|
"subtitle": "Registro pagamenti PayPal",
|
||||||
"noTransactions": "Ancora nessuna transazione",
|
"noTransactions": "Ancora nessuna transazione",
|
||||||
|
"totalTransactions": "Transazioni totali",
|
||||||
|
"pagePaid": "Pagate (questa pagina)",
|
||||||
|
"pageFailed": "Fallite (questa pagina)",
|
||||||
|
"searchPlaceholder": "Cerca per utente, ID transazione o pacchetto…",
|
||||||
|
"presetPaid": "Pagate",
|
||||||
|
"presetFailed": "Fallite",
|
||||||
"colId": "ID",
|
"colId": "ID",
|
||||||
"colUser": "Utente",
|
"colUser": "Utente",
|
||||||
|
"colPayer": "Pagatore",
|
||||||
"colAmount": "Importo",
|
"colAmount": "Importo",
|
||||||
"colCurrency": "Valuta",
|
"colCurrency": "Valuta",
|
||||||
"colPackage": "Pacchetto",
|
"colPackage": "Pacchetto",
|
||||||
"colStatus": "Stato",
|
"colStatus": "Stato",
|
||||||
|
"colTransaction": "ID transazione",
|
||||||
"colDate": "Data",
|
"colDate": "Data",
|
||||||
"colActions": "Azioni",
|
"colActions": "Azioni",
|
||||||
"statusCompleted": "Completato",
|
"statusCompleted": "Completato",
|
||||||
@@ -1271,16 +1290,30 @@
|
|||||||
"vouchers": {
|
"vouchers": {
|
||||||
"title": "Buoni negozio",
|
"title": "Buoni negozio",
|
||||||
"subtitle": "Gestisci codici buono riscattabili",
|
"subtitle": "Gestisci codici buono riscattabili",
|
||||||
|
"vouchers": "Buoni",
|
||||||
|
"active": "Attivi",
|
||||||
|
"usedUp": "Esauriti",
|
||||||
|
"expired": "Scaduti",
|
||||||
"newVoucher": "+ Nuovo buono",
|
"newVoucher": "+ Nuovo buono",
|
||||||
"edit": "Modifica",
|
"edit": "Modifica",
|
||||||
"delete": "Elimina",
|
"delete": "Elimina",
|
||||||
"noVouchers": "Ancora nessun buono",
|
"noVouchers": "Ancora nessun buono",
|
||||||
|
"confirmDeleteTitle": "Elimina buono",
|
||||||
"confirmDelete": "Eliminare il buono \"{code}\"?",
|
"confirmDelete": "Eliminare il buono \"{code}\"?",
|
||||||
|
"created": "Buono creato",
|
||||||
|
"deleted": "Buono eliminato",
|
||||||
|
"createError": "Impossibile creare il buono",
|
||||||
|
"deleteError": "Impossibile eliminare il buono",
|
||||||
|
"statusActive": "Attivo",
|
||||||
|
"statusUsedUp": "Esaurito",
|
||||||
|
"statusExpired": "Scaduto",
|
||||||
"colId": "ID",
|
"colId": "ID",
|
||||||
"colCode": "Codice",
|
"colCode": "Codice",
|
||||||
"colValue": "Valore",
|
"colValue": "Valore",
|
||||||
|
"colAmount": "Importo",
|
||||||
"colType": "Tipo",
|
"colType": "Tipo",
|
||||||
"colUses": "Usi / Max",
|
"colUses": "Usi / Max",
|
||||||
|
"colStatus": "Stato",
|
||||||
"colExpires": "Scade",
|
"colExpires": "Scade",
|
||||||
"colCreated": "Creato",
|
"colCreated": "Creato",
|
||||||
"colActions": "Azioni",
|
"colActions": "Azioni",
|
||||||
@@ -1288,6 +1321,8 @@
|
|||||||
"code": "Codice",
|
"code": "Codice",
|
||||||
"codePlaceholder": "es. ESTATE2025",
|
"codePlaceholder": "es. ESTATE2025",
|
||||||
"value": "Valore",
|
"value": "Valore",
|
||||||
|
"amount": "Crediti",
|
||||||
|
"amountPlaceholder": "es. 100",
|
||||||
"type": "Tipo",
|
"type": "Tipo",
|
||||||
"typeCredits": "Crediti",
|
"typeCredits": "Crediti",
|
||||||
"typeDuckets": "Duckets",
|
"typeDuckets": "Duckets",
|
||||||
@@ -1302,6 +1337,7 @@
|
|||||||
"maxUsesPlaceholder": "0 = illimitato",
|
"maxUsesPlaceholder": "0 = illimitato",
|
||||||
"expiresAt": "Scade il",
|
"expiresAt": "Scade il",
|
||||||
"enabled": "Attivo",
|
"enabled": "Attivo",
|
||||||
|
"create": "Crea buono",
|
||||||
"save": "Salva buono",
|
"save": "Salva buono",
|
||||||
"saving": "Salvataggio…",
|
"saving": "Salvataggio…",
|
||||||
"cancel": "Annulla"
|
"cancel": "Annulla"
|
||||||
|
|||||||
Reference in new issue
Block a user