Make checkout's login prompt reactive instead of persistent, and give the confirmation email real style and voice
The always-visible "Schon Kundin?" toggle was gendered and shown to every logged-out visitor regardless of relevance. Card 1's email field now checks on blur (/api/account/check-email) whether that address already has an account, and only then swaps in a gender-neutral login form, pre-filled — the collision check in handleSubmit stays as a fallback. The order-confirmation and password-reset emails also got a real visual pass: same warm background/brand color/circular success-icon treatment as the on-screen /bestellbestaetigung page, serif heading, thin brand divider, table-based layout for email-client compatibility. Copy is on-brand and a little playful now instead of generic transactional boilerplate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { checkEmailExists } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
// Called on blur from the checkout email field (CheckoutContent.tsx) —
|
||||
// lets the form switch to login mode as soon as an existing account is
|
||||
// detected, instead of only after a failed registration attempt. Same
|
||||
// exposure as the registration-collision case already had (both reveal
|
||||
// "this email has an account"), so rate-limited the same way rather than
|
||||
// treated as a new problem.
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`check-email:${getClientIp(request)}`, { limit: 20, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ exists: false }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const email = typeof body?.email === "string" ? body.email : "";
|
||||
if (!email) return NextResponse.json({ exists: false });
|
||||
|
||||
const exists = await checkEmailExists(email);
|
||||
return NextResponse.json({ exists });
|
||||
}
|
||||
@@ -119,6 +119,33 @@ export function CheckoutContent({
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
// Checks whether the email just typed into Card 1 already has an
|
||||
// account — proactively, on blur, rather than only finding out after a
|
||||
// registration attempt fails (see handleSubmit's own emailExists
|
||||
// handling, which still applies as a fallback if this check is skipped
|
||||
// or the account was created in the meantime). Silent when there's no
|
||||
// match — the account gate simply stays empty; nothing is shown "just
|
||||
// in case", only once actually relevant.
|
||||
async function handleEmailBlur(e: React.FocusEvent<HTMLInputElement>) {
|
||||
const email = e.target.value.trim();
|
||||
if (!email || customerEmail || showLogin) return;
|
||||
try {
|
||||
const res = await fetch("/api/account/check-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.exists) {
|
||||
setLoginEmail(email);
|
||||
setShowLogin(true);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — worst case, the registration attempt itself catches
|
||||
// the collision at submit time instead (handleSubmit).
|
||||
}
|
||||
}
|
||||
|
||||
// Always goes through /api/checkout now — that route re-prices
|
||||
// everything server-side, re-validates+redeems a discount code exactly
|
||||
// once (see its own comment), registers a new account inline when no
|
||||
@@ -242,12 +269,14 @@ export function CheckoutContent({
|
||||
<p className="text-body text-text-muted">Fast geschafft! Nur noch ein paar Angaben.</p>
|
||||
</div>
|
||||
|
||||
{/* Account gate — an account is required to buy, so this either
|
||||
confirms the active session or offers "already a customer?"
|
||||
login inline (registration itself happens as part of the main
|
||||
form submit below, via the password field in Card 1). Ref used
|
||||
to scroll this into view if a checkout submit turns out to
|
||||
collide with an existing account (see handleSubmit). */}
|
||||
{/* Account gate — an account is required to buy. Not a persistent
|
||||
"already a customer? log in" prompt anymore (removed —
|
||||
Nutzer-Entscheidung: don't show it unless actually relevant):
|
||||
stays empty by default, and only shows the login form once
|
||||
handleEmailBlur (Card 1's email field) or handleSubmit's own
|
||||
emailExists fallback actually detects that the typed email
|
||||
belongs to an existing account. Ref used to scroll this into
|
||||
view when that happens after a submit attempt specifically. */}
|
||||
<div ref={accountGateRef} className="w-full">
|
||||
{customerEmail ? (
|
||||
<p className="text-body-sm text-text-primary">
|
||||
@@ -256,42 +285,50 @@ export function CheckoutContent({
|
||||
Abmelden
|
||||
</button>
|
||||
</p>
|
||||
) : !showLogin ? (
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Schon Kundin?{" "}
|
||||
<button type="button" onClick={() => setShowLogin(true)} className="underline font-bold hover:text-brand transition-colors">
|
||||
Hier einloggen
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-end w-full sm:w-auto">
|
||||
<FormField
|
||||
label="E-Mail-Adresse"
|
||||
type="email"
|
||||
value={loginEmail}
|
||||
onChange={(e) => setLoginEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
wrapperClassName="w-full sm:w-56"
|
||||
/>
|
||||
<FormField
|
||||
label="Passwort"
|
||||
type="password"
|
||||
value={loginPassword}
|
||||
onChange={(e) => setLoginPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
wrapperClassName="w-full sm:w-56"
|
||||
/>
|
||||
) : showLogin ? (
|
||||
<div className="flex flex-col gap-3 items-start w-full">
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Für diese E-Mail-Adresse existiert bereits ein Konto — bitte einloggen, um fortzufahren.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-end w-full sm:w-auto">
|
||||
<FormField
|
||||
label="E-Mail-Adresse"
|
||||
type="email"
|
||||
value={loginEmail}
|
||||
onChange={(e) => setLoginEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
wrapperClassName="w-full sm:w-56"
|
||||
/>
|
||||
<FormField
|
||||
label="Passwort"
|
||||
type="password"
|
||||
value={loginPassword}
|
||||
onChange={(e) => setLoginPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
wrapperClassName="w-full sm:w-56"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogin}
|
||||
disabled={loggingIn}
|
||||
className={`px-5 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${loggingIn ? "opacity-70 pointer-events-none" : ""}`}
|
||||
>
|
||||
{loggingIn ? "…" : "Einloggen"}
|
||||
</button>
|
||||
</div>
|
||||
{loginError && <p className="text-label text-red-600 w-full">{loginError}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogin}
|
||||
disabled={loggingIn}
|
||||
className={`px-5 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${loggingIn ? "opacity-70 pointer-events-none" : ""}`}
|
||||
onClick={() => {
|
||||
setShowLogin(false);
|
||||
setLoginError(null);
|
||||
}}
|
||||
className="text-label text-text-muted underline hover:text-text-primary transition-colors"
|
||||
>
|
||||
{loggingIn ? "…" : "Einloggen"}
|
||||
Andere E-Mail-Adresse verwenden
|
||||
</button>
|
||||
{loginError && <p className="text-label text-red-600 w-full">{loginError}</p>}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
@@ -321,6 +358,7 @@ export function CheckoutContent({
|
||||
placeholder="max@beispiel.de"
|
||||
autoComplete="email"
|
||||
required
|
||||
onBlur={handleEmailBlur}
|
||||
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
|
||||
/>
|
||||
{/* Only needed for the inline-registration path — an existing
|
||||
|
||||
@@ -43,6 +43,23 @@ export type AuthResult =
|
||||
| { ok: true; token: string; customer: CustomerSummary }
|
||||
| { ok: false; reason: string; emailExists?: boolean };
|
||||
|
||||
// Called from app/api/account/check-email/route.ts — lets the checkout
|
||||
// form detect an existing account *before* a submit attempt fails (see
|
||||
// CheckoutContent.tsx's email field onBlur), not just after. Service-secret
|
||||
// authenticated (same reasoning as the other service calls in this file) —
|
||||
// Customers' read access isn't public, and there's no customer session yet
|
||||
// at this point either way.
|
||||
export async function checkEmailExists(email: string): Promise<boolean> {
|
||||
const params = new URLSearchParams({ "where[email][equals]": email, limit: "1" });
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/customers?${params}`, {
|
||||
headers: { "x-order-service-secret": SERVICE_SECRET },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data: { docs?: unknown[] } = await res.json();
|
||||
return (data.docs?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function registerCustomer(input: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
|
||||
+112
-35
@@ -13,6 +13,17 @@ import { formatPrice, formatDate } from "./format";
|
||||
// `style` attributes. Structure (this file) is fixed; only the wording
|
||||
// (heading/bodyText/footerText, edited in Payload's email-templates
|
||||
// collection) is admin-editable — see that collection's own comment for why.
|
||||
//
|
||||
// Visual language deliberately echoes /bestellbestaetigung (the on-screen
|
||||
// confirmation page, app/bestellbestaetigung/components/BestellbestaetigungContent.tsx)
|
||||
// rather than being a generic transactional-email template: same brand
|
||||
// color/warm cream background, the same circular success-icon treatment,
|
||||
// the same thin brand-colored divider under the headline, a serif display
|
||||
// heading. Not literally shared code (that component is React+Tailwind,
|
||||
// this is plain inline-styled HTML for email-client compatibility — no
|
||||
// external fonts/CSS, no flexbox reliance), just matched by eye so a
|
||||
// customer doesn't get a starkly different "brand voice" between the page
|
||||
// they just saw and the email that follows it.
|
||||
|
||||
export type EmailTemplateContent = {
|
||||
subject: string;
|
||||
@@ -21,14 +32,31 @@ export type EmailTemplateContent = {
|
||||
footerText: string | null;
|
||||
};
|
||||
|
||||
// Same hex values as app/globals.css's --color-* tokens — kept as literal
|
||||
// constants here rather than imported, since this file has no build-time
|
||||
// access to CSS custom properties (email clients wouldn't resolve `var()`
|
||||
// either, even if it did).
|
||||
const BRAND = "#f6a701";
|
||||
const BG_BASE = "#f8f5f1";
|
||||
const BG_MUTED = "#f3efe9";
|
||||
const TEXT_PRIMARY = "#1a1a18";
|
||||
const TEXT_MUTED = "#6b6b69";
|
||||
const BORDER = "#e5e0d8";
|
||||
const SUCCESS = "#2f8f4e";
|
||||
// Georgia, not a web font — most email clients strip @font-face/external
|
||||
// font requests, so this is the closest reliably-available serif to
|
||||
// Playfair Display/Lora's editorial feel rather than an attempt to load
|
||||
// the real thing.
|
||||
const FONT_SERIF = "Georgia,'Times New Roman',serif";
|
||||
const FONT_SANS = "-apple-system,Helvetica,Arial,sans-serif";
|
||||
|
||||
function paragraphs(text: string): string {
|
||||
function paragraphs(text: string, align: "center" | "left" = "left"): string {
|
||||
return text
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => `<p style="margin:0 0 16px;line-height:1.6;">${escapeHtml(p).replace(/\n/g, "<br/>")}</p>`)
|
||||
.map(
|
||||
(p) =>
|
||||
`<p style="margin:0 0 12px;line-height:1.6;font-size:15px;color:${TEXT_MUTED};text-align:${align};">${escapeHtml(p).replace(/\n/g, "<br/>")}</p>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
@@ -36,14 +64,56 @@ function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function emailShell(bodyHtml: string): string {
|
||||
return `<div style="max-width:600px;margin:0 auto;padding:32px 24px;font-family:Arial,Helvetica,sans-serif;color:${TEXT_PRIMARY};">
|
||||
<p style="margin:0 0 24px;font-weight:700;font-size:18px;">einfach produktiv.</p>
|
||||
${bodyHtml}
|
||||
<p style="margin-top:32px;padding-top:16px;border-top:1px solid ${BORDER};font-size:12px;color:#6b6b66;">
|
||||
einfach produktiv · admin@mk360.de
|
||||
</p>
|
||||
</div>`;
|
||||
// `icon`: a single glyph rendered inside the brand-tinted circle up top —
|
||||
// "✓" for order-confirmation, "✉" for password-reset. Same circular
|
||||
// treatment as the confirmation page's own success icon and its delivery-
|
||||
// status panel icon.
|
||||
function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerText: string | null): string {
|
||||
return `<body style="margin:0;padding:32px 16px;background:${BG_BASE};font-family:${FONT_SANS};">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;margin:0 auto;">
|
||||
<tr>
|
||||
<td style="padding-bottom:20px;text-align:center;">
|
||||
<span style="font-family:${FONT_SERIF};font-weight:700;font-size:15px;color:${TEXT_PRIMARY};letter-spacing:0.02em;">einfach produktiv.</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:#ffffff;border:1px solid ${BORDER};border-radius:12px;padding:40px 32px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align:center;padding-bottom:20px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto;">
|
||||
<tr>
|
||||
<td width="56" height="56" style="background:${BRAND}1a;border-radius:50%;text-align:center;vertical-align:middle;font-size:24px;color:${BRAND};">
|
||||
${icon}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align:center;padding-bottom:8px;">
|
||||
<span style="font-family:${FONT_SERIF};font-weight:700;font-size:26px;color:${TEXT_PRIMARY};">${headingHtml}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align:center;padding-bottom:20px;">
|
||||
<div style="width:32px;height:2px;background:${BRAND};margin:0 auto;"></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>${bodyHtml}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top:24px;text-align:center;">
|
||||
${footerText ? `<p style="margin:0 0 8px;font-size:13px;color:${TEXT_MUTED};">${escapeHtml(footerText)}</p>` : ""}
|
||||
<p style="margin:0;font-size:12px;color:${TEXT_MUTED};">einfach produktiv · admin@mk360.de</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>`;
|
||||
}
|
||||
|
||||
export type OrderConfirmationItem = { productName: string; quantity: number; unitPrice: number };
|
||||
@@ -76,43 +146,50 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
const rows = order.items
|
||||
.map(
|
||||
(item) => `<tr>
|
||||
<td style="padding:8px 0;border-bottom:1px solid ${BORDER};">${escapeHtml(item.productName)} × ${item.quantity}</td>
|
||||
<td style="padding:8px 0;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;">${formatPrice(item.quantity * item.unitPrice)}</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span></td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;font-size:14px;color:${TEXT_PRIMARY};">${formatPrice(item.quantity * item.unitPrice)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const summaryRow = (label: string, value: string) =>
|
||||
`<tr><td style="padding:4px 0;">${label}</td><td style="padding:4px 0;text-align:right;">${value}</td></tr>`;
|
||||
const summaryRow = (label: string, value: string, color = TEXT_PRIMARY) =>
|
||||
`<tr><td style="padding:4px 0;font-size:14px;color:${color};">${label}</td><td style="padding:4px 0;text-align:right;font-size:14px;color:${color};">${value}</td></tr>`;
|
||||
|
||||
return emailShell(`
|
||||
<h1 style="margin:0 0 16px;font-size:22px;">${escapeHtml(template.heading)}</h1>
|
||||
${paragraphs(template.bodyText)}
|
||||
<table style="width:100%;border-collapse:collapse;margin:24px 0;font-size:14px;">
|
||||
<tr><td colspan="2" style="padding-bottom:8px;font-size:12px;color:#6b6b66;">Bestellnummer ${escapeHtml(order.orderNumber)} · ${formatDate(order.createdAt)}</td></tr>
|
||||
const body = `
|
||||
${paragraphs(template.bodyText, "center")}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:16px;background:${BG_MUTED};border-radius:8px;padding:20px;">
|
||||
<tr><td colspan="2" style="padding-bottom:10px;font-size:12px;color:${TEXT_MUTED};">Bestellnummer <strong style="color:${TEXT_PRIMARY};">${escapeHtml(order.orderNumber)}</strong> · ${formatDate(order.createdAt)}</td></tr>
|
||||
${rows}
|
||||
</table>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:14px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:16px;">
|
||||
${summaryRow("Zwischensumme", formatPrice(order.subtotal))}
|
||||
${order.discountAmount > 0 ? summaryRow(`Rabattcode${order.discountCode ? ` (${escapeHtml(order.discountCode)})` : ""}`, `-${formatPrice(order.discountAmount)}`) : ""}
|
||||
${order.discountAmount > 0 ? summaryRow(`Rabattcode${order.discountCode ? ` (${escapeHtml(order.discountCode)})` : ""}`, `-${formatPrice(order.discountAmount)}`, SUCCESS) : ""}
|
||||
${summaryRow("Versand", order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost))}
|
||||
</table>
|
||||
<table style="width:100%;border-collapse:collapse;margin-top:8px;padding-top:8px;border-top:1px solid ${BORDER};font-size:16px;font-weight:700;">
|
||||
<tr><td>Gesamtsumme</td><td style="text-align:right;">${formatPrice(order.total)}</td></tr>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:12px;padding-top:12px;border-top:1px solid ${BORDER};">
|
||||
<tr>
|
||||
<td style="font-family:${FONT_SERIF};font-weight:700;font-size:17px;color:${TEXT_PRIMARY};">Gesamtsumme</td>
|
||||
<td style="text-align:right;font-weight:700;font-size:17px;color:${TEXT_PRIMARY};">${formatPrice(order.total)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
${template.footerText ? `<p style="margin-top:24px;font-size:14px;color:#6b6b66;">${escapeHtml(template.footerText)}</p>` : ""}
|
||||
`);
|
||||
`;
|
||||
|
||||
return emailShell("✓", escapeHtml(template.heading), body, template.footerText);
|
||||
}
|
||||
|
||||
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string): string {
|
||||
return emailShell(`
|
||||
<h1 style="margin:0 0 16px;font-size:22px;">${escapeHtml(template.heading)}</h1>
|
||||
${paragraphs(template.bodyText)}
|
||||
<p style="margin:24px 0;">
|
||||
<a href="${resetUrl}" style="display:inline-block;padding:12px 24px;background:${BRAND};color:${TEXT_PRIMARY};font-weight:700;text-decoration:none;border-radius:4px;">Neues Passwort vergeben</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#6b6b66;">Falls der Button nicht funktioniert: ${resetUrl}</p>
|
||||
<p style="font-size:13px;color:#6b6b66;">Der Link ist 1 Stunde gültig.</p>
|
||||
${template.footerText ? `<p style="margin-top:24px;font-size:14px;color:#6b6b66;">${escapeHtml(template.footerText)}</p>` : ""}
|
||||
`);
|
||||
const body = `
|
||||
${paragraphs(template.bodyText, "center")}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
|
||||
<tr>
|
||||
<td style="background:${BRAND};border-radius:6px;">
|
||||
<a href="${resetUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">Neues Passwort vergeben</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};word-break:break-all;">${resetUrl}</p>
|
||||
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};">Der Link ist 1 Stunde gültig.</p>
|
||||
`;
|
||||
|
||||
return emailShell("✉", escapeHtml(template.heading), body, template.footerText);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ import { renderOrderConfirmationHtml, type OrderConfirmationData } from "./email
|
||||
// lives, seeding it is a one-time setup step, not a runtime dependency).
|
||||
export async function sendOrderConfirmationEmail(order: OrderConfirmationData, customerEmail: string): Promise<boolean> {
|
||||
const template = (await getEmailTemplate("order-confirmation")) ?? {
|
||||
subject: "Deine Bestellung bei einfach produktiv",
|
||||
heading: "Vielen Dank für deine Bestellung!",
|
||||
bodyText: "Wir haben deine Bestellung erhalten und bereiten sie für den Versand vor.",
|
||||
subject: "Bestellt! Deine Ruhe kann kommen 🎉",
|
||||
heading: "Geschafft!",
|
||||
bodyText: "Deine Bestellung ist bei uns eingetrudelt — wir kümmern uns schon liebevoll darum, sie für dich zu packen.",
|
||||
footerText: null,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user