From 682f2ecc63b23933b1af4a5fe7ae076689b160d9 Mon Sep 17 00:00:00 2001 From: Chloe Date: Sun, 9 Jun 2024 21:25:56 +0700 Subject: [PATCH] feat: add vehicle details to cart attributes Signed-off-by: Chloe --- components/cart/actions.ts | 38 ++++++++++- components/cart/modal.tsx | 102 ++++++++++++++++++++-------- components/cart/vehicle-details.tsx | 60 ++++++++++++++++ lib/shopify/index.ts | 19 +++++- lib/shopify/mutations/cart.ts | 11 +++ lib/shopify/types.ts | 15 ++++ package.json | 5 +- pnpm-lock.yaml | 30 ++++++++ 8 files changed, 249 insertions(+), 31 deletions(-) create mode 100644 components/cart/vehicle-details.tsx diff --git a/components/cart/actions.ts b/components/cart/actions.ts index 084b5dc85..d52bc6b08 100644 --- a/components/cart/actions.ts +++ b/components/cart/actions.ts @@ -1,7 +1,14 @@ 'use server'; import { TAGS } from 'lib/constants'; -import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify'; +import { + addToCart, + createCart, + getCart, + removeFromCart, + setCartAttributes, + updateCart +} from 'lib/shopify'; import { revalidateTag } from 'next/cache'; import { cookies } from 'next/headers'; @@ -34,6 +41,35 @@ export async function addItem(prevState: any, selectedVariantIds: Array) } } +export async function setMetafields( + prevState: any, + formData: { customer_vin: string; customer_mileage: string } +) { + const cartId = cookies().get('cartId')?.value; + + if (!cartId) { + return 'Missing cart ID'; + } + + try { + await setCartAttributes(cartId, [ + { + key: 'customer_vin', + value: formData.customer_vin + }, + { + key: 'customer_mileage', + value: formData.customer_mileage + } + ]); + + revalidateTag(TAGS.cart); + } catch (e) { + console.log(e); + return 'Error set cart attributes'; + } +} + export async function removeItem(prevState: any, lineIds: string[]) { const cartId = cookies().get('cartId')?.value; diff --git a/components/cart/modal.tsx b/components/cart/modal.tsx index 82bc3f5fa..2a709cb2e 100644 --- a/components/cart/modal.tsx +++ b/components/cart/modal.tsx @@ -2,18 +2,31 @@ import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'; import { ShoppingCartIcon } from '@heroicons/react/24/outline'; +import { zodResolver } from '@hookform/resolvers/zod'; +import clsx from 'clsx'; +import LoadingDots from 'components/loading-dots'; import Price from 'components/price'; import type { Cart } from 'lib/shopify/types'; import { Fragment, useEffect, useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { setMetafields } from './actions'; import CloseCart from './close-cart'; import LineItem from './line-item'; import OpenCart from './open-cart'; +import VehicleDetails, { VehicleFormSchema, vehicleFormSchema } from './vehicle-details'; export default function CartModal({ cart }: { cart: Cart | undefined }) { const [isOpen, setIsOpen] = useState(false); const quantityRef = useRef(cart?.totalQuantity); const openCart = () => setIsOpen(true); const closeCart = () => setIsOpen(false); + const { control, handleSubmit } = useForm({ + resolver: zodResolver(vehicleFormSchema) + }); + + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(); + const linkRef = useRef(null); useEffect(() => { // Open cart modal when quantity changes. @@ -28,6 +41,25 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) { } }, [isOpen, cart?.totalQuantity, quantityRef]); + const onSubmit = async (data: VehicleFormSchema) => { + if (!cart) return; + + setLoading(true); + + try { + const message = await setMetafields(cart.id, data); + if (message) { + setMessage(message); + } else { + linkRef.current?.click(); + } + } catch (error) { + setMessage('Error updating vehicle details'); + } finally { + setLoading(false); + } + }; + return ( <> + +

+ {message} +

+ )} diff --git a/components/cart/vehicle-details.tsx b/components/cart/vehicle-details.tsx new file mode 100644 index 000000000..0778de7b3 --- /dev/null +++ b/components/cart/vehicle-details.tsx @@ -0,0 +1,60 @@ +import { Description, Field, Input, Label } from '@headlessui/react'; +import { Control, Controller } from 'react-hook-form'; +import * as zod from 'zod'; + +export const vehicleFormSchema = zod.object({ + customer_vin: zod.string({ required_error: 'Vin number is required' }).min(0), + customer_mileage: zod.string({ required_error: 'Mileage is required' }).min(0) +}); + +export type VehicleFormSchema = zod.infer; + +type VehicleDetailsProps = { + control: Control; +}; + +const VehicleDetails = ({ control }: VehicleDetailsProps) => { + return ( +
+
Vehicle Details
+ ( + + + + {error && ( + {error.message} + )} + + )} + /> + + ( + + + + {error && ( + {error.message} + )} + + )} + /> +
+ ); +}; + +export default VehicleDetails; diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts index c0b897d5b..80deba303 100644 --- a/lib/shopify/index.ts +++ b/lib/shopify/index.ts @@ -19,7 +19,8 @@ import { addToCartMutation, createCartMutation, editCartItemsMutation, - removeFromCartMutation + removeFromCartMutation, + setCartAttributesMutation } from './mutations/cart'; import { getCartQuery } from './queries/cart'; import { @@ -39,6 +40,7 @@ import { } from './queries/product'; import { Cart, + CartAttributeInput, CartItem, CartProductVariant, Collection, @@ -75,6 +77,7 @@ import { ShopifyProductVariant, ShopifyProductsOperation, ShopifyRemoveFromCartOperation, + ShopifySetCartAttributesOperation, ShopifyUpdateCartOperation } from './types'; @@ -339,6 +342,19 @@ export async function addToCart( return reshapeCart(res.body.data.cartLinesAdd.cart); } +export async function setCartAttributes(cartId: string, attributes: CartAttributeInput[]) { + const res = await shopifyFetch({ + query: setCartAttributesMutation, + variables: { + attributes, + cartId + }, + cache: 'no-store' + }); + + return res.body.data.cart; +} + export async function removeFromCart(cartId: string, lineIds: string[]): Promise { const res = await shopifyFetch({ query: removeFromCartMutation, @@ -382,7 +398,6 @@ export async function getCart(cartId: string): Promise { } const cart = reshapeCart(res.body.data.cart); - let extendedCartLines = cart.lines; const lineIdMap = {} as { [key: string]: string }; diff --git a/lib/shopify/mutations/cart.ts b/lib/shopify/mutations/cart.ts index 4cc1b5ac6..1f5351dc7 100644 --- a/lib/shopify/mutations/cart.ts +++ b/lib/shopify/mutations/cart.ts @@ -11,6 +11,17 @@ export const addToCartMutation = /* GraphQL */ ` ${cartFragment} `; +export const setCartAttributesMutation = /* GraphQL */ ` + mutation setCartAttributes($attributes: [AttributeInput!]!, $cartId: ID!) { + cartAttributesUpdate(cartId: $cartId, attributes: $attributes) { + cart { + ...cart + } + } + } + ${cartFragment} +`; + export const createCartMutation = /* GraphQL */ ` mutation createCart($lineItems: [CartLineInput!]) { cartCreate(input: { lines: $lineItems }) { diff --git a/lib/shopify/types.ts b/lib/shopify/types.ts index b24df84ee..c8e3e3a89 100644 --- a/lib/shopify/types.ts +++ b/lib/shopify/types.ts @@ -242,6 +242,16 @@ export type ShopifyAddToCartOperation = { }; }; +export type ShopifySetCartAttributesOperation = { + data: { + cart: ShopifyCart; + }; + variables: { + attributes: CartAttributeInput[]; + cartId: string; + }; +}; + export type ShopifyRemoveFromCartOperation = { data: { cartLinesRemove: { @@ -428,3 +438,8 @@ export type Filter = { export const SCREEN_SIZES = ['small', 'medium', 'large', 'extra_large'] as const; export type ScreenSize = (typeof SCREEN_SIZES)[number]; + +export type CartAttributeInput = { + key: string; + value: string; +}; diff --git a/package.json b/package.json index b4d7e64d6..2ca3a8fe4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dependencies": { "@headlessui/react": "^2.0.1", "@heroicons/react": "^2.1.3", + "@hookform/resolvers": "^3.6.0", "@radix-ui/react-checkbox": "^1.0.4", "clsx": "^2.1.0", "geist": "^1.3.0", @@ -33,8 +34,10 @@ "next": "14.1.4", "react": "18.2.0", "react-dom": "18.2.0", + "react-hook-form": "^7.51.5", "react-tooltip": "^5.26.3", - "tailwind-merge": "^2.2.2" + "tailwind-merge": "^2.2.2", + "zod": "^3.23.8" }, "devDependencies": { "@tailwindcss/aspect-ratio": "^0.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f048a08ce..ae1bd097e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: '@heroicons/react': specifier: ^2.1.3 version: 2.1.3(react@18.2.0) + '@hookform/resolvers': + specifier: ^3.6.0 + version: 3.6.0(react-hook-form@7.51.5) '@radix-ui/react-checkbox': specifier: ^1.0.4 version: 1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.72)(react-dom@18.2.0)(react@18.2.0) @@ -38,12 +41,18 @@ dependencies: react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.51.5 + version: 7.51.5(react@18.2.0) react-tooltip: specifier: ^5.26.3 version: 5.26.3(react-dom@18.2.0)(react@18.2.0) tailwind-merge: specifier: ^2.2.2 version: 2.2.2 + zod: + specifier: ^3.23.8 + version: 3.23.8 devDependencies: '@tailwindcss/aspect-ratio': @@ -255,6 +264,14 @@ packages: react: 18.2.0 dev: false + /@hookform/resolvers@3.6.0(react-hook-form@7.51.5): + resolution: {integrity: sha512-UBcpyOX3+RR+dNnqBd0lchXpoL8p4xC21XP8H6Meb8uve5Br1GCnmg0PcBoKKqPKgGu9GHQ/oygcmPrQhetwqw==} + peerDependencies: + react-hook-form: ^7.0.0 + dependencies: + react-hook-form: 7.51.5(react@18.2.0) + dev: false + /@humanwhocodes/config-array@0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -3254,6 +3271,15 @@ packages: scheduler: 0.23.0 dev: false + /react-hook-form@7.51.5(react@18.2.0): + resolution: {integrity: sha512-J2ILT5gWx1XUIJRETiA7M19iXHlG74+6O3KApzvqB/w8S5NQR7AbU8HVZrMALdmDgWpRPYiZJl0zx8Z4L2mP6Q==} + engines: {node: '>=12.22.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + dependencies: + react: 18.2.0 + dev: false + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: true @@ -4031,3 +4057,7 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true + + /zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + dev: false