diff --git a/apps/member-profile/app/routes/_profile.profile.general.tsx b/apps/member-profile/app/routes/_profile.profile.general.tsx index b56318831..2f00e1570 100644 --- a/apps/member-profile/app/routes/_profile.profile.general.tsx +++ b/apps/member-profile/app/routes/_profile.profile.general.tsx @@ -16,8 +16,10 @@ import { Student } from '@oyster/types'; import { Button, Divider, + Form, getErrors, InputField, + PhoneNumberInput, validateForm, } from '@oyster/ui'; @@ -52,6 +54,7 @@ export async function loader({ request }: LoaderFunctionArgs) { 'genderPronouns', 'headline', 'lastName', + 'phoneNumber', 'preferredName', ]) .executeTakeFirstOrThrow(); @@ -73,6 +76,7 @@ const UpdateGeneralInformation = Student.pick({ headline: true, lastName: true, preferredName: true, + phoneNumber: true, }).extend({ currentLocation: Student.shape.currentLocation.unwrap(), currentLocationLatitude: Student.shape.currentLocationLatitude.unwrap(), @@ -174,6 +178,19 @@ export default function UpdateGeneralInformationSection() { longitudeName={keys.currentLocationLongitude} /> + + + + Save diff --git a/packages/db/src/migrations/20240924211318_phone_number.ts b/packages/db/src/migrations/20240924211318_phone_number.ts new file mode 100644 index 000000000..cb1673939 --- /dev/null +++ b/packages/db/src/migrations/20240924211318_phone_number.ts @@ -0,0 +1,12 @@ +import { type Kysely } from 'kysely'; + +export async function up(db: Kysely) { + await db.schema + .alterTable('students') + .addColumn('phone_number', 'text') + .execute(); +} + +export async function down(db: Kysely) { + await db.schema.alterTable('students').dropColumn('phone_number').execute(); +} diff --git a/packages/types/src/domain/student.ts b/packages/types/src/domain/student.ts index 95d43b6b7..52fe4dd4a 100644 --- a/packages/types/src/domain/student.ts +++ b/packages/types/src/domain/student.ts @@ -159,6 +159,21 @@ export const Student = Entity.merge(StudentSocialLinks) otherMajor: z.string().optional(), otherSchool: z.string().optional(), + /** + * A 10-digit phone number without any formatting. Note that since we only + * serve US and Canadian students, we will not worry about asking for nor + * storing the country code, it is by default +1. We will only store + * 10-digit values without any formatting (ie: parentheses, dashes, etc). + * + * @example "1112223333" + * @example "1234567890" + */ + phoneNumber: z + .string() + .trim() + .regex(/^\d{10}$/, 'Must be a 10-digit number.') + .optional(), + /** * The preferred name that a member would like to go by. This will typically * just be a first name. diff --git a/packages/ui/src/components/form.tsx b/packages/ui/src/components/form.tsx index 41dbfbc9b..b381b606c 100644 --- a/packages/ui/src/components/form.tsx +++ b/packages/ui/src/components/form.tsx @@ -73,6 +73,9 @@ type InputFieldProps = FieldProps & Pick & Pick; +/** + * @deprecated Instead, just compose the `Form.Field` and `Input` together. + */ export function InputField({ defaultValue, description, diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx index af7f0c6bc..a01f2d95c 100644 --- a/packages/ui/src/components/input.tsx +++ b/packages/ui/src/components/input.tsx @@ -1,4 +1,4 @@ -import React, { type HTMLInputTypeAttribute } from 'react'; +import React, { type HTMLInputTypeAttribute, useState } from 'react'; import { cx } from '../utils/cx'; @@ -42,6 +42,75 @@ export const Input = React.forwardRef( } ); +type PhoneNumberInputProps = Pick & { + defaultValue?: string; // Limit the default value to a string. +}; + +export function PhoneNumberInput({ + defaultValue, + name, + ...rest +}: PhoneNumberInputProps) { + const [value, setValue] = useState(defaultValue || ''); + + const formattedValue = formatPhoneNumber(value); + const rawValue = formattedValue.replace(/\D/g, ''); + + return ( + <> + setValue(e.target.value)} + pattern="\(\d{3}\) \d{3}-\d{4}" + placeholder="(555) 123-4567" + type="tel" + value={formattedValue} + {...rest} + /> + + + + ); +} + +/** + * Formats a phone number to the format: (xxx) xxx-xxxx. + * + * @param number - The phone number to format. + * @returns The formatted phone number. + * + * @example + * formatPhoneNumber("") => "" + * formatPhoneNumber("1") => "(1" + * formatPhoneNumber("12") => "(12" + * formatPhoneNumber("123") => "(123" + * formatPhoneNumber("1234") => "(123) 4" + * formatPhoneNumber("12345") => "(123) 45" + * formatPhoneNumber("123456") => "(123) 456" + * formatPhoneNumber("1234567") => "(123) 456-7" + * formatPhoneNumber("12345678") => "(123) 456-78" + * formatPhoneNumber("123456789") => "(123) 456-789" + * formatPhoneNumber("1234567890") => "(123) 456-7890" + * formatPhoneNumber("1234567890123") => "(123) 456-7890" + */ +function formatPhoneNumber(input: string): string { + const digits = input.replace(/\D/g, ''); + + if (digits.length === 0) { + return ''; + } + + if (digits.length <= 3) { + return `(${digits}`; + } + + if (digits.length <= 6) { + return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + } + + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`; +} + export function getInputCn() { return cx( 'w-full rounded-lg border border-gray-300 p-2', diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index f82e481ba..a30fb1cc9 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,7 +13,7 @@ export { MB_IN_BYTES, FileUploader } from './components/file-uploader'; export { Form, getErrors, InputField, validateForm } from './components/form'; export type { DescriptionProps, FieldProps } from './components/form'; export { IconButton, getIconButtonCn } from './components/icon-button'; -export { Input, getInputCn } from './components/input'; +export { Input, PhoneNumberInput, getInputCn } from './components/input'; export type { InputProps } from './components/input'; export { Link } from './components/link'; export { Login } from './components/login';