From 2e1c90d525a0ab398b1d3445d634074eae0ee06c Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Mon, 3 Oct 2022 10:07:20 -0500 Subject: [PATCH 1/2] Start adding address book --- app/components/AccountAddressBook.tsx | 105 +++++++++++ app/components/index.ts | 1 + app/routes/account.tsx | 14 +- app/routes/account/address/$addressId.tsx | 211 ++++++++++++++++++++++ 4 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 app/components/AccountAddressBook.tsx create mode 100644 app/routes/account/address/$addressId.tsx diff --git a/app/components/AccountAddressBook.tsx b/app/components/AccountAddressBook.tsx new file mode 100644 index 0000000000..ff1fd0f6fa --- /dev/null +++ b/app/components/AccountAddressBook.tsx @@ -0,0 +1,105 @@ +import { Outlet, useOutlet } from "@remix-run/react"; +import type { + Customer, + MailingAddress, +} from "@shopify/hydrogen-ui-alpha/storefront-api-types"; +import { Button, Text, Modal } from "~/components"; +import type { EditAddressContext } from "~/routes/account/address/$addressId"; + +export function AccountAddressBook({ + customer, + addresses, +}: { + customer: Customer; + addresses: MailingAddress[]; +}) { + const editingAddress = useOutlet(); + + return ( + <> + {editingAddress && ( + + + + )} +
+

Address Book

+
+ {!addresses?.length ? ( + + You haven't saved any addresses yet. + + ) : null} +
+ +
+ {addresses?.length ? ( +
+ {customer.defaultAddress && ( +
+ )} + {addresses + .filter((address) => address.id !== customer.defaultAddress?.id) + .map((address) => ( +
+ ))} +
+ ) : null} +
+
+ + ); +} +function Address({ + address, + defaultAddress, +}: { + address: any; + defaultAddress?: boolean; +}) { + return ( +
+ {defaultAddress ? ( +
+ + Default + +
+ ) : null} + + +
+ + +
+
+ ); +} diff --git a/app/components/index.ts b/app/components/index.ts index 3155ba2725..6ce048f77a 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -12,6 +12,7 @@ export { Grid } from "./Grid"; export { CartDetails, CartEmpty } from "./CartDetails"; export { OrderCard } from "./OrderCard"; export { AccountDetails } from "./AccountDetails"; +export { AccountAddressBook } from "./AccountAddressBook"; export { Modal } from "./Modal"; // Sue me export * from "./Icon"; diff --git a/app/routes/account.tsx b/app/routes/account.tsx index abdebcd90b..94ac75bfb1 100644 --- a/app/routes/account.tsx +++ b/app/routes/account.tsx @@ -3,6 +3,7 @@ import { Form, useLoaderData } from "@remix-run/react"; import { flattenConnection } from "@shopify/hydrogen-ui-alpha"; import type { Customer, + MailingAddress, Order, } from "@shopify/hydrogen-ui-alpha/storefront-api-types"; import { @@ -11,6 +12,7 @@ import { PageHeader, Text, AccountDetails, + AccountAddressBook, } from "~/components"; import { getCustomer } from "~/data"; import { getSession } from "~/lib/session.server"; @@ -37,11 +39,13 @@ export async function loader({ request, context }: LoaderArgs) { customer, heading, orders, + addresses: flattenConnection(customer.addresses), }); } export default function Account() { - const { customer, orders, heading } = useLoaderData(); + const { customer, orders, heading, addresses } = + useLoaderData(); return ( <> @@ -54,10 +58,10 @@ export default function Account() { {orders && } - {/* */} + {/* {!orders && ( <> {}; + +export default function EditAddress() { + const { addressId } = useParams(); + const isNewAddress = addressId === "add"; + const actionData = useActionData(); + const transition = useTransition(); + const { addresses, defaultAddress } = useOutletContext(); + console.log({ addresses, defaultAddress }); + const address = addresses.find((address) => address.id === addressId); + + return ( + <> + + {isNewAddress ? "Add address" : "Edit address"} + +
+
+ + {actionData?.formError && ( +
+

{actionData.formError}

+
+ )} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+
+ + ); +} From 87c771aefb054d73fdbb3016eee2a5bac87ef0fa Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Tue, 11 Oct 2022 11:11:44 -0500 Subject: [PATCH 2/2] Build out address editing flow --- app/components/AccountAddressBook.tsx | 65 +++---- app/components/AccountDetails.tsx | 11 +- app/data/index.ts | 209 ++++++++++++++++++++++ app/hooks/useCart.tsx | 10 +- app/routes/account.login.tsx | 8 +- app/routes/account.tsx | 19 +- app/routes/account/address/$addressId.tsx | 144 +++++++++++++-- app/routes/account/edit.tsx | 4 +- package-lock.json | 2 +- package.json | 24 +-- 10 files changed, 406 insertions(+), 90 deletions(-) diff --git a/app/components/AccountAddressBook.tsx b/app/components/AccountAddressBook.tsx index ff1fd0f6fa..58222542a8 100644 --- a/app/components/AccountAddressBook.tsx +++ b/app/components/AccountAddressBook.tsx @@ -1,10 +1,9 @@ -import { Outlet, useOutlet } from "@remix-run/react"; +import { Form } from "@remix-run/react"; import type { Customer, MailingAddress, } from "@shopify/hydrogen-ui-alpha/storefront-api-types"; -import { Button, Text, Modal } from "~/components"; -import type { EditAddressContext } from "~/routes/account/address/$addressId"; +import { Button, Link, Text } from "~/components"; export function AccountAddressBook({ customer, @@ -13,30 +12,16 @@ export function AccountAddressBook({ customer: Customer; addresses: MailingAddress[]; }) { - const editingAddress = useOutlet(); - return ( <> - {editingAddress && ( - - - - )}

Address Book

- {!addresses?.length ? ( + {!addresses?.length && ( You haven't saved any addresses yet. - ) : null} + )}
- {addresses?.length ? ( + {Boolean(addresses?.length) && (
{customer.defaultAddress && (
@@ -57,48 +42,54 @@ export function AccountAddressBook({
))}
- ) : null} + )}
); } + function Address({ address, defaultAddress, }: { - address: any; + address: MailingAddress; defaultAddress?: boolean; }) { return (
- {defaultAddress ? ( + {defaultAddress && (
Default
- ) : null} + )}
    - {address.firstName || address.lastName ? ( + {(address.firstName || address.lastName) && (
  • - {(address.firstName && address.firstName + " ") + address.lastName} + {"" + + (address.firstName && address.firstName + " ") + + address?.lastName}
  • - ) : ( - <> - )} - {address.formatted ? ( - address.formatted.map((line: string) =>
  • {line}
  • ) - ) : ( - <> )} + {address.formatted && + address.formatted.map((line: string) =>
  • {line}
  • )}
- - + + Edit + +
+ + +
); diff --git a/app/components/AccountDetails.tsx b/app/components/AccountDetails.tsx index 1d3edabcdd..5ae67103e1 100644 --- a/app/components/AccountDetails.tsx +++ b/app/components/AccountDetails.tsx @@ -1,20 +1,11 @@ -import { Outlet, useOutlet } from "@remix-run/react"; import type { Customer } from "@shopify/hydrogen-ui-alpha/storefront-api-types"; -import { Modal, Link } from "~/components"; -import type { AccountDetailsOutletContext } from "~/routes/account/edit"; +import { Link } from "~/components"; export function AccountDetails({ customer }: { customer: Customer }) { - const outlet = useOutlet(); - const { firstName, lastName, email, phone } = customer; return ( <> - {!!outlet && ( - - - - )}

Account Details

diff --git a/app/data/index.ts b/app/data/index.ts index 410115c8ef..20c9d8701c 100644 --- a/app/data/index.ts +++ b/app/data/index.ts @@ -21,6 +21,11 @@ import type { UserError, Page, ShopPolicy, + CustomerAddressUpdatePayload, + MailingAddressInput, + CustomerAddressDeletePayload, + CustomerDefaultAddressUpdatePayload, + CustomerAddressCreatePayload, } from "@shopify/hydrogen-ui-alpha/storefront-api-types"; import { getPublicTokenHeaders, @@ -1342,6 +1347,16 @@ const CUSTOMER_QUERY = `#graphql defaultAddress { id formatted + firstName + lastName + company + address1 + address2 + country + province + city + zip + phone } addresses(first: 6) { edges { @@ -1486,3 +1501,197 @@ export async function updateCustomer({ throw new Error(error); } } + +const UPDATE_ADDRESS_MUTATION = `#graphql + mutation customerAddressUpdate( + $address: MailingAddressInput! + $customerAccessToken: String! + $id: ID! + ) { + customerAddressUpdate( + address: $address + customerAccessToken: $customerAccessToken + id: $id + ) { + customerUserErrors { + code + field + message + } + } + } +`; + +export async function updateCustomerAddress({ + customerAccessToken, + addressId, + address, +}: { + customerAccessToken: string; + addressId: string; + address: MailingAddressInput; +}): Promise { + const { data, errors } = await getStorefrontData<{ + customerAddressUpdate: CustomerAddressUpdatePayload; + }>({ + query: UPDATE_ADDRESS_MUTATION, + variables: { + customerAccessToken, + id: addressId, + address, + }, + }); + + const error = getApiErrorMessage( + "customerAddressUpdate", + data, + errors as UserError[] + ); + + if (error) { + throw new Error(error); + } +} + +const DELETE_ADDRESS_MUTATION = `#graphql + mutation customerAddressDelete($customerAccessToken: String!, $id: ID!) { + customerAddressDelete(customerAccessToken: $customerAccessToken, id: $id) { + customerUserErrors { + code + field + message + } + deletedCustomerAddressId + } + } +`; + +export async function deleteCustomerAddress({ + customerAccessToken, + addressId, +}: { + customerAccessToken: string; + addressId: string; +}): Promise { + const { data, errors } = await getStorefrontData<{ + customerAddressDelete: CustomerAddressDeletePayload; + }>({ + query: DELETE_ADDRESS_MUTATION, + variables: { + customerAccessToken, + id: addressId, + }, + }); + + const error = getApiErrorMessage( + "customerAddressDelete", + data, + errors as UserError[] + ); + + if (error) { + throw new Error(error); + } +} + +const UPDATE_DEFAULT_ADDRESS_MUTATION = `#graphql + mutation customerDefaultAddressUpdate( + $addressId: ID! + $customerAccessToken: String! + ) { + customerDefaultAddressUpdate( + addressId: $addressId + customerAccessToken: $customerAccessToken + ) { + customerUserErrors { + code + field + message + } + } + } +`; + +export async function updateCustomerDefaultAddress({ + customerAccessToken, + addressId, +}: { + customerAccessToken: string; + addressId: string; +}): Promise { + const { data, errors } = await getStorefrontData<{ + customerDefaultAddressUpdate: CustomerDefaultAddressUpdatePayload; + }>({ + query: UPDATE_DEFAULT_ADDRESS_MUTATION, + variables: { + customerAccessToken, + addressId, + }, + }); + + const error = getApiErrorMessage( + "customerDefaultAddressUpdate", + data, + errors as UserError[] + ); + + if (error) { + throw new Error(error); + } +} + +const CREATE_ADDRESS_MUTATION = `#graphql + mutation customerAddressCreate( + $address: MailingAddressInput! + $customerAccessToken: String! + ) { + customerAddressCreate( + address: $address + customerAccessToken: $customerAccessToken + ) { + customerAddress { + id + } + customerUserErrors { + code + field + message + } + } + } +`; + +export async function createCustomerAddress({ + customerAccessToken, + address, +}: { + customerAccessToken: string; + address: MailingAddressInput; +}): Promise { + const { data, errors } = await getStorefrontData<{ + customerAddressCreate: CustomerAddressCreatePayload; + }>({ + query: CREATE_ADDRESS_MUTATION, + variables: { + customerAccessToken, + address, + }, + }); + + const error = getApiErrorMessage( + "customerAddressCreate", + data, + errors as UserError[] + ); + + if (error) { + throw new Error(error); + } + + invariant( + data?.customerAddressCreate?.customerAddress?.id, + "Expected customer address to be created" + ); + + return data.customerAddressCreate.customerAddress.id; +} diff --git a/app/hooks/useCart.tsx b/app/hooks/useCart.tsx index 0a97c655a8..8d346ba435 100644 --- a/app/hooks/useCart.tsx +++ b/app/hooks/useCart.tsx @@ -1,15 +1,11 @@ -import {useParentRouteData} from './useRouteData'; +import { useParentRouteData } from "./useRouteData"; -import type { - Cart, -} from "@shopify/hydrogen-ui-alpha/storefront-api-types"; +import type { Cart } from "@shopify/hydrogen-ui-alpha/storefront-api-types"; export function useCart(): Cart | undefined { - const rootData = useParentRouteData('/'); + const rootData = useParentRouteData("/"); if (rootData?.cart?._data) { return rootData?.cart?._data; } - - throw rootData?.cart } diff --git a/app/routes/account.login.tsx b/app/routes/account.login.tsx index 6732b1d0a1..bd1d6a28bb 100644 --- a/app/routes/account.login.tsx +++ b/app/routes/account.login.tsx @@ -121,7 +121,8 @@ export default function Login() { autoFocus onBlur={(event) => { setNativeEmailError( - !event.currentTarget.validity.valid + event.currentTarget.value.length && + !event.currentTarget.validity.valid ? "Invalid email address" : null ); @@ -145,7 +146,10 @@ export default function Login() { // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus onBlur={(event) => { - if (event.currentTarget.validity.valid) { + if ( + event.currentTarget.validity.valid || + !event.currentTarget.value.length + ) { setNativePasswordError(null); } else { setNativePasswordError( diff --git a/app/routes/account.tsx b/app/routes/account.tsx index 33c94dad11..bd6a106715 100644 --- a/app/routes/account.tsx +++ b/app/routes/account.tsx @@ -1,5 +1,5 @@ import { type LoaderArgs, redirect, json } from "@remix-run/cloudflare"; -import { Form, useLoaderData } from "@remix-run/react"; +import { Form, Outlet, useLoaderData, useOutlet } from "@remix-run/react"; import { flattenConnection } from "@shopify/hydrogen-ui-alpha"; import type { Customer, @@ -13,9 +13,11 @@ import { Text, AccountDetails, AccountAddressBook, + Modal, } from "~/components"; import { getCustomer } from "~/data"; import { getSession } from "~/lib/session.server"; +import type { AccountOutletContext } from "./account/edit"; export async function loader({ request, context, params }: LoaderArgs) { const session = await getSession(request, context); @@ -25,7 +27,12 @@ export async function loader({ request, context, params }: LoaderArgs) { return redirect("/account/login"); } - const customer = await getCustomer({ customerAccessToken, params, request, context }); + const customer = await getCustomer({ + customerAccessToken, + params, + request, + context, + }); const heading = customer ? customer.firstName @@ -46,9 +53,15 @@ export async function loader({ request, context, params }: LoaderArgs) { export default function Account() { const { customer, orders, heading, addresses } = useLoaderData(); + const outlet = useOutlet(); return ( <> + {!!outlet && ( + + + + )}
); } diff --git a/app/routes/account/address/$addressId.tsx b/app/routes/account/address/$addressId.tsx index 4e43a1a201..8843910814 100644 --- a/app/routes/account/address/$addressId.tsx +++ b/app/routes/account/address/$addressId.tsx @@ -1,4 +1,4 @@ -import type { ActionFunction } from "@remix-run/cloudflare"; +import { type ActionFunction, json, redirect } from "@remix-run/cloudflare"; import { Form, useActionData, @@ -6,29 +6,137 @@ import { useParams, useTransition, } from "@remix-run/react"; -import type { MailingAddress } from "@shopify/hydrogen-ui-alpha/storefront-api-types"; +import { flattenConnection } from "@shopify/hydrogen-ui-alpha"; +import type { MailingAddressInput } from "@shopify/hydrogen-ui-alpha/storefront-api-types"; +import invariant from "tiny-invariant"; import { Button, Text } from "~/components"; +import { + createCustomerAddress, + deleteCustomerAddress, + updateCustomerAddress, + updateCustomerDefaultAddress, +} from "~/data"; +import { getSession } from "~/lib/session.server"; import { getInputStyleClasses } from "~/lib/utils"; +import type { AccountOutletContext } from "../edit"; interface ActionData { formError?: string; } -export interface EditAddressContext { - addresses: MailingAddress[]; - defaultAddress?: MailingAddress; -} +const badRequest = (data: ActionData) => json(data, { status: 400 }); + +export const action: ActionFunction = async ({ request, context }) => { + const [formData, session] = await Promise.all([ + request.formData(), + getSession(request, context), + ]); + + const customerAccessToken = await session.get("customerAccessToken"); + invariant(customerAccessToken, "You must be logged in to edit your account."); + + const addressId = formData.get("addressId"); + invariant(typeof addressId === "string", "You must provide an address id."); + + if (request.method === "DELETE") { + try { + await deleteCustomerAddress({ + customerAccessToken, + addressId, + }); + + // TODO: Why doesn't `redirect('..')` work here? it redirects to the root / instead of /account. + return redirect("/account"); + } catch (error: any) { + return badRequest({ formError: error.message }); + } + } + + const address: MailingAddressInput = {}; -export const action: ActionFunction = async ({ request, context }) => {}; + const keys: (keyof MailingAddressInput)[] = [ + "lastName", + "firstName", + "address1", + "address2", + "city", + "province", + "country", + "zip", + "phone", + "company", + ]; + + for (const key of keys) { + const value = formData.get(key); + if (typeof value === "string") { + address[key] = value; + } + } + + const defaultAddress = formData.get("defaultAddress"); + + if (addressId === "add") { + try { + const id = await createCustomerAddress({ + customerAccessToken, + address, + }); + + if (defaultAddress) { + await updateCustomerDefaultAddress({ + customerAccessToken, + addressId: id, + }); + } + + // TODO: Why doesn't `redirect('..')` work here? it redirects to the root / instead of /account. + return redirect("/account"); + } catch (error: any) { + return badRequest({ formError: error.message }); + } + } else { + try { + await updateCustomerAddress({ + customerAccessToken, + addressId: decodeURIComponent(addressId), + address, + }); + + if (defaultAddress) { + await updateCustomerDefaultAddress({ + customerAccessToken, + addressId: decodeURIComponent(addressId), + }); + } + + // TODO: Why doesn't `redirect('..')` work here? it redirects to the root / instead of /account. + return redirect("/account"); + } catch (error: any) { + return badRequest({ formError: error.message }); + } + } +}; export default function EditAddress() { const { addressId } = useParams(); const isNewAddress = addressId === "add"; const actionData = useActionData(); const transition = useTransition(); - const { addresses, defaultAddress } = useOutletContext(); - console.log({ addresses, defaultAddress }); - const address = addresses.find((address) => address.id === addressId); + const { customer } = useOutletContext(); + const addresses = flattenConnection(customer.addresses); + const defaultAddress = customer.defaultAddress; + /** + * When a refresh happens (or a user visits this link directly), the URL + * is actually stale because it contains a special token. This means the data + * loaded by the parent and passed to the outlet contains a newer, fresher token, + * and we don't find a match. We update the `find` logic to just perform a match + * on the first (permanent) part of the ID. + */ + const normalizedAddress = decodeURIComponent(addressId ?? "").split("?")[0]; + const address = addresses.find((address) => + address.id!.startsWith(normalizedAddress) + ); return ( <> @@ -37,7 +145,11 @@ export default function EditAddress() {
- + {actionData?.formError && (

{actionData.formError}

@@ -84,8 +196,8 @@ export default function EditAddress() {
@@ -122,8 +234,8 @@ export default function EditAddress() {
{ */ export default function AccountDetailsEdit() { const actionData = useActionData(); - const { customer } = useOutletContext(); + const { customer } = useOutletContext(); const transition = useTransition(); return ( diff --git a/package-lock.json b/package-lock.json index a1ec747c73..2d30f46060 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "postcss-preset-env": "^7.8.2", "tailwindcss": "^3.1.8", "typescript": "^4.8.3", - "wrangler": "2.1.3" + "wrangler": "^2.1.3" }, "engines": { "node": ">=16.13" diff --git a/package.json b/package.json index 0cb3f6dc6f..0d14afa710 100644 --- a/package.json +++ b/package.json @@ -14,39 +14,39 @@ }, "dependencies": { "@cloudflare/kv-asset-handler": "^0.2.0", + "@headlessui/react": "^1.7.2", "@remix-run/cloudflare": "0.0.0-experimental-e18af792a", "@remix-run/react": "0.0.0-experimental-e18af792a", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "@headlessui/react": "^1.7.2", "@shopify/hydrogen-ui-alpha": "^2022.7.4", "clsx": "^1.2.1", "cross-env": "^7.0.3", "graphql-tag": "^2.12.6", + "react": "^18.2.0", + "react-dom": "^18.2.0", "react-use": "^17.4.0", "tiny-invariant": "^1.2.0", "typographic-base": "^1.0.4" }, "devDependencies": { - "@tailwindcss/forms": "^0.5.3", - "@tailwindcss/typography": "^0.5.7", - "npm-run-all": "^4.1.5", - "postcss": "^8.4.16", - "postcss-cli": "^10.0.0", - "postcss-import": "^15.0.0", - "postcss-preset-env": "^7.8.2", - "tailwindcss": "^3.1.8", "@cloudflare/workers-types": "^3.14.1", "@remix-run/dev": "0.0.0-experimental-e18af792a", "@remix-run/eslint-config": "0.0.0-experimental-e18af792a", + "@tailwindcss/forms": "^0.5.3", + "@tailwindcss/typography": "^0.5.7", "@types/react": "^18.0.20", "@types/react-dom": "^18.0.6", "concurrently": "^7.4.0", "cross-env": "^7.0.3", "eslint": "^8.20.0", "miniflare": "^2.6.0", + "npm-run-all": "^4.1.5", + "postcss": "^8.4.16", + "postcss-cli": "^10.0.0", + "postcss-import": "^15.0.0", + "postcss-preset-env": "^7.8.2", + "tailwindcss": "^3.1.8", "typescript": "^4.8.3", - "wrangler": "2.1.3" + "wrangler": "^2.1.3" }, "engines": { "node": ">=16.13"