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';