diff --git a/apps/member-profile/app/routes/_profile.home.claim-swag-pack._index.tsx b/apps/member-profile/app/routes/_profile.home.claim-swag-pack._index.tsx index 180646c9..34decaa6 100644 --- a/apps/member-profile/app/routes/_profile.home.claim-swag-pack._index.tsx +++ b/apps/member-profile/app/routes/_profile.home.claim-swag-pack._index.tsx @@ -54,16 +54,12 @@ export async function loader({ request }: LoaderFunctionArgs) { }); } -const ClaimSwagPackFormData = ClaimSwagPackInput.omit({ - studentId: true, -}); - export async function action({ request }: ActionFunctionArgs) { const session = await ensureUserAuthenticated(request); const { data, errors, ok } = await validateForm( request, - ClaimSwagPackFormData + ClaimSwagPackInput.omit({ studentId: true }) ); if (!ok) { @@ -71,8 +67,9 @@ export async function action({ request }: ActionFunctionArgs) { } try { - await claimSwagPack({ + const result = await claimSwagPack({ addressCity: data.addressCity, + addressCountry: data.addressCountry, addressLine1: data.addressLine1, addressLine2: data.addressLine2, addressState: data.addressState, @@ -80,6 +77,10 @@ export async function action({ request }: ActionFunctionArgs) { studentId: user(session), }); + if (!result.ok) { + return json({ error: result.error }, { status: result.code }); + } + return redirect(Route['/home/claim-swag-pack/confirmation']); } catch (e) { reportException(e); @@ -91,7 +92,7 @@ export async function action({ request }: ActionFunctionArgs) { } } -const keys = ClaimSwagPackFormData.keyof().enum; +const keys = ClaimSwagPackInput.keyof().enum; export default function ClaimSwagPack() { const { inventoryPromise } = useLoaderData(); @@ -142,6 +143,19 @@ function ClaimSwagPackForm() { Let us know where to send your swag pack!
+ + + + - { + // We save the address regardless if the swag pack order failed or not so + // we'll be able to send them something in the future. const student = await db - .selectFrom('students') - .select(['email', 'firstName', 'lastName']) + .updateTable('students') + .set({ + addressCity, + addressCountry, + addressLine1, + addressLine2, + addressState, + addressZip, + }) .where('id', '=', studentId) + .returning(['email', 'firstName', 'lastName']) .executeTakeFirstOrThrow(); + // Currently, SwagUp only supports the US, but not Puerto Rico. + // See: https://support.swagup.com/en/articles/6952397-international-shipments-restricted-items + const isAddressSupported = addressCountry === 'US' && addressState !== 'PR'; + + // If the address isn't supported, then we'll send a notification to our + // team to create a gift card manually for them. + if (!isAddressSupported) { + const notification = dedent` + ${student.firstName} ${student.lastName} (${student.email}) is attempting to claim a swag pack, but they're either from Puerto Rico or Canada, which is not supported for our product. + + We let them know we'll send them a merch store gift card in the next "few days"! + `; + + job('notification.slack.send', { + message: notification, + workspace: 'internal', + }); + + const error = dedent` + Unfortunately, our swag pack provider, SwagUp, does not support shipments to Puerto Rico and Canada. Instead, we will send you a gift card to our official merch store. + + Our team has been notified, please give us a few days to complete this request! + `; + + return fail({ + code: 400, + error, + }); + } + const swagPackOrderId = await orderSwagPack({ contact: { address: { city: addressCity, + country: addressCountry, line1: addressLine1, line2: addressLine2, state: addressState, @@ -35,14 +81,11 @@ export async function claimSwagPack({ await db .updateTable('students') .set({ - addressCity, - addressLine1, - addressLine2, - addressState, - addressZip, claimedSwagPackAt: new Date(), swagUpOrderId: swagPackOrderId, }) .where('id', '=', studentId) .execute(); + + return success({}); } diff --git a/packages/db/src/migrations/20240822172825_students_address_country.ts b/packages/db/src/migrations/20240822172825_students_address_country.ts new file mode 100644 index 00000000..9f4e60aa --- /dev/null +++ b/packages/db/src/migrations/20240822172825_students_address_country.ts @@ -0,0 +1,15 @@ +import { type Kysely } from 'kysely'; + +export async function up(db: Kysely) { + await db.schema + .alterTable('students') + .addColumn('address_country', 'text') + .execute(); +} + +export async function down(db: Kysely) { + await db.schema + .alterTable('students') + .dropColumn('address_country') + .execute(); +} diff --git a/packages/types/src/domain/types.ts b/packages/types/src/domain/types.ts index 2d57d8fc..af350e40 100644 --- a/packages/types/src/domain/types.ts +++ b/packages/types/src/domain/types.ts @@ -17,6 +17,7 @@ export type Entity = z.infer; export const Address = z.object({ city: z.string().trim().min(1), + country: z.string().trim().min(2).max(3), line1: z.string().trim().min(1), line2: z.string().trim().optional(), state: z.string().trim().min(1), diff --git a/packages/ui/src/components/address.tsx b/packages/ui/src/components/address.tsx index 6ee20961..1fa11554 100644 --- a/packages/ui/src/components/address.tsx +++ b/packages/ui/src/components/address.tsx @@ -1,16 +1,67 @@ import { type PropsWithChildren } from 'react'; +import React, { useContext, useState } from 'react'; import { Input, type InputProps } from './input'; import { Select, type SelectProps } from './select'; +type SupportedCountry = 'CA' | 'US'; + +type AddressContext = { + country: SupportedCountry; + setCountry(country: SupportedCountry): void; +}; + +const AddressContext = React.createContext({ + country: 'US', + setCountry: (_: SupportedCountry) => {}, +}); + export const Address = ({ children }: PropsWithChildren) => { - return
{children}
; + const [country, setCountry] = useState('US'); + + return ( + +
{children}
+
+ ); }; Address.City = function City(props: InputProps) { return ; }; +type Country = { + abbreviation: SupportedCountry; + name: string; +}; + +const COUNTRIES: Country[] = [ + { abbreviation: 'CA', name: 'Canada' }, + { abbreviation: 'US', name: 'United States' }, +]; + +Address.Country = function Country(props: SelectProps) { + const { country, setCountry } = useContext(AddressContext); + + return ( + + ); +}; + Address.HalfGrid = function HalfGrid({ children }: PropsWithChildren) { return (
@@ -36,65 +87,87 @@ type State = { name: string; }; -const USA_STATES: State[] = [ - { abbreviation: 'AL', name: 'Alabama' }, - { abbreviation: 'AK', name: 'Alaska' }, - { abbreviation: 'AZ', name: 'Arizona' }, - { abbreviation: 'AR', name: 'Arkansas' }, - { abbreviation: 'CA', name: 'California' }, - { abbreviation: 'CO', name: 'Colorado' }, - { abbreviation: 'CT', name: 'Connecticut' }, - { abbreviation: 'DE', name: 'Delaware' }, - { abbreviation: 'DC', name: 'District Of Columbia' }, - { abbreviation: 'FL', name: 'Florida' }, - { abbreviation: 'GA', name: 'Georgia' }, - { abbreviation: 'HI', name: 'Hawaii' }, - { abbreviation: 'ID', name: 'Idaho' }, - { abbreviation: 'IL', name: 'Illinois' }, - { abbreviation: 'IN', name: 'Indiana' }, - { abbreviation: 'IA', name: 'Iowa' }, - { abbreviation: 'KS', name: 'Kansas' }, - { abbreviation: 'KY', name: 'Kentucky' }, - { abbreviation: 'LA', name: 'Louisiana' }, - { abbreviation: 'ME', name: 'Maine' }, - { abbreviation: 'MD', name: 'Maryland' }, - { abbreviation: 'MA', name: 'Massachusetts' }, - { abbreviation: 'MI', name: 'Michigan' }, - { abbreviation: 'MN', name: 'Minnesota' }, - { abbreviation: 'MS', name: 'Mississippi' }, - { abbreviation: 'MO', name: 'Missouri' }, - { abbreviation: 'MT', name: 'Montana' }, - { abbreviation: 'NE', name: 'Nebraska' }, - { abbreviation: 'NV', name: 'Nevada' }, - { abbreviation: 'NH', name: 'New Hampshire' }, - { abbreviation: 'NJ', name: 'New Jersey' }, - { abbreviation: 'NM', name: 'New Mexico' }, - { abbreviation: 'NY', name: 'New York' }, - { abbreviation: 'NC', name: 'North Carolina' }, - { abbreviation: 'ND', name: 'North Dakota' }, - { abbreviation: 'OH', name: 'Ohio' }, - { abbreviation: 'OK', name: 'Oklahoma' }, - { abbreviation: 'OR', name: 'Oregon' }, - { abbreviation: 'PA', name: 'Pennsylvania' }, - { abbreviation: 'PR', name: 'Puerto Rico' }, - { abbreviation: 'RI', name: 'Rhode Island' }, - { abbreviation: 'SC', name: 'South Carolina' }, - { abbreviation: 'SD', name: 'South Dakota' }, - { abbreviation: 'TN', name: 'Tennessee' }, - { abbreviation: 'TX', name: 'Texas' }, - { abbreviation: 'UT', name: 'Utah' }, - { abbreviation: 'VT', name: 'Vermont' }, - { abbreviation: 'VA', name: 'Virginia' }, - { abbreviation: 'WA', name: 'Washington' }, - { abbreviation: 'WV', name: 'West Virginia' }, - { abbreviation: 'WI', name: 'Wisconsin' }, - { abbreviation: 'WY', name: 'Wyoming' }, -]; +const COUNTRY_TO_STATES: Record = { + CA: [ + { abbreviation: 'AB', name: 'Alberta' }, + { abbreviation: 'BC', name: 'British Columbia' }, + { abbreviation: 'MB', name: 'Manitoba' }, + { abbreviation: 'NB', name: 'New Brunswick' }, + { abbreviation: 'NL', name: 'Newfoundland and Labrador' }, + { abbreviation: 'NT', name: 'Northwest Territories' }, + { abbreviation: 'NS', name: 'Nova Scotia' }, + { abbreviation: 'NU', name: 'Nunavut' }, + { abbreviation: 'ON', name: 'Ontario' }, + { abbreviation: 'PE', name: 'Prince Edward Island' }, + { abbreviation: 'QC', name: 'Quebec' }, + { abbreviation: 'SK', name: 'Saskatchewan' }, + { abbreviation: 'YT', name: 'Yukon' }, + ], + + US: [ + { abbreviation: 'AL', name: 'Alabama' }, + { abbreviation: 'AK', name: 'Alaska' }, + { abbreviation: 'AZ', name: 'Arizona' }, + { abbreviation: 'AR', name: 'Arkansas' }, + { abbreviation: 'CA', name: 'California' }, + { abbreviation: 'CO', name: 'Colorado' }, + { abbreviation: 'CT', name: 'Connecticut' }, + { abbreviation: 'DE', name: 'Delaware' }, + { abbreviation: 'DC', name: 'District Of Columbia' }, + { abbreviation: 'FL', name: 'Florida' }, + { abbreviation: 'GA', name: 'Georgia' }, + { abbreviation: 'HI', name: 'Hawaii' }, + { abbreviation: 'ID', name: 'Idaho' }, + { abbreviation: 'IL', name: 'Illinois' }, + { abbreviation: 'IN', name: 'Indiana' }, + { abbreviation: 'IA', name: 'Iowa' }, + { abbreviation: 'KS', name: 'Kansas' }, + { abbreviation: 'KY', name: 'Kentucky' }, + { abbreviation: 'LA', name: 'Louisiana' }, + { abbreviation: 'ME', name: 'Maine' }, + { abbreviation: 'MD', name: 'Maryland' }, + { abbreviation: 'MA', name: 'Massachusetts' }, + { abbreviation: 'MI', name: 'Michigan' }, + { abbreviation: 'MN', name: 'Minnesota' }, + { abbreviation: 'MS', name: 'Mississippi' }, + { abbreviation: 'MO', name: 'Missouri' }, + { abbreviation: 'MT', name: 'Montana' }, + { abbreviation: 'NE', name: 'Nebraska' }, + { abbreviation: 'NV', name: 'Nevada' }, + { abbreviation: 'NH', name: 'New Hampshire' }, + { abbreviation: 'NJ', name: 'New Jersey' }, + { abbreviation: 'NM', name: 'New Mexico' }, + { abbreviation: 'NY', name: 'New York' }, + { abbreviation: 'NC', name: 'North Carolina' }, + { abbreviation: 'ND', name: 'North Dakota' }, + { abbreviation: 'OH', name: 'Ohio' }, + { abbreviation: 'OK', name: 'Oklahoma' }, + { abbreviation: 'OR', name: 'Oregon' }, + { abbreviation: 'PA', name: 'Pennsylvania' }, + { abbreviation: 'PR', name: 'Puerto Rico' }, + { abbreviation: 'RI', name: 'Rhode Island' }, + { abbreviation: 'SC', name: 'South Carolina' }, + { abbreviation: 'SD', name: 'South Dakota' }, + { abbreviation: 'TN', name: 'Tennessee' }, + { abbreviation: 'TX', name: 'Texas' }, + { abbreviation: 'UT', name: 'Utah' }, + { abbreviation: 'VT', name: 'Vermont' }, + { abbreviation: 'VA', name: 'Virginia' }, + { abbreviation: 'WA', name: 'Washington' }, + { abbreviation: 'WV', name: 'West Virginia' }, + { abbreviation: 'WI', name: 'Wisconsin' }, + { abbreviation: 'WY', name: 'Wyoming' }, + ], +}; Address.State = function State(props: SelectProps) { + const { country } = useContext(AddressContext); + + const states = COUNTRY_TO_STATES[country]; + return (