Skip to content

Commit

Permalink
feat: ability to add phone number in profile 📲 (colorstackorg#482)
Browse files Browse the repository at this point in the history
  • Loading branch information
Hamza-Mos authored and iperalta7 committed Sep 25, 2024
1 parent 7be94ac commit f8d28a2
Show file tree
Hide file tree
Showing 6 changed files with 118 additions and 2 deletions.
17 changes: 17 additions & 0 deletions apps/member-profile/app/routes/_profile.profile.general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import { Student } from '@oyster/types';
import {
Button,
Divider,
Form,
getErrors,
InputField,
PhoneNumberInput,
validateForm,
} from '@oyster/ui';

Expand Down Expand Up @@ -52,6 +54,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
'genderPronouns',
'headline',
'lastName',
'phoneNumber',
'preferredName',
])
.executeTakeFirstOrThrow();
Expand All @@ -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(),
Expand Down Expand Up @@ -174,6 +178,19 @@ export default function UpdateGeneralInformationSection() {
longitudeName={keys.currentLocationLongitude}
/>

<Form.Field
description="Enter your 10-digit phone number. We'll use this to send you important ColorStack updates."
error={errors.phoneNumber}
label="Phone Number"
labelFor={keys.phoneNumber}
>
<PhoneNumberInput
defaultValue={student.phoneNumber || undefined}
id={keys.phoneNumber}
name={keys.phoneNumber}
/>
</Form.Field>

<Button.Group>
<Button.Submit>Save</Button.Submit>
</Button.Group>
Expand Down
12 changes: 12 additions & 0 deletions packages/db/src/migrations/20240924211318_phone_number.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { type Kysely } from 'kysely';

export async function up(db: Kysely<any>) {
await db.schema
.alterTable('students')
.addColumn('phone_number', 'text')
.execute();
}

export async function down(db: Kysely<any>) {
await db.schema.alterTable('students').dropColumn('phone_number').execute();
}
15 changes: 15 additions & 0 deletions packages/types/src/domain/student.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/components/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ type InputFieldProps = FieldProps<string> &
Pick<FormFieldProps, 'description' | 'label' | 'required'> &
Pick<InputProps, 'disabled' | 'placeholder'>;

/**
* @deprecated Instead, just compose the `Form.Field` and `Input` together.
*/
export function InputField({
defaultValue,
description,
Expand Down
71 changes: 70 additions & 1 deletion packages/ui/src/components/input.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { type HTMLInputTypeAttribute } from 'react';
import React, { type HTMLInputTypeAttribute, useState } from 'react';

import { cx } from '../utils/cx';

Expand Down Expand Up @@ -42,6 +42,75 @@ export const Input = React.forwardRef(
}
);

type PhoneNumberInputProps = Pick<InputProps, 'id' | 'name' | 'required'> & {
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 (
<>
<input
className={getInputCn()}
onChange={(e) => setValue(e.target.value)}
pattern="\(\d{3}\) \d{3}-\d{4}"
placeholder="(555) 123-4567"
type="tel"
value={formattedValue}
{...rest}
/>

<input name={name} type="hidden" value={rawValue} />
</>
);
}

/**
* 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',
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down

0 comments on commit f8d28a2

Please sign in to comment.