diff --git a/app/components/forms.tsx b/app/components/forms.tsx index 79fdedb..bd62947 100644 --- a/app/components/forms.tsx +++ b/app/components/forms.tsx @@ -67,6 +67,33 @@ export function Field({ ) } +export function HeightField({ + labelProps, + inputProps, + errors, + className, +}: { + labelProps: React.LabelHTMLAttributes + inputProps: React.InputHTMLAttributes + errors?: ListOfErrors + className?: string +}) { + const fallbackId = useId() + const id = inputProps.id ?? fallbackId + const errorId = errors?.length ? `${id}-error` : undefined + return ( +
+
+ ) +} + export function DatePickerField({ labelProps, errors, diff --git a/app/routes/settings+/profile.tsx b/app/routes/settings+/profile.tsx index d373a43..8e53389 100644 --- a/app/routes/settings+/profile.tsx +++ b/app/routes/settings+/profile.tsx @@ -1,4 +1,4 @@ -import { conform, useForm } from '@conform-to/react' +import { conform, useFieldset, useForm } from '@conform-to/react' import { getFieldsetConstraint, parse } from '@conform-to/zod' import { json, @@ -20,7 +20,12 @@ import { verifyLogin, } from '~/utils/auth.server.ts' import { prisma } from '~/utils/db.server.ts' -import { CheckboxField, ErrorList, Field } from '~/components/forms.tsx' +import { + CheckboxField, + ErrorList, + Field, + HeightField, +} from '~/components/forms.tsx' import { Button } from '~/components/ui/button.tsx' import { StatusButton } from '~/components/ui/status-button.tsx' import { getUserImgSrc } from '~/utils/misc.ts' @@ -38,24 +43,52 @@ import { checkboxSchema, optionalDateTimeZoneSchema, } from '~/utils/zod-extensions.ts' +import { + convertFeetInchesIntoInches, + convertInchesToHeightObj, +} from '~/utils/length-conversions.ts' const profileFormSchema = z.object({ name: nameSchema.optional(), username: usernameSchema, email: emailSchema.optional(), mailingList: checkboxSchema(), - birthdate: optionalDateTimeZoneSchema, + birthdate: optionalDateTimeZoneSchema.optional(), phone: phoneSchema, - height: z.coerce - .number() - .int({ message: 'Height must be an integer in inches' }) - .min(0) - .optional(), yearsOfExperience: z.coerce.number().int().min(0).optional(), currentPassword: z .union([passwordSchema, z.string().min(0).max(0)]) .optional(), newPassword: z.union([passwordSchema, z.string().min(0).max(0)]).optional(), + height: z + .object({ + heightFeet: z.coerce + .number({ invalid_type_error: 'Feet must be a number' }) + .int({ message: 'Feet must be an integer' }) + .min(0, { message: 'Feet must be between 0 and 8' }) + .max(8, { message: 'Feet must be between 0 and 8' }) + .optional(), + heightInches: z.coerce + .number({ invalid_type_error: 'Inches must be a number' }) + .int({ message: 'Inches must be an integer' }) + .min(0, { message: 'Inches must be between 0 and 12' }) + .max(12, { message: 'Inches must be between 0 and 12' }) + .optional(), + }) + .refine( + obj => { + return ( + (obj.heightFeet && obj.heightInches) || + (!obj.heightFeet && !obj.heightInches) + ) + }, + { message: 'You must enter both feet and inches for height' }, + ) + .transform(val => { + if (val.heightFeet && val.heightInches) { + return convertFeetInchesIntoInches(val.heightFeet, val.heightInches) + } else return null + }), }) export async function loader({ request }: DataFunctionArgs) { @@ -181,6 +214,11 @@ export default function EditUserProfile() { formattedBirthdate = format(data.user.birthdate, 'yyyy-MM-dd') } + let userHeight + if (data.user.height) { + userHeight = convertInchesToHeightObj(data.user.height) + } + const [form, fields] = useForm({ id: 'edit-profile', constraint: getFieldsetConstraint(profileFormSchema), @@ -195,11 +233,12 @@ export default function EditUserProfile() { mailingList: data.user.mailingList ? 'on' : undefined, phone: data.user.phone, birthdate: formattedBirthdate ?? '', - height: data.user.height ?? '', + height: userHeight ?? undefined, yearsOfExperience: data.user.yearsOfExperience ?? '', }, shouldRevalidate: 'onBlur', }) + const { heightFeet, heightInches } = useFieldset(form.ref, fields.height) return (
@@ -309,18 +348,64 @@ export default function EditUserProfile() { }} errors={fields.birthdate.errors} /> - +
+ +
+ + +
+ {fields.height.errors && ( + + )} + {heightFeet.errors && ( + + )} + {heightInches.errors && ( + + )} +
+
+
{age ?

{`Age: ${age}`}

: null} {data.user.height ? ( -

{`Height: ${data.user.height}`}

+

{`Height: ${displayHeightFromInches( + data.user.height, + )}`}

) : null} {data.user.yearsOfExperience !== null ? (

diff --git a/app/styles/tailwind.css b/app/styles/tailwind.css index 80a8f2c..ef6ba47 100644 --- a/app/styles/tailwind.css +++ b/app/styles/tailwind.css @@ -104,4 +104,13 @@ @apply bg-background text-foreground; font-feature-settings: 'rlig' 1, 'calt' 1; } + /* Remove number arrows for height fields */ + .heightField::-webkit-outer-spin-button, + .heightField::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + .heightField[type='number'] { + -moz-appearance: textfield; + } } diff --git a/app/utils/length-conversions.ts b/app/utils/length-conversions.ts new file mode 100644 index 0000000..eae179a --- /dev/null +++ b/app/utils/length-conversions.ts @@ -0,0 +1,20 @@ +export function convertFeetInchesIntoInches( + feet: number, + inches: number, +): number { + return feet * 12 + inches +} + +export function convertInchesToHeightObj(height: number) { + const heightFeet = Math.floor(height / 12) + const heightInches = height % 12 + return { + heightFeet, + heightInches, + } +} + +export function displayHeightFromInches(height: number) { + const heightObj = convertInchesToHeightObj(height) + return `${heightObj.heightFeet}'${heightObj.heightInches}"` +}