2026-09-11 02:46:56 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
APPLICATIONS AND OPPORTUNITIES - one call
|
|
|
|
|
----------------------------------------------------------------------------
|
|
|
|
|
/{locale}/applications-and-opportunities/{slug}
|
|
|
|
|
|
|
|
|
|
Draft section 8: the detail page carries the application form and the
|
|
|
|
|
acknowledgement ("Thank you for applying to join the SHARP network. Our team
|
|
|
|
|
will review your application and follow up within [X weeks].").
|
|
|
|
|
|
|
|
|
|
That "[X weeks]" is marked TBD in the draft and the CMS has no field for it,
|
|
|
|
|
so the number is NEVER named: the acknowledgement says the team will follow
|
|
|
|
|
up by email, and nothing more.
|
|
|
|
|
|
|
|
|
|
ROUTE
|
|
|
|
|
Registered in app/routes.ts as "applicationsDetail", a SIBLING of the listing
|
|
|
|
|
route under the ":lang" layout, and typed against its generated `./+types/`
|
|
|
|
|
module.
|
|
|
|
|
========================================================================= */
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
data,
|
|
|
|
|
Form,
|
|
|
|
|
useActionData,
|
|
|
|
|
useLoaderData,
|
|
|
|
|
useNavigation,
|
|
|
|
|
} from "react-router";
|
|
|
|
|
|
|
|
|
|
import { ArrowIcon, Button } from "~/components/ui/Button";
|
|
|
|
|
import { Input, Textarea } from "~/components/ui/Input";
|
|
|
|
|
import {
|
|
|
|
|
interpolate,
|
|
|
|
|
localizedPath,
|
|
|
|
|
sectionPath,
|
|
|
|
|
toIsoDate,
|
|
|
|
|
translate,
|
|
|
|
|
useTranslation,
|
|
|
|
|
} from "~/i18n";
|
|
|
|
|
import { safeHref } from "~/lib/safe-href";
|
|
|
|
|
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
|
|
|
|
import { isStrapiError, requireEntity } from "~/lib/strapi";
|
|
|
|
|
|
|
|
|
|
import type { Route } from "./+types/applications-and-opportunities.$slug";
|
|
|
|
|
import { getRouteCopy } from "./_events-opportunities/copy";
|
|
|
|
|
import {
|
|
|
|
|
FIELD_ERROR_KEYS,
|
|
|
|
|
HONEYPOT_FIELD,
|
|
|
|
|
isHoneypotFilled,
|
|
|
|
|
validateApplication,
|
|
|
|
|
type ApplicationValues,
|
|
|
|
|
type FieldErrors,
|
|
|
|
|
} from "./_events-opportunities/forms";
|
|
|
|
|
import {
|
|
|
|
|
formatAttachmentMeta,
|
|
|
|
|
formatOpportunityDate,
|
|
|
|
|
opportunityStatusKey,
|
|
|
|
|
} from "./_events-opportunities/opportunities";
|
|
|
|
|
import { fetchOpportunity } from "./_events-opportunities/opportunities.server";
|
|
|
|
|
import { readRootSeo } from "./_events-opportunities/root-seo";
|
|
|
|
|
import { submitApplication } from "./_events-opportunities/submissions.server";
|
|
|
|
|
import {
|
|
|
|
|
BadgeRow,
|
|
|
|
|
DetailList,
|
|
|
|
|
Notice,
|
|
|
|
|
StatusBadge,
|
|
|
|
|
type DetailItem,
|
|
|
|
|
} from "./_events-opportunities/ui";
|
|
|
|
|
import {
|
|
|
|
|
detailAlternateDescriptors,
|
|
|
|
|
SELF_ALTERNATES_HANDLE,
|
|
|
|
|
} from "./_shared/alternates";
|
|
|
|
|
import { requireLocale } from "./_shared/listing";
|
|
|
|
|
import { BackLink, PageShell } from "./_shared/listing-ui";
|
|
|
|
|
import { RichText } from "./_shared/RichText";
|
|
|
|
|
|
|
|
|
|
/** This route emits its own canonical + hreflang - see ./_shared/alternates.ts. */
|
|
|
|
|
export const handle = SELF_ALTERNATES_HANDLE;
|
|
|
|
|
|
|
|
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
|
|
|
|
const locale = requireLocale(params.lang);
|
|
|
|
|
const slug = params.slug ?? "";
|
|
|
|
|
|
|
|
|
|
const { opportunity, alternates } = requireEntity(
|
|
|
|
|
await fetchOpportunity(slug, locale),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { locale, opportunity, alternates };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
APPLICATION
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
type ActionResult =
|
|
|
|
|
| { readonly ok: true }
|
|
|
|
|
| {
|
|
|
|
|
readonly ok: false;
|
|
|
|
|
readonly errors: FieldErrors;
|
|
|
|
|
readonly values: ApplicationValues;
|
|
|
|
|
/** True when validation passed but the CMS refused the write. */
|
|
|
|
|
readonly failed: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export async function action({ params, request }: Route.ActionArgs) {
|
|
|
|
|
const locale = requireLocale(params.lang);
|
|
|
|
|
const slug = params.slug ?? "";
|
|
|
|
|
|
|
|
|
|
const { opportunity } = requireEntity(await fetchOpportunity(slug, locale));
|
|
|
|
|
const form = await request.formData();
|
|
|
|
|
|
|
|
|
|
/* A closed call, or one that is handled elsewhere, has no form on the page
|
|
|
|
|
and must not accept a POST either. */
|
|
|
|
|
if (opportunity.status !== "open" || opportunity.externalApplicationUrl) {
|
|
|
|
|
throw data("This call is not accepting applications", { status: 409 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isHoneypotFilled(form)) {
|
|
|
|
|
return { ok: true } satisfies ActionResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { values, errors, valid } = validateApplication(form);
|
|
|
|
|
|
|
|
|
|
if (!valid) {
|
|
|
|
|
return data(
|
|
|
|
|
{ ok: false, errors, values, failed: false } satisfies ActionResult,
|
|
|
|
|
{ status: 400 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-09-24 11:51:24 +02:00
|
|
|
await submitApplication({
|
|
|
|
|
values,
|
|
|
|
|
opportunityId: opportunity.documentId,
|
|
|
|
|
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
|
|
|
|
|
});
|
2026-09-11 02:46:56 +02:00
|
|
|
} catch (error) {
|
|
|
|
|
if (!isStrapiError(error)) throw error;
|
|
|
|
|
console.error(
|
|
|
|
|
`[opportunities] application for "${slug}" was rejected: ${error.status} ${error.message}`,
|
|
|
|
|
);
|
|
|
|
|
return data(
|
|
|
|
|
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult,
|
|
|
|
|
{ status: 502 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { ok: true } satisfies ActionResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
META
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
export function meta({ matches, loaderData }: Route.MetaArgs) {
|
|
|
|
|
const root = readRootSeo(matches);
|
|
|
|
|
const result = loaderData as Awaited<ReturnType<typeof loader>> | undefined;
|
|
|
|
|
const copy = getRouteCopy(root.locale);
|
|
|
|
|
|
|
|
|
|
if (!result) {
|
|
|
|
|
return buildMeta({
|
|
|
|
|
locale: root.locale,
|
|
|
|
|
pathname: root.pathname,
|
|
|
|
|
siteUrl: root.siteUrl,
|
|
|
|
|
title: copy.opportunities.heading,
|
|
|
|
|
description: copy.opportunities.metaDescription,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { opportunity } = result;
|
|
|
|
|
const deadline = opportunity.deadline
|
|
|
|
|
? translate(root.locale, "opportunities.deadline", {
|
|
|
|
|
date: formatOpportunityDate(opportunity.deadline, root.locale),
|
|
|
|
|
})
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
const descriptors = buildMeta({
|
|
|
|
|
locale: root.locale,
|
|
|
|
|
pathname: root.pathname,
|
|
|
|
|
siteUrl: root.siteUrl,
|
|
|
|
|
title: opportunity.title,
|
|
|
|
|
description:
|
|
|
|
|
opportunity.summary ?? deadline ?? copy.opportunities.metaDescription,
|
|
|
|
|
...(opportunity.image
|
|
|
|
|
? { image: { url: opportunity.image.src, alt: opportunity.image.alt } }
|
|
|
|
|
: {}),
|
|
|
|
|
// A closed call stays online for reference, but it should not keep
|
|
|
|
|
// attracting search traffic for an application nobody can make.
|
|
|
|
|
noIndex: opportunity.status === "closed",
|
|
|
|
|
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
|
|
|
|
|
{
|
|
|
|
|
name: translate(root.locale, "nav.sections.home"),
|
|
|
|
|
pathname: localizedPath("/", root.locale),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: translate(root.locale, "nav.sections.applications"),
|
|
|
|
|
pathname: sectionPath("applications", root.locale),
|
|
|
|
|
},
|
|
|
|
|
{ name: opportunity.title, pathname: root.pathname },
|
|
|
|
|
]),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
...descriptors,
|
|
|
|
|
...detailAlternateDescriptors({
|
|
|
|
|
siteUrl: root.siteUrl,
|
|
|
|
|
section: "applications",
|
|
|
|
|
locale: root.locale,
|
|
|
|
|
slugs: result.alternates,
|
|
|
|
|
}),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
PAGE
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
export default function OpportunityDetail() {
|
|
|
|
|
const { opportunity } = useLoaderData<typeof loader>();
|
|
|
|
|
const result = useActionData() as ActionResult | undefined;
|
|
|
|
|
const navigation = useNavigation();
|
|
|
|
|
const { locale, t } = useTranslation();
|
|
|
|
|
const copy = getRouteCopy(locale);
|
|
|
|
|
|
|
|
|
|
const listPath = sectionPath("applications", locale);
|
|
|
|
|
const detailPath = `${listPath}/${encodeURIComponent(opportunity.slug)}`;
|
|
|
|
|
const isSubmitting = navigation.state === "submitting";
|
|
|
|
|
const isOpen = opportunity.status === "open";
|
|
|
|
|
|
|
|
|
|
const details: DetailItem[] = [
|
|
|
|
|
{
|
|
|
|
|
label: copy.opportunityDetail.statusLabel,
|
|
|
|
|
value: t(opportunityStatusKey(opportunity.status)),
|
|
|
|
|
},
|
|
|
|
|
...(opportunity.opportunityType
|
|
|
|
|
? [
|
|
|
|
|
{
|
|
|
|
|
label: copy.opportunityDetail.typeLabel,
|
|
|
|
|
value: copy.opportunityTypes[opportunity.opportunityType],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
: []),
|
|
|
|
|
...(opportunity.openDate
|
|
|
|
|
? [
|
|
|
|
|
{
|
|
|
|
|
label: copy.opportunityDetail.opensLabel,
|
|
|
|
|
value: (
|
|
|
|
|
<time dateTime={toIsoDate(opportunity.openDate)}>
|
|
|
|
|
{formatOpportunityDate(opportunity.openDate, locale)}
|
|
|
|
|
</time>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
: []),
|
|
|
|
|
...(opportunity.deadline
|
|
|
|
|
? [
|
|
|
|
|
{
|
|
|
|
|
label: copy.opportunityDetail.deadlineLabel,
|
|
|
|
|
value: (
|
|
|
|
|
<time dateTime={toIsoDate(opportunity.deadline)}>
|
|
|
|
|
{formatOpportunityDate(opportunity.deadline, locale)}
|
|
|
|
|
</time>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
: []),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<PageShell>
|
|
|
|
|
<BackLink to={listPath} label={copy.opportunityDetail.backToList} />
|
|
|
|
|
|
|
|
|
|
<header className="flex max-w-[var(--layout-prose-max)] flex-col gap-md">
|
|
|
|
|
<BadgeRow>
|
|
|
|
|
<StatusBadge tone={isOpen ? "accent" : "neutral"}>
|
|
|
|
|
{t(opportunityStatusKey(opportunity.status))}
|
|
|
|
|
</StatusBadge>
|
|
|
|
|
{opportunity.opportunityType ? (
|
|
|
|
|
<StatusBadge>
|
|
|
|
|
{copy.opportunityTypes[opportunity.opportunityType]}
|
|
|
|
|
</StatusBadge>
|
|
|
|
|
) : null}
|
|
|
|
|
</BadgeRow>
|
|
|
|
|
|
|
|
|
|
<h1 className="display-sm text-text-primary">{opportunity.title}</h1>
|
|
|
|
|
|
|
|
|
|
{opportunity.summary ? (
|
|
|
|
|
<p className="body-lg text-text-secondary">{opportunity.summary}</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-2xl lg:flex-row lg:items-start">
|
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col gap-lg">
|
|
|
|
|
{opportunity.description ? (
|
|
|
|
|
<section
|
|
|
|
|
aria-labelledby="opportunity-about"
|
|
|
|
|
className="flex flex-col gap-md"
|
|
|
|
|
>
|
|
|
|
|
<h2
|
|
|
|
|
id="opportunity-about"
|
|
|
|
|
className="heading-h2 text-text-primary"
|
|
|
|
|
>
|
|
|
|
|
{copy.opportunityDetail.aboutHeading}
|
|
|
|
|
</h2>
|
|
|
|
|
<RichText value={opportunity.description} />
|
|
|
|
|
</section>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{opportunity.eligibility ? (
|
|
|
|
|
<section
|
|
|
|
|
aria-labelledby="opportunity-eligibility"
|
|
|
|
|
className="flex flex-col gap-md"
|
|
|
|
|
>
|
|
|
|
|
<h2
|
|
|
|
|
id="opportunity-eligibility"
|
|
|
|
|
className="heading-h3 text-text-primary"
|
|
|
|
|
>
|
|
|
|
|
{t("opportunities.eligibility")}
|
|
|
|
|
</h2>
|
|
|
|
|
<RichText value={opportunity.eligibility} />
|
|
|
|
|
</section>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{opportunity.howToApply ? (
|
|
|
|
|
<section
|
|
|
|
|
aria-labelledby="opportunity-how"
|
|
|
|
|
className="flex flex-col gap-md"
|
|
|
|
|
>
|
|
|
|
|
<h2 id="opportunity-how" className="heading-h3 text-text-primary">
|
|
|
|
|
{copy.opportunityDetail.howToApplyHeading}
|
|
|
|
|
</h2>
|
|
|
|
|
<RichText value={opportunity.howToApply} />
|
|
|
|
|
</section>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<ApplicationSection
|
|
|
|
|
detailPath={detailPath}
|
|
|
|
|
opportunity={opportunity}
|
|
|
|
|
result={result}
|
|
|
|
|
isSubmitting={isSubmitting}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<aside className="flex w-full flex-col gap-lg lg:w-[22rem] lg:shrink-0">
|
|
|
|
|
<DetailList
|
|
|
|
|
heading={copy.opportunityDetail.detailsHeading}
|
|
|
|
|
items={details}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{opportunity.attachments.length > 0 ? (
|
|
|
|
|
<section
|
|
|
|
|
aria-labelledby="opportunity-documents"
|
|
|
|
|
className="flex flex-col gap-md rounded-card border border-border-subtle bg-surface-raised p-lg"
|
|
|
|
|
>
|
|
|
|
|
<h2
|
|
|
|
|
id="opportunity-documents"
|
|
|
|
|
className="label-md uppercase text-text-tertiary"
|
|
|
|
|
>
|
|
|
|
|
{copy.opportunityDetail.attachmentsHeading}
|
|
|
|
|
</h2>
|
|
|
|
|
<ul className="flex flex-col gap-md">
|
|
|
|
|
{opportunity.attachments.map((file) => (
|
|
|
|
|
<li key={file.url} className="flex flex-col items-start gap-2xs">
|
|
|
|
|
{/* Media URLs come from the CMS too; an unusable one keeps
|
|
|
|
|
the file's name on the page as plain text. */}
|
|
|
|
|
{safeHref(file.url) ? (
|
|
|
|
|
<Button
|
|
|
|
|
as="a"
|
|
|
|
|
href={safeHref(file.url)}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
variant="secondary"
|
|
|
|
|
size="sm"
|
|
|
|
|
>
|
|
|
|
|
{file.name}
|
|
|
|
|
</Button>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="body-sm-bold text-text-primary">
|
|
|
|
|
{file.name}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<p className="body-sm text-text-tertiary">
|
|
|
|
|
{formatAttachmentMeta(file, locale)}
|
|
|
|
|
</p>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</section>
|
|
|
|
|
) : null}
|
|
|
|
|
</aside>
|
|
|
|
|
</div>
|
|
|
|
|
</PageShell>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
APPLICATION SECTION
|
|
|
|
|
Closed, not open yet, handled elsewhere, by email, or the form itself.
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
function ApplicationSection({
|
|
|
|
|
detailPath,
|
|
|
|
|
opportunity,
|
|
|
|
|
result,
|
|
|
|
|
isSubmitting,
|
|
|
|
|
}: {
|
|
|
|
|
detailPath: string;
|
|
|
|
|
opportunity: Awaited<ReturnType<typeof loader>>["opportunity"];
|
|
|
|
|
result: ActionResult | undefined;
|
|
|
|
|
isSubmitting: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const { locale, t } = useTranslation();
|
|
|
|
|
const copy = getRouteCopy(locale);
|
|
|
|
|
|
|
|
|
|
const errors = result && !result.ok ? result.errors : {};
|
|
|
|
|
const values = result && !result.ok ? result.values : undefined;
|
|
|
|
|
/* This form has no consent checkbox - only the event registration does -
|
|
|
|
|
so the "consent" code cannot occur here; it is narrowed away rather than
|
|
|
|
|
indexed into a table that has no entry for it. */
|
|
|
|
|
const fieldError = (name: string) => {
|
|
|
|
|
const code = errors[name];
|
|
|
|
|
if (!code || code === "consent") return undefined;
|
|
|
|
|
return t(FIELD_ERROR_KEYS[code]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (result?.ok) {
|
|
|
|
|
/* The draft's "[X weeks]" is TBD and the CMS models no response time, so
|
|
|
|
|
the acknowledgement never names one. */
|
|
|
|
|
return (
|
|
|
|
|
<Notice tone="accent" role="status" title={t("form.successTitle")}>
|
|
|
|
|
{copy.opportunityDetail.applicationReceivedBody}
|
|
|
|
|
</Notice>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (opportunity.status === "upcoming") {
|
|
|
|
|
return (
|
|
|
|
|
<Notice
|
|
|
|
|
tone="info"
|
|
|
|
|
title={
|
|
|
|
|
opportunity.openDate
|
|
|
|
|
? interpolate(copy.opportunityDetail.upcomingNote, {
|
|
|
|
|
date: formatOpportunityDate(opportunity.openDate, locale),
|
|
|
|
|
})
|
|
|
|
|
: t("filters.upcoming")
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (opportunity.status === "closed") {
|
|
|
|
|
return <Notice tone="info" title={copy.opportunityDetail.closedNote} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (opportunity.externalApplicationUrl) {
|
|
|
|
|
return (
|
|
|
|
|
<Notice tone="info" title={copy.opportunityDetail.externalApplication}>
|
|
|
|
|
{/* `safeHref` gates the CMS URL; without a usable one the notice still
|
|
|
|
|
tells the reader that applications are handled elsewhere. */}
|
|
|
|
|
{safeHref(opportunity.externalApplicationUrl) ? (
|
|
|
|
|
<Button
|
|
|
|
|
as="a"
|
|
|
|
|
href={safeHref(opportunity.externalApplicationUrl)}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
variant="primary"
|
|
|
|
|
iconEnd={<ArrowIcon />}
|
|
|
|
|
>
|
|
|
|
|
{t("actions.apply")}
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
</Notice>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section
|
|
|
|
|
id="apply"
|
|
|
|
|
aria-labelledby="apply-heading"
|
|
|
|
|
className="flex flex-col gap-lg rounded-card border border-border-subtle bg-surface-raised p-lg"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex flex-col gap-xs">
|
|
|
|
|
<h2 id="apply-heading" className="heading-h3 text-text-primary">
|
|
|
|
|
{copy.opportunityDetail.applyHeading}
|
|
|
|
|
</h2>
|
|
|
|
|
<p className="body-sm text-text-secondary">
|
|
|
|
|
{copy.opportunityDetail.formIntro}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{result && !result.ok && result.failed ? (
|
|
|
|
|
<Notice tone="danger" role="alert" title={t("form.errorGeneric")}>
|
|
|
|
|
{copy.form.submissionFailed}
|
|
|
|
|
</Notice>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<Form method="post" action={detailPath} className="flex flex-col gap-md">
|
|
|
|
|
<div className="grid gap-md sm:grid-cols-2">
|
|
|
|
|
<Input
|
|
|
|
|
name="fullName"
|
|
|
|
|
label={t("form.name")}
|
|
|
|
|
required
|
|
|
|
|
autoComplete="name"
|
|
|
|
|
defaultValue={values?.fullName}
|
|
|
|
|
error={fieldError("fullName")}
|
|
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
name="email"
|
|
|
|
|
type="email"
|
|
|
|
|
label={t("form.email")}
|
|
|
|
|
required
|
|
|
|
|
autoComplete="email"
|
|
|
|
|
inputMode="email"
|
|
|
|
|
defaultValue={values?.email}
|
|
|
|
|
error={fieldError("email")}
|
|
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
name="organisation"
|
|
|
|
|
label={t("form.organisation")}
|
|
|
|
|
hint={t("form.optional")}
|
|
|
|
|
autoComplete="organization"
|
|
|
|
|
defaultValue={values?.organisation}
|
|
|
|
|
className="sm:col-span-2"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Textarea
|
|
|
|
|
name="message"
|
|
|
|
|
label={t("form.message")}
|
|
|
|
|
hint={copy.opportunityDetail.messageHint}
|
|
|
|
|
required
|
|
|
|
|
rows={6}
|
|
|
|
|
defaultValue={values?.message}
|
|
|
|
|
error={fieldError("message")}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Honeypot. Hidden from sight and from assistive technology alike. */}
|
|
|
|
|
<div aria-hidden="true" className="hidden">
|
|
|
|
|
<label htmlFor={HONEYPOT_FIELD}>{copy.form.honeypotLabel}</label>
|
|
|
|
|
<input
|
|
|
|
|
id={HONEYPOT_FIELD}
|
|
|
|
|
name={HONEYPOT_FIELD}
|
|
|
|
|
type="text"
|
|
|
|
|
tabIndex={-1}
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-sm">
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
variant="primary"
|
|
|
|
|
isLoading={isSubmitting}
|
|
|
|
|
iconEnd={<ArrowIcon />}
|
|
|
|
|
>
|
|
|
|
|
{t("actions.apply")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</Form>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|