initial commit
This commit is contained in:
@@ -0,0 +1,601 @@
|
||||
/* ============================================================================
|
||||
CONTACT - /en/contact and /ar/contact
|
||||
----------------------------------------------------------------------------
|
||||
Section 9 of docs/SHARPER_Website_Draft_Section_EN_AR.docx.
|
||||
|
||||
The form is a real one: it posts to this route's `action`, which validates
|
||||
every field on the SERVER (so validation cannot be skipped by disabling
|
||||
JavaScript or by posting directly) and writes an
|
||||
api::contact-submission.contact-submission entry in Strapi.
|
||||
|
||||
Client-side JS is an enhancement only - the page works as a plain HTML form
|
||||
post, and `useNavigation()` merely swaps the button into its loading state.
|
||||
|
||||
Every message the reader can see is localised: field labels and the generic
|
||||
errors come from the i18n catalogue, the page prose from the approved draft.
|
||||
========================================================================= */
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { data, Form, Link, useLocation, useNavigation } from "react-router";
|
||||
|
||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { Input, Textarea } from "~/components/ui/Input";
|
||||
import { createTranslator, sectionPath, type Locale } from "~/i18n";
|
||||
import { getServerEnv } from "~/lib/env.server";
|
||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||
|
||||
import type { Route } from "./+types/contact";
|
||||
import type { CmsContactPage } from "./_page-kit/cms-types";
|
||||
import { list, loadSingle, toImage } from "./_page-kit/cms.server";
|
||||
import { pageCopy } from "./_page-kit/copy";
|
||||
import { routeLocale } from "./_page-kit/locale";
|
||||
import { RichText } from "./_page-kit/rich-text";
|
||||
import {
|
||||
EmptyState,
|
||||
InfoPanel,
|
||||
PageHero,
|
||||
PageSection,
|
||||
Prose,
|
||||
SectionHeader,
|
||||
} from "./_page-kit/sections";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
LOADER
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const locale = routeLocale(params);
|
||||
|
||||
const page = await loadSingle<CmsContactPage>("contact-page", {
|
||||
locale,
|
||||
populate: {
|
||||
hero: { populate: { media: true, ctas: true } },
|
||||
socialLinks: true,
|
||||
consortiumContacts: true,
|
||||
seo: { populate: { ogImage: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
locale,
|
||||
hero: {
|
||||
eyebrow: page?.hero?.eyebrow ?? null,
|
||||
headline: page?.hero?.headline ?? null,
|
||||
subheading: page?.hero?.subheading ?? null,
|
||||
media: toImage(page?.hero?.media, "large") ?? null,
|
||||
},
|
||||
intro: page?.intro ?? null,
|
||||
generalEmail: page?.generalEmail ?? null,
|
||||
websiteLabel: page?.websiteLabel ?? null,
|
||||
socialLinks: list(page?.socialLinks).map((link) => ({
|
||||
label: link.label,
|
||||
href: link.href,
|
||||
isExternal: link.isExternal ?? true,
|
||||
})),
|
||||
consortiumContacts: list(page?.consortiumContacts).map((address) => ({
|
||||
id: String(address.id ?? address.organisation),
|
||||
organisation: address.organisation,
|
||||
addressLine: address.addressLine ?? null,
|
||||
city: address.city ?? null,
|
||||
country: address.country ?? null,
|
||||
email: address.email ?? null,
|
||||
phone: address.phone ?? null,
|
||||
})),
|
||||
seo: {
|
||||
title: page?.seo?.metaTitle ?? null,
|
||||
description: page?.seo?.metaDescription ?? null,
|
||||
image: toImage(page?.seo?.ogImage, "large") ?? null,
|
||||
noIndex: page?.seo?.noIndex ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function meta({ loaderData, matches }: Route.MetaArgs) {
|
||||
const root = matches[0].loaderData;
|
||||
const locale = loaderData?.locale ?? root.locale;
|
||||
const t = createTranslator(locale);
|
||||
const copy = pageCopy(locale).contact;
|
||||
|
||||
return buildMeta({
|
||||
locale,
|
||||
pathname: root.pathname,
|
||||
siteUrl: root.siteUrl,
|
||||
title: loaderData?.seo.title ?? t("nav.sections.contact"),
|
||||
description: loaderData?.seo.description ?? copy.lead,
|
||||
...(loaderData?.seo.image
|
||||
? { image: { url: loaderData.seo.image.src, alt: loaderData.seo.image.alt } }
|
||||
: {}),
|
||||
noIndex: loaderData?.seo.noIndex ?? false,
|
||||
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
|
||||
{ name: t("nav.sections.home"), pathname: sectionPath("home", locale) },
|
||||
{
|
||||
name: t("nav.sections.contact"),
|
||||
pathname: sectionPath("contact", locale),
|
||||
},
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
FORM CONTRACT
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
const FIELD_NAMES = ["name", "organisation", "email", "subject", "message"] as const;
|
||||
type ContactField = (typeof FIELD_NAMES)[number];
|
||||
|
||||
type ContactValues = Record<ContactField, string>;
|
||||
type ContactErrors = Partial<Record<ContactField, string>>;
|
||||
|
||||
/**
|
||||
* A field bots fill in and people never see. Named like a real field so a
|
||||
* naive spam bot completes it; a submission that carries a value is accepted
|
||||
* with a success response and quietly dropped, which tells the bot nothing.
|
||||
*/
|
||||
const HONEYPOT_FIELD = "website";
|
||||
|
||||
/**
|
||||
* The `id` handed to each control. It has to be fixed rather than the `useId()`
|
||||
* one `Input` generates, because the error summary links at it with a fragment.
|
||||
*/
|
||||
function fieldDomId(field: ContactField): string {
|
||||
return `contact-${field}`;
|
||||
}
|
||||
|
||||
/** Mirrors the maxLength constraints on the Strapi content type. */
|
||||
const MAX_LENGTH: Record<ContactField, number> = {
|
||||
name: 200,
|
||||
organisation: 200,
|
||||
email: 254,
|
||||
subject: 250,
|
||||
message: 5000,
|
||||
};
|
||||
|
||||
/** Deliberately permissive - RFC-valid addresses vary far more than any regex. */
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||
|
||||
const EMPTY_VALUES: ContactValues = {
|
||||
name: "",
|
||||
organisation: "",
|
||||
email: "",
|
||||
subject: "",
|
||||
message: "",
|
||||
};
|
||||
|
||||
function readValues(form: FormData): ContactValues {
|
||||
const values = { ...EMPTY_VALUES };
|
||||
|
||||
for (const field of FIELD_NAMES) {
|
||||
const raw = form.get(field);
|
||||
values[field] = typeof raw === "string" ? raw.trim() : "";
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function validate(values: ContactValues, locale: Locale): ContactErrors {
|
||||
const t = createTranslator(locale);
|
||||
const copy = pageCopy(locale).contact;
|
||||
const errors: ContactErrors = {};
|
||||
|
||||
const required: ContactField[] = ["name", "email", "subject", "message"];
|
||||
for (const field of required) {
|
||||
if (values[field] === "") errors[field] = t("form.errorRequired");
|
||||
}
|
||||
|
||||
if (!errors.email && !EMAIL_PATTERN.test(values.email)) {
|
||||
errors.email = t("form.errorEmail");
|
||||
}
|
||||
|
||||
for (const field of FIELD_NAMES) {
|
||||
if (!errors[field] && values[field].length > MAX_LENGTH[field]) {
|
||||
errors[field] = copy.errorTooLong;
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the message to Strapi. Server-only: `getServerEnv` lives in a
|
||||
* `.server` module, so the token cannot reach the browser bundle.
|
||||
*
|
||||
* THE PAYLOAD IS THE WHITELIST, NOTHING MORE
|
||||
* `contact-submission` has a `sanitize-submission` middleware
|
||||
* (apps/cms/src/api/contact-submission/middlewares/sanitize-submission.ts)
|
||||
* that copies out exactly `name · organisation · email · subject · message`
|
||||
* and drops the rest, then stamps `submissionStatus` and `submittedAt`
|
||||
* itself. Sending those two from here is not merely redundant - it reads as
|
||||
* though the browser decides triage state, and the value would be silently
|
||||
* discarded anyway - so the body below is the whitelist and only it.
|
||||
*/
|
||||
async function createContactSubmission(values: ContactValues): Promise<void> {
|
||||
const env = getServerEnv();
|
||||
const url = `${env.STRAPI_URL.replace(/\/+$/, "")}/api/contact-submissions`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
...(env.STRAPI_TOKEN ? { authorization: `Bearer ${env.STRAPI_TOKEN}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
name: values.name,
|
||||
// An empty optional field is stored as null, not as "".
|
||||
organisation: values.organisation === "" ? null : values.organisation,
|
||||
email: values.email,
|
||||
subject: values.subject,
|
||||
message: values.message,
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Strapi responded ${response.status} ${response.statusText} for ${url}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ params, request }: Route.ActionArgs) {
|
||||
const locale = routeLocale(params);
|
||||
const form = await request.formData();
|
||||
|
||||
// Honeypot: answer exactly like a success, store nothing.
|
||||
const trap = form.get(HONEYPOT_FIELD);
|
||||
if (typeof trap === "string" && trap.trim() !== "") {
|
||||
return data({ status: "success" as const, errors: {} as ContactErrors, values: EMPTY_VALUES });
|
||||
}
|
||||
|
||||
const values = readValues(form);
|
||||
const errors = validate(values, locale);
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return data(
|
||||
{ status: "invalid" as const, errors, values },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await createContactSubmission(values);
|
||||
} catch (error) {
|
||||
console.error("[contact] submission failed", error);
|
||||
const t = createTranslator(locale);
|
||||
|
||||
return data(
|
||||
{
|
||||
status: "failed" as const,
|
||||
errors: {} as ContactErrors,
|
||||
values,
|
||||
formError: t("form.errorGeneric"),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return data({ status: "success" as const, errors: {} as ContactErrors, values: EMPTY_VALUES });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
PAGE
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export default function Contact({
|
||||
loaderData,
|
||||
actionData,
|
||||
}: Route.ComponentProps) {
|
||||
const { locale, hero, intro, generalEmail, socialLinks, consortiumContacts } =
|
||||
loaderData;
|
||||
const t = createTranslator(locale);
|
||||
const copy = pageCopy(locale).contact;
|
||||
|
||||
const navigation = useNavigation();
|
||||
const { pathname } = useLocation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
const errors = actionData?.errors ?? {};
|
||||
const values = actionData?.values ?? EMPTY_VALUES;
|
||||
const formError =
|
||||
actionData && "formError" in actionData ? actionData.formError : undefined;
|
||||
const isSuccess = actionData?.status === "success";
|
||||
const errorMessages = Object.values(errors).filter(
|
||||
(message): message is string => typeof message === "string",
|
||||
);
|
||||
|
||||
/* A failed submit re-renders the page with the summary at the top of the
|
||||
form. Screen-reader users are left wherever they were, and keyboard users
|
||||
below the fold, so focus is moved onto the summary itself - it is
|
||||
tabIndex={-1} for exactly this. Keyed on `actionData`, which is a fresh
|
||||
object per submission, so a repeated failure focuses again. */
|
||||
const errorSummaryRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionData || actionData.status === "success") return;
|
||||
errorSummaryRef.current?.focus();
|
||||
}, [actionData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero
|
||||
title={hero.headline ?? copy.heroHeadline}
|
||||
eyebrow={hero.eyebrow ?? t("nav.sections.engagementAndOpportunities")}
|
||||
lead={hero.subheading ?? copy.lead}
|
||||
{...(hero.media ? { media: hero.media } : {})}
|
||||
/>
|
||||
|
||||
<PageSection className="pt-0 md:pt-0">
|
||||
<div className="grid grid-cols-1 gap-2xl lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
|
||||
{/* ---------------------------------------------------------------
|
||||
THE FORM
|
||||
--------------------------------------------------------------- */}
|
||||
<div className="flex flex-col gap-lg">
|
||||
<SectionHeader
|
||||
id="contact-form-heading"
|
||||
title={copy.formHeading}
|
||||
lead={copy.formIntro}
|
||||
/>
|
||||
|
||||
{isSuccess ? (
|
||||
<div
|
||||
className="flex flex-col items-start gap-md rounded-card border border-[var(--border-accent)] bg-[var(--surface-accent-subtle)] p-lg"
|
||||
role="status"
|
||||
>
|
||||
<p className="body-lg-bold text-[var(--text-primary)]">
|
||||
{t("form.successTitle")}
|
||||
</p>
|
||||
<p className="body-md text-[var(--text-secondary)]">
|
||||
{t("form.successBody")}
|
||||
</p>
|
||||
<Button as={Link} to={pathname} variant="secondary">
|
||||
{copy.sendAnother}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Form
|
||||
method="post"
|
||||
aria-labelledby="contact-form-heading"
|
||||
className="flex flex-col gap-lg"
|
||||
noValidate
|
||||
>
|
||||
{formError || errorMessages.length > 0 ? (
|
||||
<div
|
||||
ref={errorSummaryRef}
|
||||
tabIndex={-1}
|
||||
role="alert"
|
||||
className="flex flex-col gap-xs rounded-card border border-[var(--border-danger)] bg-[var(--surface-danger-subtle)] p-md"
|
||||
>
|
||||
<p className="body-md-bold text-[var(--text-danger)]">
|
||||
{formError ?? copy.errorSummaryTitle}
|
||||
</p>
|
||||
{errorMessages.length > 0 ? (
|
||||
<ul className="flex list-disc flex-col gap-2xs ps-lg body-sm text-[var(--text-secondary)]">
|
||||
{/* Listed in form order, not object order, and each one
|
||||
is a link at the control it is about. The label is
|
||||
repeated so the link text still identifies the field
|
||||
when the messages are read out of context. */}
|
||||
{FIELD_NAMES.filter((field) => errors[field]).map((field) => (
|
||||
<li key={field}>
|
||||
<a
|
||||
href={`#${fieldDomId(field)}`}
|
||||
className="underline underline-offset-2 hover:text-[var(--text-primary)]"
|
||||
>
|
||||
{t(`form.${field}`)}: {errors[field]}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Honeypot - off-screen, out of the tab order and out of the
|
||||
accessibility tree, so only an automated client fills it. */}
|
||||
<div aria-hidden="true" className="sr-only">
|
||||
<label htmlFor="contact-website">{copy.honeypotLabel}</label>
|
||||
<input
|
||||
id="contact-website"
|
||||
type="text"
|
||||
name={HONEYPOT_FIELD}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
defaultValue=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-lg md:grid-cols-2">
|
||||
<Input
|
||||
name="name"
|
||||
id={fieldDomId("name")}
|
||||
label={t("form.name")}
|
||||
required
|
||||
autoComplete="name"
|
||||
maxLength={MAX_LENGTH.name}
|
||||
defaultValue={values.name}
|
||||
{...(errors.name ? { error: errors.name } : {})}
|
||||
/>
|
||||
<Input
|
||||
name="organisation"
|
||||
id={fieldDomId("organisation")}
|
||||
label={t("form.organisation")}
|
||||
hint={copy.organisationHint}
|
||||
autoComplete="organization"
|
||||
maxLength={MAX_LENGTH.organisation}
|
||||
defaultValue={values.organisation}
|
||||
{...(errors.organisation ? { error: errors.organisation } : {})}
|
||||
/>
|
||||
<Input
|
||||
name="email"
|
||||
id={fieldDomId("email")}
|
||||
type="email"
|
||||
label={t("form.email")}
|
||||
required
|
||||
autoComplete="email"
|
||||
maxLength={MAX_LENGTH.email}
|
||||
defaultValue={values.email}
|
||||
{...(errors.email ? { error: errors.email } : {})}
|
||||
/>
|
||||
<Input
|
||||
name="subject"
|
||||
id={fieldDomId("subject")}
|
||||
label={t("form.subject")}
|
||||
required
|
||||
maxLength={MAX_LENGTH.subject}
|
||||
defaultValue={values.subject}
|
||||
{...(errors.subject ? { error: errors.subject } : {})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
name="message"
|
||||
id={fieldDomId("message")}
|
||||
label={t("form.message")}
|
||||
required
|
||||
rows={6}
|
||||
maxLength={MAX_LENGTH.message}
|
||||
defaultValue={values.message}
|
||||
{...(errors.message ? { error: errors.message } : {})}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
variant="primary"
|
||||
isLoading={isSubmitting}
|
||||
iconEnd={<ArrowIcon />}
|
||||
className="self-start"
|
||||
>
|
||||
{isSubmitting ? t("form.sending") : t("form.send")}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---------------------------------------------------------------
|
||||
CONTACT DETAILS
|
||||
--------------------------------------------------------------- */}
|
||||
<div className="flex flex-col gap-lg">
|
||||
<SectionHeader id="contact-details-heading" title={copy.detailsHeading} />
|
||||
|
||||
{intro ? (
|
||||
<Prose>
|
||||
<RichText markdown={intro} />
|
||||
</Prose>
|
||||
) : null}
|
||||
|
||||
<dl className="flex flex-col gap-md rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg">
|
||||
<div className="flex flex-col gap-2xs">
|
||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{copy.generalEnquiries}
|
||||
</dt>
|
||||
<dd className="body-md text-[var(--text-secondary)]">
|
||||
{generalEmail ? (
|
||||
<a
|
||||
href={`mailto:${generalEmail}`}
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
{generalEmail}
|
||||
</a>
|
||||
) : (
|
||||
copy.contactPending
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2xs">
|
||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{copy.websiteLabel}
|
||||
</dt>
|
||||
<dd className="body-md">
|
||||
<a
|
||||
href={copy.websiteHref}
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
{loaderData.websiteLabel ?? copy.websiteText}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{socialLinks.length > 0 ? (
|
||||
<div className="flex flex-col gap-2xs">
|
||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{t("site.followUs")}
|
||||
</dt>
|
||||
<dd>
|
||||
<ul className="flex flex-wrap gap-md body-md">
|
||||
{socialLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<a
|
||||
href={link.href}
|
||||
{...(link.isExternal
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* CONSORTIUM CONTACTS - "TBD" in the draft, so the CMS owns them. */}
|
||||
<PageSection aria-labelledby="contact-consortium-heading">
|
||||
<div className="flex flex-col gap-lg">
|
||||
<SectionHeader
|
||||
id="contact-consortium-heading"
|
||||
title={copy.consortiumHeading}
|
||||
/>
|
||||
|
||||
{consortiumContacts.length > 0 ? (
|
||||
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
||||
{consortiumContacts.map((contact) => (
|
||||
<li key={contact.id}>
|
||||
<InfoPanel title={contact.organisation}>
|
||||
<address className="flex flex-col gap-2xs not-italic">
|
||||
{contact.addressLine ? <span>{contact.addressLine}</span> : null}
|
||||
{contact.city || contact.country ? (
|
||||
<span>
|
||||
{[contact.city, contact.country]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</span>
|
||||
) : null}
|
||||
{contact.email ? (
|
||||
<a
|
||||
href={`mailto:${contact.email}`}
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
{contact.email}
|
||||
</a>
|
||||
) : null}
|
||||
{contact.phone ? (
|
||||
<a
|
||||
href={`tel:${contact.phone.replace(/\s+/g, "")}`}
|
||||
dir="ltr"
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
{contact.phone}
|
||||
</a>
|
||||
) : null}
|
||||
</address>
|
||||
</InfoPanel>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState message={copy.consortiumEmpty} />
|
||||
)}
|
||||
</div>
|
||||
</PageSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user