feat: add vehicle details to cart attributes

Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
Chloe 2024-06-09 21:25:56 +07:00
parent a3d416a19b
commit 682f2ecc63
No known key found for this signature in database
GPG Key ID: CFD53CE570D42DF5
8 changed files with 249 additions and 31 deletions

View File

@ -1,7 +1,14 @@
'use server'; 'use server';
import { TAGS } from 'lib/constants'; 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 { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
@ -34,6 +41,35 @@ export async function addItem(prevState: any, selectedVariantIds: Array<string>)
} }
} }
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[]) { export async function removeItem(prevState: any, lineIds: string[]) {
const cartId = cookies().get('cartId')?.value; const cartId = cookies().get('cartId')?.value;

View File

@ -2,18 +2,31 @@
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'; import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react';
import { ShoppingCartIcon } from '@heroicons/react/24/outline'; 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 Price from 'components/price';
import type { Cart } from 'lib/shopify/types'; import type { Cart } from 'lib/shopify/types';
import { Fragment, useEffect, useRef, useState } from 'react'; import { Fragment, useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { setMetafields } from './actions';
import CloseCart from './close-cart'; import CloseCart from './close-cart';
import LineItem from './line-item'; import LineItem from './line-item';
import OpenCart from './open-cart'; import OpenCart from './open-cart';
import VehicleDetails, { VehicleFormSchema, vehicleFormSchema } from './vehicle-details';
export default function CartModal({ cart }: { cart: Cart | undefined }) { export default function CartModal({ cart }: { cart: Cart | undefined }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const quantityRef = useRef(cart?.totalQuantity); const quantityRef = useRef(cart?.totalQuantity);
const openCart = () => setIsOpen(true); const openCart = () => setIsOpen(true);
const closeCart = () => setIsOpen(false); const closeCart = () => setIsOpen(false);
const { control, handleSubmit } = useForm<VehicleFormSchema>({
resolver: zodResolver(vehicleFormSchema)
});
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | undefined>();
const linkRef = useRef<HTMLAnchorElement | null>(null);
useEffect(() => { useEffect(() => {
// Open cart modal when quantity changes. // Open cart modal when quantity changes.
@ -28,6 +41,25 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
} }
}, [isOpen, cart?.totalQuantity, quantityRef]); }, [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 ( return (
<> <>
<button aria-label="Open cart" onClick={openCart}> <button aria-label="Open cart" onClick={openCart}>
@ -76,34 +108,50 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
return <LineItem item={item} closeCart={closeCart} key={item.id} />; return <LineItem item={item} closeCart={closeCart} key={item.id} />;
})} })}
</ul> </ul>
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400"> <form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700"> <div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
<p>Taxes</p> <VehicleDetails control={control} />
<Price <div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
className="text-right text-base text-black dark:text-white" <p>Taxes</p>
amount={cart.cost.totalTaxAmount.amount} <Price
currencyCode={cart.cost.totalTaxAmount.currencyCode} className="text-right text-base text-black dark:text-white"
/> amount={cart.cost.totalTaxAmount.amount}
currencyCode={cart.cost.totalTaxAmount.currencyCode}
/>
</div>
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
<p>Shipping</p>
<p className="text-right">Calculated at checkout</p>
</div>
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
<p>Total</p>
<Price
className="text-right text-base text-black dark:text-white"
amount={cart.cost.totalAmount.amount}
currencyCode={cart.cost.totalAmount.currencyCode}
/>
</div>
</div> </div>
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700"> <a href={cart.checkoutUrl} ref={linkRef} className="hidden">
<p>Shipping</p> Proceed to Checkout
<p className="text-right">Calculated at checkout</p> </a>
</div> <button
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700"> type="submit"
<p>Total</p> className={clsx(
<Price 'flex w-full flex-row items-center justify-center gap-2 rounded-full bg-secondary p-3 text-sm font-medium text-white',
className="text-right text-base text-black dark:text-white" { 'cursor-not-allowed opacity-60 hover:opacity-60': loading },
amount={cart.cost.totalAmount.amount} { 'cursor-pointer opacity-90 hover:opacity-100': !loading }
currencyCode={cart.cost.totalAmount.currencyCode} )}
/> aria-disabled={loading}
</div> >
</div> {loading && <LoadingDots className="bg-white" />}
<a Proceed to Checkout
href={cart.checkoutUrl} </button>
className="block w-full rounded-full bg-secondary p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
> <p aria-live="polite" className="sr-only" role="status">
Proceed to Checkout {message}
</a> </p>
</form>
</div> </div>
)} )}
</DialogPanel> </DialogPanel>

View File

@ -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<typeof vehicleFormSchema>;
type VehicleDetailsProps = {
control: Control<VehicleFormSchema>;
};
const VehicleDetails = ({ control }: VehicleDetailsProps) => {
return (
<div className="mb-5 mt-3 border-y border-gray-300 pb-5 pt-3">
<div className="text-base font-medium text-gray-900">Vehicle Details</div>
<Controller
name="customer_vin"
control={control}
render={({ field, fieldState: { error } }) => (
<Field className="mt-4">
<Label className="block text-sm font-medium text-gray-700">Vin Number</Label>
<Input
type="number"
className="mt-1 block w-full rounded-md border-gray-300 text-dark shadow-sm focus:outline-none data-[focus]:border-primary/50 data-[focus]:ring-primary/50 sm:text-sm"
autoFocus
{...field}
/>
{error && (
<Description className="mt-1 text-sm text-red-500">{error.message}</Description>
)}
</Field>
)}
/>
<Controller
name="customer_mileage"
control={control}
render={({ field, fieldState: { error } }) => (
<Field className="mt-4">
<Label className="block text-sm font-medium text-gray-700">Current Mileage</Label>
<Input
type="number"
className="mt-1 block w-full rounded-md border-gray-300 text-dark shadow-sm focus:outline-none data-[focus]:border-primary/50 data-[focus]:ring-primary/50 sm:text-sm"
{...field}
/>
{error && (
<Description className="mt-1 text-sm text-red-500">{error.message}</Description>
)}
</Field>
)}
/>
</div>
);
};
export default VehicleDetails;

View File

@ -19,7 +19,8 @@ import {
addToCartMutation, addToCartMutation,
createCartMutation, createCartMutation,
editCartItemsMutation, editCartItemsMutation,
removeFromCartMutation removeFromCartMutation,
setCartAttributesMutation
} from './mutations/cart'; } from './mutations/cart';
import { getCartQuery } from './queries/cart'; import { getCartQuery } from './queries/cart';
import { import {
@ -39,6 +40,7 @@ import {
} from './queries/product'; } from './queries/product';
import { import {
Cart, Cart,
CartAttributeInput,
CartItem, CartItem,
CartProductVariant, CartProductVariant,
Collection, Collection,
@ -75,6 +77,7 @@ import {
ShopifyProductVariant, ShopifyProductVariant,
ShopifyProductsOperation, ShopifyProductsOperation,
ShopifyRemoveFromCartOperation, ShopifyRemoveFromCartOperation,
ShopifySetCartAttributesOperation,
ShopifyUpdateCartOperation ShopifyUpdateCartOperation
} from './types'; } from './types';
@ -339,6 +342,19 @@ export async function addToCart(
return reshapeCart(res.body.data.cartLinesAdd.cart); return reshapeCart(res.body.data.cartLinesAdd.cart);
} }
export async function setCartAttributes(cartId: string, attributes: CartAttributeInput[]) {
const res = await shopifyFetch<ShopifySetCartAttributesOperation>({
query: setCartAttributesMutation,
variables: {
attributes,
cartId
},
cache: 'no-store'
});
return res.body.data.cart;
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> { export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({ const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
query: removeFromCartMutation, query: removeFromCartMutation,
@ -382,7 +398,6 @@ export async function getCart(cartId: string): Promise<Cart | undefined> {
} }
const cart = reshapeCart(res.body.data.cart); const cart = reshapeCart(res.body.data.cart);
let extendedCartLines = cart.lines; let extendedCartLines = cart.lines;
const lineIdMap = {} as { [key: string]: string }; const lineIdMap = {} as { [key: string]: string };

View File

@ -11,6 +11,17 @@ export const addToCartMutation = /* GraphQL */ `
${cartFragment} ${cartFragment}
`; `;
export const setCartAttributesMutation = /* GraphQL */ `
mutation setCartAttributes($attributes: [AttributeInput!]!, $cartId: ID!) {
cartAttributesUpdate(cartId: $cartId, attributes: $attributes) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const createCartMutation = /* GraphQL */ ` export const createCartMutation = /* GraphQL */ `
mutation createCart($lineItems: [CartLineInput!]) { mutation createCart($lineItems: [CartLineInput!]) {
cartCreate(input: { lines: $lineItems }) { cartCreate(input: { lines: $lineItems }) {

View File

@ -242,6 +242,16 @@ export type ShopifyAddToCartOperation = {
}; };
}; };
export type ShopifySetCartAttributesOperation = {
data: {
cart: ShopifyCart;
};
variables: {
attributes: CartAttributeInput[];
cartId: string;
};
};
export type ShopifyRemoveFromCartOperation = { export type ShopifyRemoveFromCartOperation = {
data: { data: {
cartLinesRemove: { cartLinesRemove: {
@ -428,3 +438,8 @@ export type Filter = {
export const SCREEN_SIZES = ['small', 'medium', 'large', 'extra_large'] as const; export const SCREEN_SIZES = ['small', 'medium', 'large', 'extra_large'] as const;
export type ScreenSize = (typeof SCREEN_SIZES)[number]; export type ScreenSize = (typeof SCREEN_SIZES)[number];
export type CartAttributeInput = {
key: string;
value: string;
};

View File

@ -24,6 +24,7 @@
"dependencies": { "dependencies": {
"@headlessui/react": "^2.0.1", "@headlessui/react": "^2.0.1",
"@heroicons/react": "^2.1.3", "@heroicons/react": "^2.1.3",
"@hookform/resolvers": "^3.6.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"geist": "^1.3.0", "geist": "^1.3.0",
@ -33,8 +34,10 @@
"next": "14.1.4", "next": "14.1.4",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.51.5",
"react-tooltip": "^5.26.3", "react-tooltip": "^5.26.3",
"tailwind-merge": "^2.2.2" "tailwind-merge": "^2.2.2",
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/aspect-ratio": "^0.4.2",

30
pnpm-lock.yaml generated
View File

@ -11,6 +11,9 @@ dependencies:
'@heroicons/react': '@heroicons/react':
specifier: ^2.1.3 specifier: ^2.1.3
version: 2.1.3(react@18.2.0) 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': '@radix-ui/react-checkbox':
specifier: ^1.0.4 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) 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: react-dom:
specifier: 18.2.0 specifier: 18.2.0
version: 18.2.0(react@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: react-tooltip:
specifier: ^5.26.3 specifier: ^5.26.3
version: 5.26.3(react-dom@18.2.0)(react@18.2.0) version: 5.26.3(react-dom@18.2.0)(react@18.2.0)
tailwind-merge: tailwind-merge:
specifier: ^2.2.2 specifier: ^2.2.2
version: 2.2.2 version: 2.2.2
zod:
specifier: ^3.23.8
version: 3.23.8
devDependencies: devDependencies:
'@tailwindcss/aspect-ratio': '@tailwindcss/aspect-ratio':
@ -255,6 +264,14 @@ packages:
react: 18.2.0 react: 18.2.0
dev: false 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: /@humanwhocodes/config-array@0.11.14:
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
@ -3254,6 +3271,15 @@ packages:
scheduler: 0.23.0 scheduler: 0.23.0
dev: false 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: /react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: true dev: true
@ -4031,3 +4057,7 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
dev: true dev: true
/zod@3.23.8:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
dev: false