Repository: austin-corwin/liquid-miles-web
Pull request #11: [Contact Forms] Add Static Contact form
Author: login: MoxieEric
Created: 2025-04-22T14:43:06Z | Merged: 2025-04-24T15:58:50Z

Add a basic form to the contact page. 

**Preview** - [Contact Page](https://liquid-miles-web-git-static-contact-form-austincorwins-projects.vercel.app/contact)

Establishes an initial set of components for the forms feature. Nothing happens on submit and makes no assumptions about data source for form creation/configs and the destination of submissions -- just components. 


Diff:
diff --git a/src/app/(nonAuth)/(informational)/contact/page.tsx b/src/app/(nonAuth)/(informational)/contact/page.tsx
index b0d2544..4304624 100644
--- a/src/app/(nonAuth)/(informational)/contact/page.tsx
+++ b/src/app/(nonAuth)/(informational)/contact/page.tsx
@@ -1,6 +1,13 @@
-"use client";
-import Contact from "@/features/Contact";
+import Contact from '@/features/Contact'
+import { Metadata } from 'next'
+import React from 'react'
 
-export default function Home() {
-  return <Contact />;
+export const metadata: Metadata = {
+  title: 'Contact | Liquid Miles',
 }
+
+const Page: React.FC = () => {
+  return <Contact />
+}
+
+export default Page
diff --git a/src/features/Contact.tsx b/src/features/Contact.tsx
index da1855c..e0e5edc 100644
--- a/src/features/Contact.tsx
+++ b/src/features/Contact.tsx
@@ -1,6 +1,17 @@
-import { Divider } from '@chakra-ui/react'
+import {
+  Box,
+  Divider,
+  Heading,
+  Link,
+  Stack,
+  Text,
+  VStack,
+} from '@chakra-ui/react'
 import { PageHeader } from './Pages/components/PageHeader'
 import { PageLayout } from './Pages/components/PageLayout'
+import { headingRecipes } from './chakra-ui/config/recipes'
+import { textRecipes } from './chakra-ui/config/recipes/textRecipes'
+import { ContactForm } from './contactForms/components/forms/ContactForm'
 
 const Contact = () => {
   return (
@@ -8,12 +19,41 @@ const Contact = () => {
       <PageHeader title='Contact' color='white'>
         <Divider borderTopColor='white' borderTopWidth={2} opacity={1} />
       </PageHeader>
-      <p className='text-white font-primary w-full'>
-        Contact us at{' '}
-        <a className='underline' href='mailto:liquidmilesrace@gmail.com'>
-          liquidmilesrace@gmail.com
-        </a>
-      </p>
+
+      <Stack
+        flexDirection={{ base: 'column', md: 'row' }}
+        gap={{ base: 6, md: 8, xl: 16 }}
+        alignItems='flex-start'
+        w='full'
+      >
+        <VStack w='full' maxWidth='container.md' alignItems='start' gap={4}>
+          <Heading {...headingRecipes.H3} color='white'>
+            Got Questions?
+          </Heading>
+
+          <Text {...textRecipes.Lead} {...textRecipes.Light}>
+            You might find what you need among the{' '}
+            <Link href='/faqs' color={{ base: 'primary' }}>
+              FAQs
+            </Link>{' '}
+            page or on the <Link href='/rules'>rules</Link> page.
+          </Text>
+          <Text {...textRecipes.Body} {...textRecipes.Light}>
+            For anything else get in touch using the form or{' '}
+            <a href='mailto:liquidmilesrace@gmail.com'>email us</a> and
+            we&#39;ll get back to you as soon as we can.
+          </Text>
+        </VStack>
+        <Box
+          w='full'
+          maxWidth='xl'
+          bg='white'
+          rounded='lg'
+          p={{ base: 8, md: 12 }}
+        >
+          <ContactForm />
+        </Box>
+      </Stack>
     </PageLayout>
   )
 }
diff --git a/src/features/api/utils/sleep.ts b/src/features/api/utils/sleep.ts
new file mode 100644
index 0000000..1e8a123
--- /dev/null
+++ b/src/features/api/utils/sleep.ts
@@ -0,0 +1,10 @@
+/**
+ * Simulate a delay of a given duration.
+ * Useful stand-in for mocking api calls without making network requests.
+ * @param duration - Milliseconds to wait before resolving promise
+ */
+const sleep = async (duration: number = 500): Promise<void> => {
+  return new Promise((resolve) => setTimeout(resolve, duration))
+}
+
+export { sleep }
diff --git a/src/features/chakra-ui/config/components/inputConfig.ts b/src/features/chakra-ui/config/components/inputConfig.ts
index 9988eca..9edf641 100644
--- a/src/features/chakra-ui/config/components/inputConfig.ts
+++ b/src/features/chakra-ui/config/components/inputConfig.ts
@@ -7,13 +7,24 @@ const { definePartsStyle, defineMultiStyleConfig } =
 const baseStyle = definePartsStyle({
   addon: {},
   field: {
-    fontWeight: 'semibold',
+    fontWeight: 'medium',
     _placeholder: {
-      color: 'gray.300',
+      color: 'gray.400',
       fontFamily: 'poppins',
-      fontWeight: 'semibold',
+      fontWeight: 'medium',
+    },
+    _groupHover: {
+      outlineColor: 'gray.200',
+      borderColor: 'black',
+    },
+    _hover: {
+      borderColor: 'black',
+    },
+    _focus: {
+      ringColor: 'black',
+      outlineColor: 'gray.300',
+      borderColor: 'black',
     },
-    _focus: {},
     _invalid: {
       color: 'red.700',
       _placeholder: {
diff --git a/src/features/chakra-ui/config/components/textareaConfig.ts b/src/features/chakra-ui/config/components/textareaConfig.ts
new file mode 100644
index 0000000..62907d8
--- /dev/null
+++ b/src/features/chakra-ui/config/components/textareaConfig.ts
@@ -0,0 +1,36 @@
+import { defineStyle, defineStyleConfig } from '@chakra-ui/react'
+
+const baseStyle = defineStyle({
+  fontWeight: 'medium',
+  _placeholder: {
+    color: 'gray.400',
+    fontFamily: 'poppins',
+    fontWeight: 'medium',
+  },
+  _groupHover: {
+    outlineColor: 'gray.200',
+    borderColor: 'black',
+  },
+  _hover: {
+    borderColor: 'black',
+  },
+  _focus: {
+    ringColor: 'black',
+    outlineColor: 'gray.300',
+    borderColor: 'black',
+  },
+  _invalid: {
+    color: 'red.700',
+    _placeholder: {
+      color: 'red.400',
+    },
+  },
+})
+
+const textareaConfig = defineStyleConfig({
+  baseStyle,
+  variants: {},
+  defaultProps: {},
+})
+
+export { textareaConfig }
diff --git a/src/features/chakra-ui/config/componentsConfig.ts b/src/features/chakra-ui/config/componentsConfig.ts
index b5eb2a8..21b6a76 100644
--- a/src/features/chakra-ui/config/componentsConfig.ts
+++ b/src/features/chakra-ui/config/componentsConfig.ts
@@ -13,8 +13,10 @@ import {
   selectConfig,
   skeletonConfig,
 } from './components'
+import { textareaConfig } from './components/textareaConfig'
 import { alertConfig } from './components/alertConfig'
 
+
 const componentsConfig: Record<string, ComponentStyleConfig> = {
   Accordion: accordionConfig,
   Alert: alertConfig,
@@ -29,6 +31,7 @@ const componentsConfig: Record<string, ComponentStyleConfig> = {
   Radio: radioConfig,
   Select: selectConfig,
   Skeleton: skeletonConfig,
+  Textarea: textareaConfig,
 }
 
 export { componentsConfig }
diff --git a/src/features/chakra-ui/config/recipes/inputRecipes.tsx b/src/features/chakra-ui/config/recipes/inputRecipes.tsx
new file mode 100644
index 0000000..17a13fd
--- /dev/null
+++ b/src/features/chakra-ui/config/recipes/inputRecipes.tsx
@@ -0,0 +1,13 @@
+import { InputProps, TextareaProps } from '@chakra-ui/react'
+
+export enum InputRecipe {
+  Default = 'Default',
+}
+const inputRecipes: Record<InputRecipe, InputProps & TextareaProps> = {
+  Default: {
+    errorBorderColor: 'error',
+    focusBorderColor: 'black',
+  },
+}
+
+export { inputRecipes }
diff --git a/src/features/chakra-ui/config/recipes/textRecipes.ts b/src/features/chakra-ui/config/recipes/textRecipes.ts
index 81883c3..e7926f4 100644
--- a/src/features/chakra-ui/config/recipes/textRecipes.ts
+++ b/src/features/chakra-ui/config/recipes/textRecipes.ts
@@ -5,6 +5,7 @@ export enum TextRecipe {
   Body = 'Body',
   Compact = 'Compact',
   FinePrint = 'FinePrint',
+  Light = 'Light',
 }
 const textRecipes: Record<TextRecipe, TextProps> = {
   Lead: {
@@ -53,6 +54,14 @@ const textRecipes: Record<TextRecipe, TextProps> = {
       },
     },
   },
+  Light: {
+    color: 'white',
+    sx: {
+      '> a': {
+        color: 'primary',
+      },
+    },
+  },
 }
 
 export { textRecipes }
diff --git a/src/features/contactForms/components/fields/TextField.tsx b/src/features/contactForms/components/fields/TextField.tsx
new file mode 100644
index 0000000..44aed9d
--- /dev/null
+++ b/src/features/contactForms/components/fields/TextField.tsx
@@ -0,0 +1,68 @@
+'use client'
+
+import { inputRecipes } from '@/features/chakra-ui/config/recipes/inputRecipes'
+import {
+  Fade,
+  FormControl,
+  FormControlProps,
+  FormHelperText,
+  FormLabel,
+  Input,
+  InputProps,
+  Textarea,
+  TextareaProps,
+} from '@chakra-ui/react'
+import React from 'react'
+
+interface TextFieldProps extends FormControlProps {
+  id: string
+  fieldType: InputProps['type'] | 'textarea'
+  helperText?: React.ReactNode
+  error?: React.ReactNode
+  placeholder?: InputProps['placeholder']
+  inputProps?: InputProps & TextareaProps
+}
+
+/**
+ * Form field for single and multi-line text and number inputs.
+ */
+const TextField: React.FC<TextFieldProps> = ({
+  fieldType,
+  label,
+  helperText,
+  placeholder,
+  error,
+  id,
+  inputProps,
+  ...formControlProps
+}) => {
+  const fieldProps: InputProps & TextareaProps = {
+    ...inputRecipes.Default,
+    placeholder,
+    name: id,
+    'aria-label': label,
+    ...inputProps,
+  }
+  return (
+    <FormControl
+      isRequired={formControlProps?.isRequired}
+      isReadOnly={formControlProps?.isReadOnly}
+      id={id}
+    >
+      <FormLabel>{label}</FormLabel>
+      {fieldType === 'textarea' ? (
+        <Textarea {...fieldProps} />
+      ) : (
+        <Input type={fieldType} {...fieldProps} />
+      )}
+      <Fade in={!!error}>
+        <FormHelperText color='error' fontWeight='medium'>
+          {error}
+        </FormHelperText>
+      </Fade>
+      {helperText && <FormHelperText>{helperText}</FormHelperText>}
+    </FormControl>
+  )
+}
+
+export { TextField }
diff --git a/src/features/contactForms/components/fields/index.ts b/src/features/contactForms/components/fields/index.ts
new file mode 100644
index 0000000..dd7d2ac
--- /dev/null
+++ b/src/features/contactForms/components/fields/index.ts
@@ -0,0 +1 @@
+export * from './TextField'
diff --git a/src/features/contactForms/components/forms/ContactForm.tsx b/src/features/contactForms/components/forms/ContactForm.tsx
new file mode 100644
index 0000000..e2d3748
--- /dev/null
+++ b/src/features/contactForms/components/forms/ContactForm.tsx
@@ -0,0 +1,52 @@
+'use client'
+
+import { sleep } from '@/features/api/utils/sleep'
+import React, { FormEvent } from 'react'
+import { TextField } from '../fields/TextField'
+import { Form } from '../partials/Form'
+import { FormControls } from '../partials/FormControls'
+
+const ContactForm: React.FC = () => {
+  const [isSubmitting, setIsSubmitting] = React.useState<boolean>(false)
+
+  const handleSubmit = async (e: FormEvent) => {
+    e.preventDefault()
+    setIsSubmitting(true)
+    await sleep(1000)
+    console.log('form submitted', e)
+    setIsSubmitting(false)
+  }
+
+  return (
+    <Form id='contact-form' onSubmit={handleSubmit}>
+      <TextField
+        id='name'
+        label='Name'
+        fieldType='text'
+        isRequired
+        isReadOnly={isSubmitting}
+        helperText={'What should we call you?'}
+      />
+      <TextField
+        id='email'
+        label='Email'
+        fieldType='email'
+        isRequired
+        isReadOnly={isSubmitting}
+      />
+      <TextField
+        id='message'
+        label='Message'
+        fieldType='textarea'
+        isReadOnly={isSubmitting}
+      />
+      <FormControls
+        isLoading={isSubmitting}
+        submitLabel='Send It'
+        loadingText='Sending'
+      />
+    </Form>
+  )
+}
+
+export { ContactForm }
diff --git a/src/features/contactForms/components/partials/Form.tsx b/src/features/contactForms/components/partials/Form.tsx
new file mode 100644
index 0000000..2282aa9
--- /dev/null
+++ b/src/features/contactForms/components/partials/Form.tsx
@@ -0,0 +1,20 @@
+'use client'
+
+import { StackProps, VStack } from '@chakra-ui/react'
+import React from 'react'
+
+interface FormProps extends StackProps {
+  id: string
+}
+/**
+ * Form element and wrapper for form fields
+ */
+const Form: React.FC<FormProps> = ({ children, onSubmit }) => {
+  return (
+    <VStack as='form' alignItems='start' gap={6} w='full' onSubmit={onSubmit}>
+      {children}
+    </VStack>
+  )
+}
+
+export { Form }
diff --git a/src/features/contactForms/components/partials/FormControls.tsx b/src/features/contactForms/components/partials/FormControls.tsx
new file mode 100644
index 0000000..adfabac
--- /dev/null
+++ b/src/features/contactForms/components/partials/FormControls.tsx
@@ -0,0 +1,43 @@
+'use client'
+
+import {
+  Button,
+  ButtonGroup,
+  ButtonGroupProps,
+  ButtonProps,
+} from '@chakra-ui/react'
+import React from 'react'
+
+interface FormControlsProps extends ButtonGroupProps {
+  submitLabel: string
+  loadingText?: string
+  submitButtonProps?: ButtonProps
+  isLoading?: boolean
+}
+
+/**
+ * Form controls. e.g. submit buttons, pagination
+ */
+const FormControls: React.FC<FormControlsProps> = ({
+  submitLabel = 'Submit',
+  isLoading,
+  loadingText,
+  submitButtonProps,
+  ...buttonGroupProps
+}) => {
+  return (
+    <ButtonGroup isDisabled={isLoading} {...buttonGroupProps}>
+      <Button
+        type='submit'
+        isLoading={isLoading}
+        loadingText={loadingText}
+        minW={40}
+        {...submitButtonProps}
+      >
+        {submitLabel}
+      </Button>
+    </ButtonGroup>
+  )
+}
+
+export { FormControls }


Reviews:
user: login: MoxieEric
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T21:17:31Z

Review comments:
diff_hunk: @@ -1,19 +1,61 @@
-import { Divider } from '@chakra-ui/react'
+import {
+  Box,
+  Divider,
+  Heading,
+  Link,
+  Stack,
+  Text,
+  VStack,
+} from '@chakra-ui/react'
 import { PageHeader } from './Pages/components/PageHeader'
 import { PageLayout } from './Pages/components/PageLayout'
+import { headingRecipes } from './chakra-ui/config/recipes'
+import { textRecipes } from './chakra-ui/config/recipes/textRecipes'
+import { ContactForm } from './contactForms/components/forms/ContactForm'
 
 const Contact = () => {
   return (
     <PageLayout bg='secondary'>
       <PageHeader title='Contact' color='white'>
         <Divider borderTopColor='white' borderTopWidth={2} opacity={1} />
       </PageHeader>
-      <p className='text-white font-primary w-full'>
-        Contact us at{' '}
-        <a className='underline' href='mailto:liquidmilesrace@gmail.com'>
-          liquidmilesrace@gmail.com
-        </a>
-      </p>
+
+      <Stack
+        flexDirection={{ base: 'column', md: 'row' }}
+        gap={{ base: 6, md: 8, xl: 16 }}
+        alignItems='flex-start'
+        w='full'
+      >
+        <VStack w='full' maxWidth='container.md' alignItems='start' gap={4}>
+          <Heading {...headingRecipes.H3} color='white'>
+            Got Questions?
+          </Heading>
+
+          <Text {...textRecipes.Lead} {...textRecipes.Light}>
+            You might find what you need among the{' '}
path: src/features/Contact.tsx
user: login: MoxieEric
body: Content here is static for now, but it will be easy to hook it up to a contentful page in a future PR. In the meantime please feel free to make or provide edits. This is just super basic contact page copy; one step above FPO. 
created_at: 2025-04-23T21:10:41Z
author_association: COLLABORATOR
diff_hunk: @@ -1,19 +1,61 @@
-import { Divider } from '@chakra-ui/react'
+import {
+  Box,
+  Divider,
+  Heading,
+  Link,
+  Stack,
+  Text,
+  VStack,
+} from '@chakra-ui/react'
 import { PageHeader } from './Pages/components/PageHeader'
 import { PageLayout } from './Pages/components/PageLayout'
+import { headingRecipes } from './chakra-ui/config/recipes'
+import { textRecipes } from './chakra-ui/config/recipes/textRecipes'
+import { ContactForm } from './contactForms/components/forms/ContactForm'
 
 const Contact = () => {
   return (
     <PageLayout bg='secondary'>
       <PageHeader title='Contact' color='white'>
         <Divider borderTopColor='white' borderTopWidth={2} opacity={1} />
       </PageHeader>
-      <p className='text-white font-primary w-full'>
-        Contact us at{' '}
-        <a className='underline' href='mailto:liquidmilesrace@gmail.com'>
-          liquidmilesrace@gmail.com
-        </a>
-      </p>
+
+      <Stack
+        flexDirection={{ base: 'column', md: 'row' }}
+        gap={{ base: 6, md: 8, xl: 16 }}
+        alignItems='flex-start'
+        w='full'
+      >
+        <VStack w='full' maxWidth='container.md' alignItems='start' gap={4}>
+          <Heading {...headingRecipes.H3} color='white'>
+            Got Questions?
+          </Heading>
+
+          <Text {...textRecipes.Lead} {...textRecipes.Light}>
+            You might find what you need among the{' '}
+            <Link href='/faqs' color={{ base: 'primary' }}>
+              FAQs
+            </Link>{' '}
+            page or on the <Link href='/rules'>rules</Link> page.
+          </Text>
+          <Text {...textRecipes.Body} {...textRecipes.Light}>
+            For anything else get in touch using the form or{' '}
+            <a className='underline' href='mailto:liquidmilesrace@gmail.com'>
+              email us
+            </a>{' '}
path: src/features/Contact.tsx
user: login: MoxieEric
body: - [x] Classname should be unnecessary due to recipe, remove if redundant
created_at: 2025-04-23T21:11:33Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,69 @@
+'use client'
+
+import { inputRecipes } from '@/features/chakra-ui/config/recipes/inputRecipes'
+import {
+  Fade,
+  FormControl,
+  FormControlProps,
+  FormHelperText,
+  FormLabel,
+  Input,
+  InputProps,
+  Textarea,
+  TextareaProps,
+} from '@chakra-ui/react'
+import React from 'react'
+
+interface TextFieldProps extends FormControlProps {
+  id: string
+  fieldType: InputProps['type'] | 'textarea'
+  helperText?: React.ReactNode
+  error?: React.ReactNode
+  placeholder?: InputProps['placeholder']
+  inputProps?: InputProps & TextareaProps
+}
+
+const TextField: React.FC<TextFieldProps> = ({
+  fieldType,
+  label,
+  helperText,
+  placeholder,
+  error,
+  id,
+  inputProps,
+  ...formControlProps
+}) => {
+  const fieldProps: InputProps & TextareaProps = {
+    ...inputRecipes.Default,
+    placeholder,
+    name: id,
+    'aria-label': label,
+    ...inputProps,
+  }
+  return (
+    <FormControl
+      isRequired={formControlProps?.isRequired}
+      isReadOnly={formControlProps?.isReadOnly}
+      id={id}
+    >
+      <FormLabel>{label}</FormLabel>
+      {fieldType === 'textarea' ? (
+        <Textarea
+          {...fieldProps}
+          // @todo: move to component config
+          _hover={{ borderColor: 'black', outlineColor: 'gray.200' }}
path: src/features/contactForms/components/fields/TextField.tsx
user: login: MoxieEric
body: ### 🙏 Changes

- [x] Resolve todo
created_at: 2025-04-23T21:15:17Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,55 @@
+'use client'
+
+import React, { FormEvent } from 'react'
+import { TextField } from '../fields/TextField'
+import { Form } from '../partials/Form'
+import { FormControls } from '../partials/FormControls'
+
+const simulateSubmission = async (ms: number = 500) => {
+  return new Promise((resolve) => setTimeout(resolve, ms))
+}
path: src/features/contactForms/components/forms/ContactForm.tsx
user: login: MoxieEric
body: ### 🙏 Changes

- [x] Make agnostic and move to utility 
created_at: 2025-04-23T21:16:11Z
author_association: COLLABORATOR

PR comments:
user: login: vercel[bot]
created_at: 2025-04-22T14:43:11Z
body: [vc]: #tqtwcq001WAGuYVp2WQADz5nsaojZM7/ECwEOCQeKB8=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJsaXF1aWQtbWlsZXMtd2ViIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImxpcXVpZC1taWxlcy13ZWItZ2l0LXN0YXRpYy1jb250YWN0LWZvcm0tYXVzdGluY29yd2lucy1wcm9qZWN0cy52ZXJjZWwuYXBwIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9hdXN0aW5jb3J3aW5zLXByb2plY3RzL2xpcXVpZC1taWxlcy13ZWIvQlh6UXFqZFFrRm5QN0dvWmJNaXRuOFA3b2VXSyIsInByZXZpZXdVcmwiOiJsaXF1aWQtbWlsZXMtd2ViLWdpdC1zdGF0aWMtY29udGFjdC1mb3JtLWF1c3RpbmNvcndpbnMtcHJvamVjdHMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsInJvb3REaXJlY3RvcnkiOm51bGx9XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **liquid-miles-web** | ✅ Ready ([Inspect](https://vercel.com/austincorwins-projects/liquid-miles-web/BXzQqjdQkFnP7GoZbMitn8P7oeWK)) | [Visit Preview](https://liquid-miles-web-git-static-contact-form-austincorwins-projects.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/liquid-miles-web-git-static-contact-form-austincorwins-projects.vercel.app?via=pr-comment-feedback-link) | Apr 24, 2025 3:55pm |


author_association: NONE
user: login: MoxieEric
created_at: 2025-04-23T21:52:15Z
body: @austin-corwin I tagged you as a reviewer on this for visibility, but I may merge it pretty soon since it is going into a feature branch. You'll get another chance to review this when that feature branch (#10 ) is complete.
author_association: COLLABORATOR