diff --git a/js/docs/frontend-structure.md b/js/docs/frontend-structure.md index 2190d7d6..3fd29722 100644 --- a/js/docs/frontend-structure.md +++ b/js/docs/frontend-structure.md @@ -26,7 +26,7 @@ js/src/ QueryProvider.tsx # TanStack Query provider theme.tsx # Mantine theme features/ # DOMAINS — one folder per bounded context - / # e.g. pairings, profile, intake, admin + / # e.g. pairings, profile, signup, admin .page.tsx # a page (flat file until it grows) / # page-FOLDER, only once the page gains local pieces .page.tsx @@ -65,7 +65,7 @@ js/src/ Keep the `.page.tsx` suffix inside page-folders — don't use `index.tsx`. 5. **Data layer split.** Global (`lib/api/`): generated OpenAPI types + typed fetch client + QueryClient. Domain-owned (`features//api/`): per-endpoint query/mutation hooks + Zod - schemas. `useSubmitIntake` belongs to `intake`, not a global pile. + schemas. `useSubmitSignUp` belongs to `SignUp`, not a global pile. 6. **Concern folders, on demand.** A domain has `components/` + `api/`; add `hooks/` / `utils.ts` / `types.ts` only as they grow (flat file first, folder at 2–3 files). There is no domain-level `lib/` — `lib/` means app infra only. diff --git a/js/package.json b/js/package.json index e2c2a0e3..d64fda68 100644 --- a/js/package.json +++ b/js/package.json @@ -14,7 +14,7 @@ "eslint": "eslint .", "eslint:fix": "eslint . --fix", "stylelint": "stylelint '**/*.css' --allow-empty-input", - "stylelint:fix": "stylelint '**/*.css' --fix", + "stylelint:fix": "stylelint '**/*.css' --fix --allow-empty-input", "prettier": "prettier --check .", "prettier:fix": "prettier --write .", "preview": "vite preview" diff --git a/js/src/app/router/router.tsx b/js/src/app/router/router.tsx index 6b7c48e0..b710d8e2 100644 --- a/js/src/app/router/router.tsx +++ b/js/src/app/router/router.tsx @@ -9,6 +9,7 @@ import EmailAdminPage from "@/features/emails/EmailAdminPage"; import HomePage from "@/features/home/Home.page"; import SamplePage from "@/features/sample/Sample.page"; import SampleAdminPage from "@/features/sample/SampleAdmin.page"; +import { SignUpPage } from "@/features/sign-up/SignUp.page"; import { createBrowserRouter } from "react-router-dom"; export const router = createBrowserRouter([ @@ -18,7 +19,10 @@ export const router = createBrowserRouter([ // Public: no guard, public chrome. { element: , - children: [{ index: true, element: }], + children: [ + { index: true, element: }, + { path: "sign-up", element: }, + ], }, // Authenticated: guard -> layout -> page. Admin nests a second guard + layout. diff --git a/js/src/features/home/Home.page.tsx b/js/src/features/home/Home.page.tsx index 6514c316..d5cea8a0 100644 --- a/js/src/features/home/Home.page.tsx +++ b/js/src/features/home/Home.page.tsx @@ -9,6 +9,9 @@ export default function HomePage() { Monthly one-on-one coffee chat pairings for the Patina Network. + + Complete the signup form + Go to the app diff --git a/js/src/features/sign-up/SignUp.page.tsx b/js/src/features/sign-up/SignUp.page.tsx new file mode 100644 index 00000000..afa3910c --- /dev/null +++ b/js/src/features/sign-up/SignUp.page.tsx @@ -0,0 +1,13 @@ +import { IntroText } from "@/features/sign-up/components/IntroText"; +import { SignUpForm } from "@/features/sign-up/components/SignUpForm"; +import { Stack, Title } from "@mantine/core"; + +export function SignUpPage() { + return ( + + PatChats Sign Up Form + + + + ); +} diff --git a/js/src/features/sign-up/api/schemas.ts b/js/src/features/sign-up/api/schemas.ts new file mode 100644 index 00000000..b0eb526b --- /dev/null +++ b/js/src/features/sign-up/api/schemas.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +export const signUpFormSchema = z.object({ + fullName: z.string().min(1, "Full Name is required."), + email: z + .string() + .min(1, "Email Address is required.") + .email("Enter a valid email address."), + linkedin: z + .string() + .refine( + (value) => + value === "" || + value.startsWith("https://linkedin.com/in/") || + value.startsWith("https://www.linkedin.com/in/"), + { + message: + "Enter a LinkedIn profile URL in the format linkedin.com/in/your-profile.", + }, + ), + introduction: z + .string() + .min(1, "Introduction is required.") + .max(300, "Introduction must be 300 characters or fewer."), + referralSource: z + .string() + .max(200, "Referral source must be 200 characters or fewer."), + matchingPreference: z.string(), + industry: z.string(), + role: z.string().max(200, "Role Preference must be 200 characters or fewer."), + talkingPoints: z + .string() + .max(200, "Talking points must be 200 characters or fewer."), + additionalInfo: z + .string() + .max(500, "Additional info must be 500 characters or fewer."), +}); diff --git a/js/src/features/sign-up/components/IntroText.tsx b/js/src/features/sign-up/components/IntroText.tsx new file mode 100644 index 00000000..28e1b897 --- /dev/null +++ b/js/src/features/sign-up/components/IntroText.tsx @@ -0,0 +1,28 @@ +import { Text } from "@mantine/core"; + +export function IntroText() { + return ( + <> + + Hi everyone! Would you like to get to know other members of the Patina + community better? +
+
+ PatChats is a program where every month you will get matched with + another Patina member, and find a time between the two of you to have a + 30 minute video call or coffee chat! At the end, share your socials and + take a fun selfie or screenshot to share on the #pat-chats channel on + our Discord!
+
+ Connect with other members within the Patina network to learn more about + each other and share our diverse backgrounds, professional journeys, and + career insights. Our goal is to foster a more positive, tight-knit + community where we can support one another in reaching our life and + career aspirations and have some fun while we're at it!
+
+ Sign up here to be included in next month's cycle. We currently have 80 + people signed up and looking to keep it growing! +
+ + ); +} diff --git a/js/src/features/sign-up/components/SignUpForm.tsx b/js/src/features/sign-up/components/SignUpForm.tsx new file mode 100644 index 00000000..958ce5cf --- /dev/null +++ b/js/src/features/sign-up/components/SignUpForm.tsx @@ -0,0 +1,278 @@ +import { signUpFormSchema } from "@/features/sign-up/api/schemas"; +import { + INDUSTRIES, + MATCHING_PREFERENCES, +} from "@/features/sign-up/components/signUpFormConfig"; +import { SignUpFormValues } from "@/features/sign-up/types"; +import { + Alert, + Button, + Divider, + Group, + Paper, + Select, + Stack, + Text, + Textarea, + TextInput, + Title, +} from "@mantine/core"; +import { useState } from "react"; + +// Options for select fields. Defined in SignUpFormConfig +const matchingPreferenceOptions = MATCHING_PREFERENCES.map((v) => ({ + value: v, + label: v, +})); +const industryOptions = INDUSTRIES.map((v) => ({ value: v, label: v })); + +// Define initial values +const initialFormValues: SignUpFormValues = { + fullName: "", + email: "", + linkedin: "", + introduction: "", + referralSource: "", + matchingPreference: "", + industry: "", + role: "", + talkingPoints: "", + additionalInfo: "", +}; + +export function SignUpForm() { + // State management + const [values, setValues] = useState(() => ({ + ...initialFormValues, + })); + + const [errors, setErrors] = useState< + Partial> + >({}); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + const [submitError, setSubmitError] = useState(null); + + // Helper function + const normalizeLinkedin = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (trimmed.includes("linkedin.com/in/") && !trimmed.startsWith("http")) { + return `https://${trimmed}`; + } + return trimmed; + }; + + // Handlers + const handleFieldChange = ( + field: K, + value: SignUpFormValues[K], + ) => { + setValues((current) => ({ ...current, [field]: value })); + setErrors((current) => ({ ...current, [field]: undefined })); + setSuccessMessage(null); + setSubmitError(null); + }; + + const handleFieldBlur = ( + field: keyof SignUpFormValues, + overrideValue?: string, + ) => { + const valueToValidate = overrideValue ?? values[field]; + const fieldSchema = signUpFormSchema.pick({ [field]: true } as Record< + typeof field, + true + >); + const result = fieldSchema.safeParse({ [field]: valueToValidate }); + if (!result.success) { + const message = result.error.flatten().fieldErrors[field]?.[0]; + if (message) setErrors((current) => ({ ...current, [field]: message })); + } + }; + + // Validation logic + const validateForm = (values: SignUpFormValues) => { + const result = signUpFormSchema.safeParse(values); + const fieldErrors = + result.success ? {} : result.error?.flatten().fieldErrors; + const errors = {} as Partial>; + for (const key in fieldErrors) { + if (fieldErrors[key as keyof SignUpFormValues]?.[0]) { + errors[key as keyof SignUpFormValues] = + fieldErrors[key as keyof SignUpFormValues]?.[0]; + } + } + return errors; + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setSuccessMessage(null); + setSubmitError(null); + + const validationErrors = validateForm(values); + // console.log("errors:", validationErrors); + setErrors(validationErrors); + + if (Object.keys(validationErrors).length > 0) { + setSubmitError("Please fix the errors above before submitting."); + return; + } + + setIsSubmitting(true); + try { + // TODO: replace with API call + await Promise.resolve(); + setSuccessMessage("Your form was submitted successfully."); + } catch (_error) { + setSubmitError( + "There was a problem submitting the form. Please try again.", + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+ + Contact + + handleFieldChange("fullName", event.target.value) + } + onBlur={() => handleFieldBlur("fullName")} + error={errors.fullName} + autoFocus + /> + handleFieldChange("email", event.target.value)} + onBlur={() => handleFieldBlur("email")} + error={errors.email} + /> + + handleFieldChange("linkedin", event.target.value) + } + onBlur={() => { + const normalized = normalizeLinkedin(values.linkedin); + handleFieldChange("linkedin", normalized); + handleFieldBlur("linkedin", normalized); + }} + error={errors.linkedin} + /> + + Introduction +