Add the Workshops page and keep each sign-up on the site.
The form is built once to the settled field set, and a valid send is saved before anyone is emailed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
974a5ace4c
commit
c0ba3a3798
50 files changed
+2773
-1
No files matched your search
@@ -71,6 +71,19 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
|
||||
showDecor: true,
|
||||
},
|
||||
citation: {
|
||||
container:
|
||||
"bg-black px-[var(--space-400)] py-[var(--space-1200)] md:px-[var(--space-1200)] lg:px-[var(--space-2400)] lg:py-[120px]",
|
||||
card: "",
|
||||
gap: "",
|
||||
avatarGap: "",
|
||||
avatar: "",
|
||||
quote: "text-[28px] leading-[34px] md:text-[40px] md:leading-[48px]",
|
||||
author: "text-[12px] leading-[16px] tracking-[0.08em]",
|
||||
source: "text-[12px] leading-[16px] tracking-[0.08em]",
|
||||
showDecor: false,
|
||||
citationLayout: true,
|
||||
},
|
||||
statement: {
|
||||
container:
|
||||
"flex w-full flex-col items-center px-[var(--space-800)] py-[var(--space-1200)] md:px-[var(--space-2400)] md:py-[var(--space-1200)]",
|
||||
|
||||
@@ -2,7 +2,8 @@ export type QuoteBlockVariantValue =
|
||||
| "compact"
|
||||
| "standard"
|
||||
| "extended"
|
||||
| "statement";
|
||||
| "statement"
|
||||
| "citation";
|
||||
|
||||
export interface QuoteBlockProps {
|
||||
/** Default `standard` (home portrait quote). **`statement`** = yellow Section / Quote (**About** + **`/use-cases`** — two paragraphs below **`lg`**, one paragraph from **`lg`**; [21967-24638](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21967-24638&m=dev)). */
|
||||
@@ -45,6 +46,8 @@ export interface VariantConfig {
|
||||
* When true, render Figma **Section/Quote** layout (yellow surface; stacked copy below **`lg`**, single paragraph from **`lg`**; no attribution).
|
||||
*/
|
||||
statementLayout?: boolean;
|
||||
/** Lavender citation quote used on Workshops. No avatar. */
|
||||
citationLayout?: boolean;
|
||||
}
|
||||
|
||||
export interface QuoteBlockViewProps {
|
||||
|
||||
@@ -24,6 +24,49 @@ function QuoteBlockView({
|
||||
const t = useTranslation("quoteBlock");
|
||||
const avatarAlt = t("avatarAlt").replace("{author}", author);
|
||||
|
||||
if (config.citationLayout) {
|
||||
return (
|
||||
<section
|
||||
data-figma-node="23983-53436"
|
||||
className={`${config.container} ${className}`.trim()}
|
||||
aria-labelledby={quoteId}
|
||||
>
|
||||
<figure className="relative mx-auto w-full max-w-[1216px] overflow-hidden rounded-[24px] bg-[var(--color-surface-invert-brand-lavender)] px-[var(--space-600)] py-[var(--space-1000)] md:px-[var(--space-1200)] md:py-[var(--space-1800)]">
|
||||
<div
|
||||
className="pointer-events-none absolute -right-10 -top-16 size-[280px] rounded-full bg-white/25"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute -bottom-20 right-16 size-[320px] rounded-full bg-white/20"
|
||||
aria-hidden
|
||||
/>
|
||||
<blockquote id={quoteId} className="relative">
|
||||
<p
|
||||
className={`font-bricolage-grotesque font-medium text-[var(--color-content-invert-primary)] ${config.quote}`}
|
||||
>
|
||||
“{quote}”
|
||||
</p>
|
||||
</blockquote>
|
||||
<figcaption className="relative mt-10 flex flex-col gap-1">
|
||||
<cite
|
||||
id={authorId}
|
||||
className={`font-inter not-italic uppercase text-[var(--color-content-invert-primary)] ${config.author}`}
|
||||
>
|
||||
{author}
|
||||
</cite>
|
||||
{source ? (
|
||||
<p
|
||||
className={`font-inter uppercase text-[var(--color-content-invert-primary)] ${config.source}`}
|
||||
>
|
||||
{source}
|
||||
</p>
|
||||
) : null}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.statementLayout) {
|
||||
const statementTextClass =
|
||||
"font-bricolage-grotesque text-medium-heading text-[var(--color-surface-default-tertiary)] md:text-xx-large-heading";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import WhoRunsItView from "./WhoRunsIt.view";
|
||||
import type { WhoRunsItProps } from "./WhoRunsIt.types";
|
||||
|
||||
/** Figma: "Section / Who Runs It" (24265:50770). Names no fee. */
|
||||
function WhoRunsItContainer({ className }: WhoRunsItProps) {
|
||||
return <WhoRunsItView className={className} />;
|
||||
}
|
||||
|
||||
WhoRunsItContainer.displayName = "WhoRunsIt";
|
||||
|
||||
export default memo(WhoRunsItContainer);
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface WhoRunsItProps {
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Button from "../../buttons/Button";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { WORKSHOP_SIGNUP_ANCHOR_ID } from "../../../../lib/workshopSignup";
|
||||
|
||||
function WhoRunsItView({ className = "" }: { className?: string }) {
|
||||
const copy = useMessages().pages.workshops.whoRunsIt;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-figma-node="24265-50769"
|
||||
className={`bg-[var(--color-surface-default-primary)] py-[var(--space-1800)] pl-0 pr-[var(--space-400)] md:pr-[var(--space-1200)] lg:py-[120px] lg:pr-[var(--space-2400)] ${className}`}
|
||||
>
|
||||
<div className="flex w-full flex-col items-start gap-12 lg:flex-row lg:items-center lg:gap-20">
|
||||
<div
|
||||
className="relative aspect-square w-full max-w-[419px] shrink-0 overflow-hidden bg-[var(--color-content-invert-brand-royal)]"
|
||||
aria-hidden
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- decorative Figma mark */}
|
||||
<img
|
||||
src="/assets/marketing/workshops/who-runs-it-shape.svg"
|
||||
alt=""
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 h-1/2 w-full overflow-hidden bg-[var(--color-surface-invert-brand-lime)]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- decorative Figma mark */}
|
||||
<img
|
||||
src="/assets/marketing/workshops/who-runs-it-mark.svg"
|
||||
alt=""
|
||||
className="size-full object-cover object-left"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-8 pr-[var(--space-400)] lg:pr-0">
|
||||
<h2 className="x-large-heading text-[var(--color-content-default-primary)]">
|
||||
{copy.title}
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-8 md:flex-row md:flex-wrap md:gap-16">
|
||||
{copy.people.map((person) => (
|
||||
<li key={person.id} className="flex min-w-[240px] flex-1 gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[16px] font-bold leading-6 text-[var(--color-content-default-primary)]">
|
||||
{person.name}
|
||||
</p>
|
||||
<p className="small-paragraph text-[var(--color-content-default-secondary)]">
|
||||
{person.bio}
|
||||
</p>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- facilitator portrait from Figma */}
|
||||
<img
|
||||
src={person.image}
|
||||
alt={person.imageAlt}
|
||||
width={84}
|
||||
height={84}
|
||||
className="size-[84px] shrink-0 rounded-full border-2 border-white/40 object-cover"
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="border-t border-[var(--color-border-default-secondary)] pt-6">
|
||||
<Button href={`#${WORKSHOP_SIGNUP_ANCHOR_ID}`} size="small">
|
||||
{copy.button}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(WhoRunsItView);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./WhoRunsIt.container";
|
||||
export type { WhoRunsItProps } from "./WhoRunsIt.types";
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import WorkshopCircleView from "./WorkshopCircle.view";
|
||||
import type { WorkshopCircleProps } from "./WorkshopCircle.types";
|
||||
|
||||
/** Figma: "Section / Circle 50 50" (24264:49597). */
|
||||
function WorkshopCircleContainer({ className }: WorkshopCircleProps) {
|
||||
return <WorkshopCircleView className={className} />;
|
||||
}
|
||||
|
||||
WorkshopCircleContainer.displayName = "WorkshopCircle";
|
||||
|
||||
export default memo(WorkshopCircleContainer);
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface WorkshopCircleProps {
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Button from "../../buttons/Button";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { WORKSHOP_SIGNUP_ANCHOR_ID } from "../../../../lib/workshopSignup";
|
||||
|
||||
const CARD_CLASS: Record<string, string> = {
|
||||
lavender:
|
||||
"bg-[var(--color-surface-invert-brand-lavender)] text-[var(--color-content-invert-brand-lavender)]",
|
||||
teal: "bg-[var(--color-surface-invert-brand-teal)] text-[var(--color-content-invert-brand-teal)]",
|
||||
yellow:
|
||||
"bg-[var(--color-surface-invert-brand-secondary)] text-[var(--color-content-invert-brand-secondary)]",
|
||||
red: "bg-[var(--color-surface-invert-brand-red)] text-[var(--color-content-invert-brand-red)]",
|
||||
};
|
||||
|
||||
function TableIllustration({
|
||||
cards,
|
||||
deck,
|
||||
prompt,
|
||||
}: {
|
||||
cards: ReadonlyArray<{ id: string; label: string; color: string }>;
|
||||
deck: readonly string[];
|
||||
prompt: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="relative flex aspect-square w-full max-w-[560px] flex-col items-center justify-center overflow-hidden rounded-full border border-[var(--color-border-default-secondary)] bg-[var(--color-surface-default-secondary)] px-8 pb-6 pt-10"
|
||||
aria-hidden
|
||||
>
|
||||
<div className="flex w-full max-w-[360px] justify-center gap-2">
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className={`flex h-[110px] w-[22%] items-end rounded-[8px] p-2 text-[12px] font-medium leading-[14px] ${CARD_CLASS[card.color] ?? ""}`}
|
||||
>
|
||||
{card.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex w-[140%] justify-center">
|
||||
{deck.map((label, index) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex h-[120px] w-[92px] -mx-3 flex-col items-center justify-end rounded-[6px] border border-[var(--color-border-invert-secondary,#e4e4e4)] bg-white px-2 pb-3 text-center text-[11px] leading-4 text-[var(--color-content-invert-primary)]"
|
||||
style={{ transform: `rotate(${(index - 1) * 18}deg)` }}
|
||||
>
|
||||
<span className="mb-auto mt-2 grid grid-cols-4 gap-1" aria-hidden>
|
||||
{Array.from({ length: 8 }).map((_, dot) => (
|
||||
<span key={dot} className="size-[3px] rounded-full bg-neutral-300" />
|
||||
))}
|
||||
</span>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 max-w-[240px] rounded-[12px] border border-[var(--color-border-default-secondary)] bg-black px-4 py-3 text-center text-[14px] leading-5 text-white">
|
||||
{prompt}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopCircleView({ className = "" }: { className?: string }) {
|
||||
const copy = useMessages().pages.workshops.circle;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-figma-node="24264-49598"
|
||||
className={`bg-[var(--color-surface-default-primary)] px-[var(--space-400)] py-[var(--space-1800)] md:px-[var(--space-1200)] lg:p-[var(--space-2400)] ${className}`}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[1440px] flex-col items-center gap-12 lg:flex-row lg:items-center">
|
||||
<div className="flex w-full max-w-[768px] flex-1 flex-col items-start gap-8">
|
||||
<h2 className="x-large-heading text-[var(--color-content-default-primary)]">
|
||||
{copy.title}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{copy.paragraphs.map((paragraph) => (
|
||||
<p
|
||||
key={paragraph.slice(0, 24)}
|
||||
className="medium-paragraph text-[var(--color-content-default-tertiary)]"
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<Button href={`#${WORKSHOP_SIGNUP_ANCHOR_ID}`} size="small">
|
||||
{copy.button}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 justify-center">
|
||||
<TableIllustration cards={copy.cards} deck={copy.deck} prompt={copy.prompt} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(WorkshopCircleView);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./WorkshopCircle.container";
|
||||
export type { WorkshopCircleProps } from "./WorkshopCircle.types";
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import WorkshopHeroView from "./WorkshopHero.view";
|
||||
import type { WorkshopHeroProps } from "./WorkshopHero.types";
|
||||
|
||||
/** Figma: "Section / Workshop Hero" (24262:40645). */
|
||||
function WorkshopHeroContainer({ className }: WorkshopHeroProps) {
|
||||
return <WorkshopHeroView className={className} />;
|
||||
}
|
||||
|
||||
WorkshopHeroContainer.displayName = "WorkshopHero";
|
||||
|
||||
export default memo(WorkshopHeroContainer);
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface WorkshopHeroProps {
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Button from "../../buttons/Button";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { WORKSHOP_SIGNUP_ANCHOR_ID } from "../../../../lib/workshopSignup";
|
||||
|
||||
const CARD_CLASS: Record<string, string> = {
|
||||
yellow:
|
||||
"bg-[var(--color-surface-invert-brand-secondary)] text-[var(--color-content-invert-brand-secondary)]",
|
||||
teal: "bg-[var(--color-surface-invert-brand-teal)] text-[var(--color-content-invert-brand-teal)]",
|
||||
lavender:
|
||||
"bg-[var(--color-surface-invert-brand-lavender)] text-[var(--color-content-invert-brand-lavender)]",
|
||||
red: "bg-[var(--color-surface-invert-brand-red)] text-[var(--color-content-invert-brand-red)]",
|
||||
};
|
||||
|
||||
const DESKTOP_CARD_CLASS = [
|
||||
"left-[8%] top-[18%] w-[46%] -rotate-3",
|
||||
"left-[28%] top-[8%] w-[42%] rotate-[9deg]",
|
||||
"left-0 top-[34%] w-[48%] -rotate-[15deg]",
|
||||
"left-[36%] top-[42%] w-[52%] rotate-[16deg]",
|
||||
];
|
||||
|
||||
function WorkshopHeroView({ className = "" }: { className?: string }) {
|
||||
const copy = useMessages().pages.workshops.hero;
|
||||
const href = `#${WORKSHOP_SIGNUP_ANCHOR_ID}`;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-figma-node="24258-40644"
|
||||
className={`bg-[var(--color-surface-default-primary)] px-[var(--space-400)] pb-[var(--space-1800)] pt-[var(--space-1200)] md:px-[var(--space-1200)] lg:px-[var(--space-2400)] lg:pb-[120px] lg:pt-[96px] ${className}`}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-12 lg:flex-row lg:items-center lg:gap-12">
|
||||
<div className="order-2 flex min-w-0 flex-1 flex-col gap-8 lg:order-1">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="x-small-label-caps text-[var(--color-content-default-tertiary)]">
|
||||
{copy.eyebrow}
|
||||
</p>
|
||||
<h1 className="x-large-display text-[var(--color-content-default-primary)]">
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p className="large-paragraph text-[var(--color-content-default-tertiary)]">
|
||||
{copy.body}
|
||||
</p>
|
||||
</div>
|
||||
<Button href={href} size="small">
|
||||
{copy.button}
|
||||
</Button>
|
||||
<p className="border-t border-[var(--color-border-default-secondary)] pt-6 text-[12px] leading-4 text-[var(--color-content-default-tertiary)]">
|
||||
{copy.credit}
|
||||
</p>
|
||||
</div>
|
||||
<div className="order-1 min-w-0 flex-1 lg:order-2">
|
||||
<div className="flex flex-col lg:hidden">
|
||||
{copy.cards.map((card, index) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className={`rounded-[16px] px-4 py-6 ${CARD_CLASS[card.color] ?? ""} ${index > 0 ? "-mt-3" : ""}`}
|
||||
>
|
||||
<p className="small-heading">{card.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative hidden h-[480px] lg:block">
|
||||
{copy.cards.map((card, index) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className={`absolute rounded-[16px] px-4 py-6 ${CARD_CLASS[card.color] ?? ""} ${DESKTOP_CARD_CLASS[index] ?? ""}`}
|
||||
>
|
||||
<p className="small-heading">{card.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(WorkshopHeroView);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./WorkshopHero.container";
|
||||
export type { WorkshopHeroProps } from "./WorkshopHero.types";
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import WorkshopOverviewView from "./WorkshopOverview.view";
|
||||
import type { WorkshopOverviewProps } from "./WorkshopOverview.types";
|
||||
|
||||
/** Figma: "Section / Workshop Overview" (24263:45262). Room tiles carry nothing into the form. */
|
||||
function WorkshopOverviewContainer({ className }: WorkshopOverviewProps) {
|
||||
return <WorkshopOverviewView className={className} />;
|
||||
}
|
||||
|
||||
WorkshopOverviewContainer.displayName = "WorkshopOverview";
|
||||
|
||||
export default memo(WorkshopOverviewContainer);
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface WorkshopOverviewProps {
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Button from "../../buttons/Button";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { WORKSHOP_SIGNUP_ANCHOR_ID } from "../../../../lib/workshopSignup";
|
||||
|
||||
const DOT_CLASS: Record<string, string> = {
|
||||
teal: "bg-[var(--color-content-invert-brand-teal)]",
|
||||
lime: "bg-[var(--color-surface-invert-brand-kiwi)]",
|
||||
lavender: "bg-[var(--color-content-invert-brand-lavender)]",
|
||||
red: "bg-[var(--color-content-invert-brand-red)]",
|
||||
};
|
||||
|
||||
function WorkshopOverviewView({ className = "" }: { className?: string }) {
|
||||
const copy = useMessages().pages.workshops.overview;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-figma-node="24263-45235"
|
||||
className={`bg-[var(--color-surface-default-primary)] px-[var(--space-400)] py-[var(--space-1800)] md:px-[var(--space-1200)] lg:px-[112px] lg:py-[120px] ${className}`}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[1216px] flex-col items-center gap-12">
|
||||
<div className="flex max-w-[640px] flex-col items-center gap-2 text-center">
|
||||
<h2 className="x-large-heading text-[var(--color-content-default-primary)]">
|
||||
{copy.title}
|
||||
</h2>
|
||||
<p className="medium-paragraph text-[var(--color-content-default-tertiary)]">
|
||||
{copy.body}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="grid w-full max-w-[800px] grid-cols-2 gap-4 md:gap-6">
|
||||
{copy.rooms.map((room) => (
|
||||
<li
|
||||
key={room.id}
|
||||
className="flex min-h-[100px] items-center gap-4 rounded-[12px] border border-[var(--color-border-default-secondary)] bg-[var(--color-surface-default-secondary)] p-6"
|
||||
>
|
||||
<span
|
||||
className={`size-3 shrink-0 rounded-full ${DOT_CLASS[room.color] ?? ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[18px] font-medium leading-6 text-[var(--color-content-default-primary)]">
|
||||
{room.label}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button href={`#${WORKSHOP_SIGNUP_ANCHOR_ID}`} size="small">
|
||||
{copy.button}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(WorkshopOverviewView);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./WorkshopOverview.container";
|
||||
export type { WorkshopOverviewProps } from "./WorkshopOverview.types";
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
EMPTY_WORKSHOP_FORM_VALUES,
|
||||
WORKSHOP_HONEYPOT_FIELD,
|
||||
WORKSHOP_STARTED_AT_FIELD,
|
||||
} from "../../../../lib/workshopSignup";
|
||||
import WorkshopSignupView from "./WorkshopSignup.view";
|
||||
import type { WorkshopSignupProps, WorkshopSignupStatus } from "./WorkshopSignup.types";
|
||||
|
||||
/** Figma: "Section / Workshop Signup" (24323:55538). Full field set at every width. */
|
||||
function WorkshopSignupContainer({
|
||||
className,
|
||||
issuedAt,
|
||||
schedulingUrl = "",
|
||||
sourceUrl = "",
|
||||
initialStatus = "idle",
|
||||
initialLarge = false,
|
||||
initialValues = EMPTY_WORKSHOP_FORM_VALUES,
|
||||
initialFieldErrors = {},
|
||||
}: WorkshopSignupProps) {
|
||||
const [status, setStatus] = useState<WorkshopSignupStatus>(initialStatus);
|
||||
const [largeOrganization, setLargeOrganization] = useState(initialLarge);
|
||||
const [fieldErrors, setFieldErrors] = useState(initialFieldErrors);
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
setStatus("sending");
|
||||
try {
|
||||
const response = await fetch("/api/workshop-signup", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
challenges: data.getAll("challenges"),
|
||||
challengeOther: data.get("challengeOther"),
|
||||
unspokenQuestion: data.get("unspokenQuestion"),
|
||||
organizationName: data.get("organizationName"),
|
||||
organizationKind: data.get("organizationKind"),
|
||||
memberCount: data.get("memberCount"),
|
||||
contactName: data.get("contactName"),
|
||||
email: data.get("email"),
|
||||
role: data.get("role"),
|
||||
sourcePage: "/workshops",
|
||||
sourceUrl,
|
||||
[WORKSHOP_HONEYPOT_FIELD]: data.get(WORKSHOP_HONEYPOT_FIELD),
|
||||
[WORKSHOP_STARTED_AT_FIELD]: Number(data.get(WORKSHOP_STARTED_AT_FIELD) ?? issuedAt),
|
||||
}),
|
||||
});
|
||||
const body = (await response.json()) as {
|
||||
status?: string;
|
||||
largeOrganization?: boolean;
|
||||
fieldErrors?: WorkshopSignupProps["initialFieldErrors"];
|
||||
};
|
||||
if (body.status === "sent") {
|
||||
setLargeOrganization(Boolean(body.largeOrganization));
|
||||
setFieldErrors({});
|
||||
setStatus("sent");
|
||||
return;
|
||||
}
|
||||
if (body.status === "invalid") {
|
||||
setFieldErrors(body.fieldErrors ?? {});
|
||||
setStatus("invalid");
|
||||
return;
|
||||
}
|
||||
if (body.status === "rate_limited" || response.status === 429) {
|
||||
setStatus("rate_limited");
|
||||
return;
|
||||
}
|
||||
setStatus("failed");
|
||||
} catch {
|
||||
setStatus("failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkshopSignupView
|
||||
className={className ?? ""}
|
||||
issuedAt={issuedAt}
|
||||
schedulingUrl={schedulingUrl}
|
||||
sourceUrl={sourceUrl}
|
||||
status={status}
|
||||
largeOrganization={largeOrganization}
|
||||
values={initialValues}
|
||||
fieldErrors={fieldErrors}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
WorkshopSignupContainer.displayName = "WorkshopSignup";
|
||||
|
||||
export default memo(WorkshopSignupContainer);
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FormEvent } from "react";
|
||||
import type {
|
||||
WorkshopFieldErrors,
|
||||
WorkshopFormValues,
|
||||
} from "../../../../lib/workshopSignup";
|
||||
|
||||
export type WorkshopSignupStatus =
|
||||
| "idle"
|
||||
| "sending"
|
||||
| "sent"
|
||||
| "invalid"
|
||||
| "failed"
|
||||
| "rate_limited";
|
||||
|
||||
export interface WorkshopSignupProps {
|
||||
className?: string;
|
||||
issuedAt: number;
|
||||
schedulingUrl?: string;
|
||||
sourceUrl?: string;
|
||||
initialStatus?: Exclude<WorkshopSignupStatus, "sending">;
|
||||
initialLarge?: boolean;
|
||||
initialValues?: WorkshopFormValues;
|
||||
initialFieldErrors?: WorkshopFieldErrors;
|
||||
}
|
||||
|
||||
export interface WorkshopSignupViewProps {
|
||||
className: string;
|
||||
issuedAt: number;
|
||||
schedulingUrl: string;
|
||||
sourceUrl: string;
|
||||
status: WorkshopSignupStatus;
|
||||
largeOrganization: boolean;
|
||||
values: WorkshopFormValues;
|
||||
fieldErrors: WorkshopFieldErrors;
|
||||
onSubmit: (_event: FormEvent<HTMLFormElement>) => void;
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Alert from "../../modals/Alert";
|
||||
import Button from "../../buttons/Button";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import {
|
||||
WORKSHOP_HONEYPOT_FIELD,
|
||||
WORKSHOP_SIGNUP_ANCHOR_ID,
|
||||
WORKSHOP_STARTED_AT_FIELD,
|
||||
type WorkshopFieldErrors,
|
||||
} from "../../../../lib/workshopSignup";
|
||||
import type { WorkshopSignupViewProps } from "./WorkshopSignup.types";
|
||||
|
||||
const fieldClass =
|
||||
"h-[52px] w-full rounded-[12px] border bg-[var(--color-surface-default-primary)] px-4 text-[16px] leading-6 text-[var(--color-content-default-primary)] outline-none focus-visible:border-white";
|
||||
|
||||
function borderClass(invalid: boolean): string {
|
||||
return invalid
|
||||
? "border-[var(--color-border-default-utility-negative)]"
|
||||
: "border-[var(--color-border-default-tertiary)]";
|
||||
}
|
||||
|
||||
function errorText(
|
||||
errors: WorkshopFieldErrors,
|
||||
key: keyof WorkshopFieldErrors,
|
||||
messages: Record<string, string>,
|
||||
): string | undefined {
|
||||
const code = errors[key];
|
||||
if (!code) return undefined;
|
||||
return messages[key];
|
||||
}
|
||||
|
||||
function WorkshopSignupView({
|
||||
className,
|
||||
issuedAt,
|
||||
schedulingUrl,
|
||||
sourceUrl,
|
||||
status,
|
||||
largeOrganization,
|
||||
values,
|
||||
fieldErrors,
|
||||
onSubmit,
|
||||
}: WorkshopSignupViewProps) {
|
||||
const copy = useMessages().pages.workshops.signup;
|
||||
const sending = status === "sending";
|
||||
const sent = status === "sent";
|
||||
const showSummary = status === "invalid" && Object.keys(fieldErrors).length > 0;
|
||||
const banner =
|
||||
status === "failed"
|
||||
? copy.errors.failed
|
||||
: status === "rate_limited"
|
||||
? copy.errors.rateLimited
|
||||
: showSummary
|
||||
? copy.errors.summary
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section
|
||||
id={WORKSHOP_SIGNUP_ANCHOR_ID}
|
||||
data-figma-node="24323-55538"
|
||||
className={`scroll-mt-28 bg-[var(--color-surface-default-primary)] px-[var(--space-400)] py-[var(--space-1800)] md:px-[var(--space-1200)] lg:px-[96px] lg:py-[120px] ${className}`}
|
||||
>
|
||||
<div className="mx-auto grid w-full max-w-[1440px] gap-12 lg:grid-cols-[minmax(0,1fr)_320px] lg:gap-10">
|
||||
<div className="relative min-w-0">
|
||||
<div className="pointer-events-none absolute -left-16 top-10 hidden size-[280px] rotate-[15deg] rounded-full bg-[var(--color-surface-invert-brand-lavender)] lg:block" aria-hidden />
|
||||
<div className="pointer-events-none absolute -left-10 bottom-24 hidden size-[240px] -rotate-[15deg] rounded-[64px] bg-[var(--color-surface-invert-brand-teal)] lg:block" aria-hidden />
|
||||
<div className="relative flex flex-col gap-4 text-[var(--color-content-default-primary)]">
|
||||
<h2 className="x-large-heading">{copy.title}</h2>
|
||||
<p className="medium-paragraph max-w-[640px]">{copy.intro}</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-8 rounded-[24px] bg-[var(--color-surface-default-secondary)] p-6 md:p-10 lg:p-12">
|
||||
{sent ? (
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<p className="text-[20px] font-medium leading-7 text-[var(--color-content-default-primary)]">
|
||||
{largeOrganization ? copy.sent.largeTitle : copy.sent.standardTitle}
|
||||
</p>
|
||||
<p className="medium-paragraph text-[var(--color-content-default-tertiary)]">
|
||||
{largeOrganization ? copy.sent.largeBody : copy.sent.standardBody}
|
||||
</p>
|
||||
{largeOrganization ? (
|
||||
schedulingUrl ? (
|
||||
<Button href={schedulingUrl} size="small">
|
||||
{copy.sent.largeButton}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="medium-label text-[var(--color-content-default-primary)]">
|
||||
{copy.sent.largeButton}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<Button href={copy.side.kitHref} buttonType="outline" palette="inverse" size="small">
|
||||
{copy.sent.standardButton}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
action="/api/workshop-signup"
|
||||
method="post"
|
||||
className="flex flex-col gap-10"
|
||||
onSubmit={onSubmit}
|
||||
noValidate
|
||||
>
|
||||
<input type="hidden" name={WORKSHOP_STARTED_AT_FIELD} value={issuedAt} />
|
||||
<input type="hidden" name="sourcePage" value="/workshops" />
|
||||
<input type="hidden" name="sourceUrl" value={sourceUrl} />
|
||||
<div className="absolute left-[-9999px] h-px w-px overflow-hidden" aria-hidden="true">
|
||||
<label>
|
||||
{copy.honeypotLabel}
|
||||
<input name={WORKSHOP_HONEYPOT_FIELD} tabIndex={-1} autoComplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{banner ? (
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
size="s"
|
||||
title={banner}
|
||||
hasBodyText={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<fieldset className="flex flex-col gap-6">
|
||||
<legend className="medium-label text-[var(--color-content-default-tertiary)]">
|
||||
{copy.sections.takeOn}
|
||||
</legend>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="medium-label text-[var(--color-content-default-primary)]">
|
||||
{copy.challenges.label}
|
||||
</p>
|
||||
<p className="small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||
{copy.challenges.hint}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{copy.challenges.options.map((option) =>
|
||||
option.id === "something-else" ? (
|
||||
<div key={option.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
||||
<label
|
||||
htmlFor="workshop-something-else"
|
||||
className="col-start-1 row-start-1 text-[14px] leading-5 text-[var(--color-content-default-primary)]"
|
||||
>
|
||||
{option.label}
|
||||
</label>
|
||||
<input
|
||||
id="workshop-something-else"
|
||||
type="checkbox"
|
||||
name="challenges"
|
||||
value={option.id}
|
||||
defaultChecked={values.challenges.includes(option.id)}
|
||||
className="peer col-start-2 row-start-1 size-6 shrink-0 appearance-none rounded-[4px] border border-[var(--color-border-default-control)] bg-[var(--color-surface-default-primary)] checked:bg-white"
|
||||
/>
|
||||
<div className="col-span-2 row-start-2 hidden flex-col gap-2 peer-checked:flex">
|
||||
<label
|
||||
className="medium-label text-[var(--color-content-default-primary)]"
|
||||
htmlFor="workshop-challenge-other"
|
||||
>
|
||||
{copy.challenges.otherLabel}
|
||||
</label>
|
||||
<input
|
||||
id="workshop-challenge-other"
|
||||
name="challengeOther"
|
||||
defaultValue={values.challengeOther}
|
||||
maxLength={200}
|
||||
className={`${fieldClass} ${borderClass(Boolean(fieldErrors.challengeOther))}`}
|
||||
/>
|
||||
{errorText(fieldErrors, "challengeOther", copy.errors) ? (
|
||||
<p className="small-paragraph text-[var(--color-content-default-utility-negative)]">
|
||||
{errorText(fieldErrors, "challengeOther", copy.errors)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<label key={option.id} className="flex items-center gap-3">
|
||||
<span className="min-w-0 flex-1 text-[14px] leading-5 text-[var(--color-content-default-primary)]">
|
||||
{option.label}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="challenges"
|
||||
value={option.id}
|
||||
defaultChecked={values.challenges.includes(option.id)}
|
||||
className="size-6 shrink-0 appearance-none rounded-[4px] border border-[var(--color-border-default-control)] bg-[var(--color-surface-default-primary)] checked:bg-white"
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{errorText(fieldErrors, "challenges", copy.errors) ? (
|
||||
<p className="small-paragraph text-[var(--color-content-default-utility-negative)]">
|
||||
{errorText(fieldErrors, "challenges", copy.errors)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="medium-label text-[var(--color-content-default-primary)]">
|
||||
{copy.question.label}{" "}
|
||||
<span className="text-[var(--color-content-default-tertiary)]">{copy.optional}</span>
|
||||
</p>
|
||||
<p className="small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||
{copy.question.hint}
|
||||
</p>
|
||||
<textarea
|
||||
name="unspokenQuestion"
|
||||
defaultValue={values.unspokenQuestion}
|
||||
maxLength={1000}
|
||||
rows={5}
|
||||
className={`min-h-[120px] w-full rounded-[12px] border bg-[var(--color-surface-default-primary)] px-4 py-3 text-[16px] leading-6 text-[var(--color-content-default-primary)] outline-none ${borderClass(Boolean(fieldErrors.unspokenQuestion))}`}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="flex flex-col gap-6">
|
||||
<legend className="medium-label text-[var(--color-content-default-tertiary)]">
|
||||
{copy.sections.organization}
|
||||
</legend>
|
||||
<Field
|
||||
label={copy.organizationName.label}
|
||||
name="organizationName"
|
||||
defaultValue={values.organizationName}
|
||||
maxLength={150}
|
||||
invalid={Boolean(fieldErrors.organizationName)}
|
||||
message={errorText(fieldErrors, "organizationName", copy.errors)}
|
||||
/>
|
||||
<SelectField
|
||||
label={copy.organizationKind.label}
|
||||
name="organizationKind"
|
||||
placeholder={copy.organizationKind.placeholder}
|
||||
options={copy.organizationKind.options}
|
||||
defaultValue={values.organizationKind}
|
||||
invalid={Boolean(fieldErrors.organizationKind)}
|
||||
message={errorText(fieldErrors, "organizationKind", copy.errors)}
|
||||
/>
|
||||
<SelectField
|
||||
label={copy.members.label}
|
||||
hint={copy.members.hint}
|
||||
name="memberCount"
|
||||
placeholder={copy.members.placeholder}
|
||||
options={copy.members.options}
|
||||
defaultValue={values.memberCount}
|
||||
invalid={Boolean(fieldErrors.memberCount)}
|
||||
message={errorText(fieldErrors, "memberCount", copy.errors)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="flex flex-col gap-6">
|
||||
<legend className="medium-label text-[var(--color-content-default-tertiary)]">
|
||||
{copy.sections.aboutYou}
|
||||
</legend>
|
||||
<Field
|
||||
label={copy.name.label}
|
||||
name="contactName"
|
||||
defaultValue={values.contactName}
|
||||
maxLength={100}
|
||||
invalid={Boolean(fieldErrors.contactName)}
|
||||
message={errorText(fieldErrors, "contactName", copy.errors)}
|
||||
/>
|
||||
<Field
|
||||
label={copy.email.label}
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={values.email}
|
||||
invalid={Boolean(fieldErrors.email)}
|
||||
message={errorText(fieldErrors, "email", copy.errors)}
|
||||
/>
|
||||
<SelectField
|
||||
label={copy.role.label}
|
||||
optional={copy.optional}
|
||||
name="role"
|
||||
placeholder={copy.role.placeholder}
|
||||
options={copy.role.options}
|
||||
defaultValue={values.role}
|
||||
invalid={Boolean(fieldErrors.role)}
|
||||
message={errorText(fieldErrors, "role", copy.errors)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<Button type="submit" size="small" disabled={sending}>
|
||||
{sending ? copy.sending : copy.send}
|
||||
</Button>
|
||||
<p className="text-[12px] leading-4 text-[var(--color-content-default-tertiary)]">
|
||||
{copy.assurance}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="flex flex-col gap-8 lg:pt-[156px]">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="x-small-label-caps text-[var(--color-content-default-tertiary)]">
|
||||
{copy.side.eyebrow}
|
||||
</p>
|
||||
<h3 className="x-small-heading text-[var(--color-content-default-primary)]">
|
||||
{copy.side.title}
|
||||
</h3>
|
||||
</div>
|
||||
{copy.side.questions.map((item) => (
|
||||
<div key={item.q} className="flex flex-col gap-2">
|
||||
<p className="text-[14px] font-bold leading-5 text-[var(--color-content-default-primary)]">
|
||||
{item.q}
|
||||
</p>
|
||||
<p className="small-paragraph text-[var(--color-content-default-tertiary)]">{item.a}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-col gap-3 rounded-[12px] bg-[var(--color-surface-default-secondary)] p-4">
|
||||
<p className="text-[14px] font-bold leading-5 text-[var(--color-content-default-primary)]">
|
||||
{copy.side.volunteerTitle}
|
||||
</p>
|
||||
<p className="small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||
{copy.side.volunteerBodyBefore}
|
||||
<a href={copy.side.kitHref} className="underline">
|
||||
{copy.side.volunteerLink}
|
||||
</a>
|
||||
{copy.side.volunteerBodyAfter}
|
||||
</p>
|
||||
<Button href={copy.side.kitHref} buttonType="outline" palette="inverse" size="small" className="w-full">
|
||||
{copy.side.volunteerButton}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 rounded-[12px] bg-[var(--color-surface-default-secondary)] p-6">
|
||||
<p className="x-small-label-caps text-[var(--color-content-default-primary)]">
|
||||
{copy.side.nextTitle}
|
||||
</p>
|
||||
<ol className="flex flex-col gap-4">
|
||||
{copy.side.steps.map((step, index) => (
|
||||
<li key={step} className="flex items-baseline gap-3">
|
||||
<span className="medium-label text-[var(--color-content-default-brand-teal)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||
{step}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="border-t border-[var(--color-border-default-secondary)] pt-4 text-[12px] leading-[14px] text-[var(--color-content-default-tertiary)]">
|
||||
{copy.side.nextCredit}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
name,
|
||||
defaultValue,
|
||||
maxLength,
|
||||
invalid,
|
||||
message,
|
||||
type = "text",
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
defaultValue: string;
|
||||
maxLength?: number;
|
||||
invalid: boolean;
|
||||
message?: string;
|
||||
type?: string;
|
||||
}) {
|
||||
const id = `workshop-${name}`;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor={id} className="medium-label text-[var(--color-content-default-primary)]">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
maxLength={maxLength}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={`${fieldClass} ${borderClass(invalid)}`}
|
||||
/>
|
||||
{message ? (
|
||||
<p className="small-paragraph text-[var(--color-content-default-utility-negative)]">{message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({
|
||||
label,
|
||||
hint,
|
||||
optional,
|
||||
name,
|
||||
placeholder,
|
||||
options,
|
||||
defaultValue,
|
||||
invalid,
|
||||
message,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
optional?: string;
|
||||
name: string;
|
||||
placeholder: string;
|
||||
options: ReadonlyArray<{ id: string; label: string }>;
|
||||
defaultValue: string;
|
||||
invalid: boolean;
|
||||
message?: string;
|
||||
}) {
|
||||
const id = `workshop-${name}`;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor={id} className="medium-label text-[var(--color-content-default-primary)]">
|
||||
{label}{" "}
|
||||
{optional ? (
|
||||
<span className="text-[var(--color-content-default-tertiary)]">{optional}</span>
|
||||
) : null}
|
||||
</label>
|
||||
{hint ? (
|
||||
<p className="small-paragraph text-[var(--color-content-default-tertiary)]">{hint}</p>
|
||||
) : null}
|
||||
<select
|
||||
id={id}
|
||||
name={name}
|
||||
defaultValue={defaultValue}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={`${fieldClass} ${borderClass(invalid)}`}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{message ? (
|
||||
<p className="small-paragraph text-[var(--color-content-default-utility-negative)]">{message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(WorkshopSignupView);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./WorkshopSignup.container";
|
||||
export type { WorkshopSignupProps } from "./WorkshopSignup.types";
|
||||
Reference in new issue
Block a user