From ae6ec92608035f2dbb51baf7179be8b407b746b3 Mon Sep 17 00:00:00 2001 From: paolosantarsiero Date: Sat, 18 Jan 2025 15:45:58 +0100 Subject: [PATCH] refactor: shipping form and preferences profile --- app/checkout/page.tsx | 121 ++++++++++----------- app/checkout/review/page.tsx | 49 +++++++++ app/collection/[slug]/page.tsx | 4 +- app/layout.tsx | 15 ++- app/not-found.tsx | 6 +- app/product/[name]/page.tsx | 23 ++-- app/profile/@user/orders/[id]/page.tsx | 58 ++++------ app/profile/@user/orders/page.tsx | 83 +++++++------- app/profile/@user/preferences/page.tsx | 3 + app/profile/layout.tsx | 10 +- components/cart/cart-item.tsx | 7 ++ components/checkout/checkout-provider.tsx | 93 ++++++++++++++++ components/{shipping => checkout}/form.tsx | 42 +++---- components/price.tsx | 3 - lib/utils.ts | 4 +- lib/woocomerce/models/orders.ts | 1 + 16 files changed, 323 insertions(+), 199 deletions(-) create mode 100644 app/checkout/review/page.tsx create mode 100644 app/profile/@user/preferences/page.tsx create mode 100644 components/checkout/checkout-provider.tsx rename components/{shipping => checkout}/form.tsx (74%) diff --git a/app/checkout/page.tsx b/app/checkout/page.tsx index e73362e3a..7c0f6897f 100644 --- a/app/checkout/page.tsx +++ b/app/checkout/page.tsx @@ -3,10 +3,12 @@ import { Accordion, AccordionItem, Checkbox, Radio, RadioGroup } from '@nextui-org/react'; import { useCart } from 'components/cart/cart-context'; import CartItemView from 'components/cart/cart-item'; +import { useCheckout } from 'components/checkout/checkout-provider'; +import ShippingForm from 'components/checkout/form'; import Price from 'components/price'; -import ShippingForm from 'components/shipping/form'; +import { Billing } from 'lib/woocomerce/models/billing'; import { PaymentGateways } from 'lib/woocomerce/models/payment'; -import { OrderPayload } from 'lib/woocomerce/storeApi'; +import { Shipping } from 'lib/woocomerce/models/shipping'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; import { z } from 'zod'; @@ -17,7 +19,7 @@ const shippingSchema = z.object({ address_1: z.string().min(3), address_2: z.string().optional(), city: z.string().min(3), - state: z.string().min(3), + state: z.string().max(2).min(2), postcode: z.string().min(3), country: z.string().min(3), company: z.string().optional() @@ -26,36 +28,8 @@ const shippingSchema = z.object({ export default function CheckoutPage() { const { cart } = useCart(); const router = useRouter(); - - const initialState: OrderPayload = { - shipping_address: { - first_name: '', - last_name: '', - company: '', - address_1: '', - address_2: '', - city: '', - state: '', - postcode: '', - country: '' - }, - billing_address: { - first_name: '', - last_name: '', - company: '', - email: '', - phone: '', - address_1: '', - address_2: '', - city: '', - state: '', - postcode: '', - country: '' - }, - payment_method: '', - payment_data: [] - }; - const [formData, setFormData] = useState(initialState); + const { checkout, setShipping, setBilling, setPayment } = useCheckout(); + const [error, setError] = useState(undefined); const [sameBilling, setSameBilling] = useState(true); const [paymentGateways, setPaymentGateways] = useState([]); @@ -67,38 +41,38 @@ export default function CheckoutPage() { fetchPaymentGateways(); }, []); - const handleChangeShipping = (e: any) => { - setFormData({ ...formData, shipping_address: e }); - if (sameBilling) { - setFormData({ - ...formData, - billing_address: { ...formData.billing_address, ...e } - }); - } - }; + const onSubmit = async (e: React.FormEvent) => { + console.log('submit'); + e.preventDefault(); + try { + if (sameBilling) { + setBilling({ ...checkout?.billing, ...checkout?.shipping } as Billing); + } + if (!checkout) { + return; + } - const handleChangeBilling = (e: any) => { - setFormData({ ...formData, billing_address: e }); + shippingSchema.parse(checkout.shipping); + router.push('/checkout/review'); + } catch (error) { + console.log(error); + if (error instanceof z.ZodError) { + const errorObj: Record = {}; + error.errors.forEach((err) => { + const key = err.path[0] as string; + errorObj[key] = err.message; + }); + console.log(errorObj); + setError(errorObj as Shipping); + } + } }; return (

Checkout

{ - e.preventDefault(); - try { - if (sameBilling) { - setFormData({ - ...formData, - billing_address: { ...formData.billing_address, ...formData.shipping_address } - }); - } - shippingSchema.parse(formData.shipping_address); - } catch (error) { - console.log(error); - } - }} + onSubmit={onSubmit} className="rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-black" >
@@ -140,13 +114,32 @@ export default function CheckoutPage() { className="text-white sm:w-full md:w-2/3" > - + { + const updatedShipping = { + ...checkout?.shipping, + [e.target.name]: e.target.value + } as Shipping; + setShipping(updatedShipping as Shipping); + setError(undefined); + }} + error={error} + /> setSameBilling(v)} className="mt-2"> - Use same address for billing? + Hai bisogno di fatturazione? - + { + const updatedBilling = { + ...checkout?.shipping, + [e.target.name]: e.target.value + } as Billing; + setBilling(updatedBilling); + setError(undefined); + }} + />
@@ -160,11 +153,7 @@ export default function CheckoutPage() { key={gateway.id} value={gateway.id} onChange={(e) => { - setFormData((prev) => ({ - ...prev, - payment_method: e.target.value, - payment_title: gateway.title - })); + setPayment(e.target.value, gateway.title); }} > {gateway.title} diff --git a/app/checkout/review/page.tsx b/app/checkout/review/page.tsx new file mode 100644 index 000000000..5ebf519bb --- /dev/null +++ b/app/checkout/review/page.tsx @@ -0,0 +1,49 @@ +'use client'; +import { Button } from '@nextui-org/react'; +import { useCart } from 'components/cart/cart-context'; +import CartItemView from 'components/cart/cart-item'; +import { useCheckout } from 'components/checkout/checkout-provider'; +import Price from 'components/price'; + +export default function CheckoutReview() { + const { cart } = useCart(); + const { checkout } = useCheckout(); + + return ( +
+

Riassunto

+
+ {cart?.items.map((item, i) => ( +
  • + +
  • + ))} + + Totale + +
    + Indirizzo di spedizione + + {checkout?.shipping?.first_name} {checkout?.shipping?.last_name} + + {checkout?.shipping?.address_1} + + {checkout?.shipping?.city} {checkout?.shipping?.state} {checkout?.shipping?.postcode} + + {checkout?.shipping?.country} + Metodo di pagamento + {checkout?.payment_method} + + +
    + ); +} diff --git a/app/collection/[slug]/page.tsx b/app/collection/[slug]/page.tsx index 9f4540ddc..b56a170d0 100644 --- a/app/collection/[slug]/page.tsx +++ b/app/collection/[slug]/page.tsx @@ -5,7 +5,9 @@ import { woocommerce } from 'lib/woocomerce/woocommerce'; export default async function ProductPage(props: { params: Promise<{ slug: string }> }) { const slug = (await props.params).slug; const category = (await woocommerce.get('products/categories', { slug }))?.[0]; - const products: Product[] = await woocommerce.get('products', { category: category.id.toString() }); + const products: Product[] = await woocommerce.get('products', { + category: category.id.toString() + }); return (
    diff --git a/app/layout.tsx b/app/layout.tsx index 8bff92a1d..50c91f474 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import { CartProvider } from 'components/cart/cart-context'; +import { CheckoutProvider } from 'components/checkout/checkout-provider'; import Footer from 'components/layout/footer'; import { Navbar } from 'components/layout/navbar'; import { NextAuthProvider } from 'components/next-session-provider'; @@ -42,12 +43,14 @@ export default async function RootLayout({ children }: { children: ReactNode }) - -
    - {children} - - -
    + + +
    + {children} + + +
    +
    diff --git a/app/not-found.tsx b/app/not-found.tsx index ed413d294..5214014e5 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -2,10 +2,12 @@ import Link from 'next/link'; export default function NotFound() { return ( -
    +

    Not Found

    Could not find requested resource

    - Return Home + + Return Home +
    ); } diff --git a/app/product/[name]/page.tsx b/app/product/[name]/page.tsx index 25002ec5f..91e84fdc8 100644 --- a/app/product/[name]/page.tsx +++ b/app/product/[name]/page.tsx @@ -8,7 +8,6 @@ import { ProductDescription } from 'components/product/product-description'; import { VariantSelector } from 'components/product/variant-selector'; import Prose from 'components/prose'; import { HIDDEN_PRODUCT_TAG } from 'lib/constants'; -import { isStrinInteger } from 'lib/utils'; import { Image } from 'lib/woocomerce/models/base'; import { Product, ProductVariations } from 'lib/woocomerce/models/product'; import { woocommerce } from 'lib/woocomerce/woocommerce'; @@ -19,12 +18,9 @@ export async function generateMetadata(props: { params: Promise<{ name: string }>; }): Promise { const params = await props.params; - let product: Product | undefined = undefined; - if (isStrinInteger(params.name)) { - product = await woocommerce.get(`products/${params.name}`); - } else { - product = (await woocommerce.get('products', { slug: params.name }))?.[0]; - } + const product: Product | undefined = ( + await woocommerce.get('products', { slug: params.name }) + )?.[0]; if (!product) return notFound(); @@ -83,12 +79,9 @@ async function RelatedProducts({ product }: { product: Product }) { export default async function ProductPage(props: { params: Promise<{ name: string }> }) { const params = await props.params; - let product: Product | undefined = undefined; - if (isStrinInteger(params.name)) { - product = await woocommerce.get(`products/${params.name}`); - } else { - product = (await woocommerce.get('products', { slug: params.name }))?.[0]; - } + const product: Product | undefined = ( + await woocommerce.get('products', { slug: params.name }) + )?.[0]; let variations: ProductVariations[] = []; if (product?.variations?.length) { variations = await woocommerce.get(`products/${product?.id}/variations`); @@ -96,6 +89,10 @@ export default async function ProductPage(props: { params: Promise<{ name: strin if (!product) return notFound(); + const relatedProducts = await Promise.all( + product.related_ids?.map(async (id) => woocommerce.get(`products/${id}`)) || [] + ); + const productJsonLd = { '@context': 'https://schema.org', '@type': 'Product', diff --git a/app/profile/@user/orders/[id]/page.tsx b/app/profile/@user/orders/[id]/page.tsx index b569fa741..7b21ead4d 100644 --- a/app/profile/@user/orders/[id]/page.tsx +++ b/app/profile/@user/orders/[id]/page.tsx @@ -1,37 +1,18 @@ import Price from 'components/price'; -import { authOptions } from 'lib/auth/config'; import { woocommerce } from 'lib/woocomerce/woocommerce'; -import { getServerSession } from 'next-auth'; import Image from 'next/image'; export default async function OrderPage(props: { params: Promise<{ id: number }> }) { const params = await props.params; - const data = await getServerSession(authOptions); - try { - const order = await woocommerce.get('orders', { id: params.id }); - } catch (error) { - console.error(error); - } + const order = await woocommerce.get('orders', { id: params.id }); return ( -
    +

    Order

    - - + Ordine #{order.number}
    {order.line_items.map((item, i) => (
  • src={item.image?.src || ''} />
  • -
    +
    {item.name}
    @@ -63,21 +44,22 @@ export default async function OrderPage(props: { params: Promise<{ id: number }>
    ))} -
    - - -
    + + Dettagli + + Totale {order.total} {order.currency} + + Metodo di pagamento: {order.payment_method} + + Indirizzo di spedizione + + {order.shipping.first_name} {order.shipping.last_name} + + {order.shipping.address_1} + + {order.shipping.city} {order.shipping.state} {order.shipping.postcode} + + {order.shipping.country}
    ); diff --git a/app/profile/@user/orders/page.tsx b/app/profile/@user/orders/page.tsx index a58edd0aa..268c325d7 100644 --- a/app/profile/@user/orders/page.tsx +++ b/app/profile/@user/orders/page.tsx @@ -7,59 +7,62 @@ import Link from 'next/link'; export default async function OrdersPage() { const data = await getServerSession(authOptions); - const orders = await woocommerce.get('orders'); + const orders = await woocommerce.get('orders', { customer: data?.user?.store_id }); return ( -
    +

    Orders

    {orders.map((order) => ( -
    -
    +
    +
    ID ORDINE: - {order.id} + {order.id}
    EFFETTUATO IL: - {new Date(order.date_created).toLocaleDateString()} -
    -
    - TOTALE: - {order.total} {order.currency} + {new Date(order.date_created).toLocaleDateString()}
    - {order.line_items.map((item, i) => ( -
  • - -
    -
    - {item.name + {order.line_items.map((item, i) => ( +
  • + +
    +
    + {item.name +
    +
    + {item.name} +
    +
    +
    +
    -
    - {item.name} -
    -
  • -
    - -
    - - - ))} -
    - Vedi dettagli + + + ))} +
    +
    + + Vedi dettagli +
    ))} diff --git a/app/profile/@user/preferences/page.tsx b/app/profile/@user/preferences/page.tsx new file mode 100644 index 000000000..118af3613 --- /dev/null +++ b/app/profile/@user/preferences/page.tsx @@ -0,0 +1,3 @@ +export default async function PreferencesArea() { + return
    ; +} diff --git a/app/profile/layout.tsx b/app/profile/layout.tsx index 1dad922a0..12d3c2bc2 100644 --- a/app/profile/layout.tsx +++ b/app/profile/layout.tsx @@ -1,6 +1,6 @@ 'use client'; -import { CubeIcon, UserCircleIcon } from '@heroicons/react/24/outline'; +import { Cog8ToothIcon, CubeIcon, UserCircleIcon } from '@heroicons/react/24/outline'; import { Avatar } from '@nextui-org/react'; import LogoutButton from 'components/button/logout'; import { Customer } from 'lib/woocomerce/models/customer'; @@ -54,6 +54,14 @@ export default function ProfileLayout({ user }: { user: React.ReactNode }) {
    +
    + + + +
    diff --git a/components/cart/cart-item.tsx b/components/cart/cart-item.tsx index f2d8511be..ac7a8469f 100644 --- a/components/cart/cart-item.tsx +++ b/components/cart/cart-item.tsx @@ -7,11 +7,13 @@ import { EditItemQuantityButton } from './edit-item-quantity-button'; export default function CartItemView({ item, + quantity = 1, deletable = false, editable = false, closeCart = () => {} }: { item: CartItem; + quantity?: number; deletable?: boolean; editable?: boolean; closeCart?: () => void; @@ -45,6 +47,11 @@ export default function CartItemView({
    + {item.quantity > 1 && ( + + x{item.quantity} + + )} void; + setBilling: (billing: Billing) => void; + setPayment: (paymentMethod: string, paymentMethodTitle: string) => void; +}; + +const initialState: Checkout = { + shipping: { + first_name: '', + last_name: '', + address_1: '', + address_2: '', + city: '', + state: '', + postcode: '', + country: '', + company: '' + }, + billing: { + first_name: '', + last_name: '', + address_1: '', + address_2: '', + city: '', + state: '', + postcode: '', + country: '', + company: '', + phone: '', + email: '' + }, + payment_method: '', + payment_method_title: '' +}; + +const CheckoutContext = createContext(undefined); + +export function CheckoutProvider({ children }: { children: React.ReactNode }) { + const [checkout, setCheckout] = useState(initialState); + + const setShipping = (shipping: Shipping) => { + setCheckout({ ...checkout, shipping: { ...checkout.shipping, ...shipping } }); + }; + + const setBilling = (billing: Billing) => { + setCheckout({ ...checkout, billing: { ...checkout.billing, ...billing } }); + }; + + const setPayment = (paymentMethod: string, paymentMethodTitle: string) => { + setCheckout({ + ...checkout, + payment_method: paymentMethod, + payment_method_title: paymentMethodTitle + }); + }; + + return ( + + + {children} + + + ); +} + +export function useCheckout() { + const context = useContext(CheckoutContext); + if (context === undefined) { + throw new Error('useCheckout must be used within a CheckoutProvider'); + } + return context; +} diff --git a/components/shipping/form.tsx b/components/checkout/form.tsx similarity index 74% rename from components/shipping/form.tsx rename to components/checkout/form.tsx index c4458c673..822e12ed1 100644 --- a/components/shipping/form.tsx +++ b/components/checkout/form.tsx @@ -3,41 +3,27 @@ import { Avatar, Input, Select, SelectItem } from '@nextui-org/react'; import clsx from 'clsx'; import { getCountries } from 'lib/utils'; import { Billing } from 'lib/woocomerce/models/billing'; -import { useState } from 'react'; +import { Shipping } from 'lib/woocomerce/models/shipping'; +import { useCheckout } from './checkout-provider'; -const optionalFields = ['company']; +const optionalFields = ['company', 'address_2']; export default function ShippingForm({ className, title, - handleChangeAction + onChangeInput, + error }: { className?: string; title?: string; - handleChangeAction?: (data: Billing) => void; + onChangeInput?: (e: React.ChangeEvent) => void; + error?: Shipping | Billing | undefined; }) { const countries = getCountries(); - const initialState: Billing = { - first_name: '', - last_name: '', - address_1: '', - address_2: '', - city: '', - state: '', - postcode: '', - country: '', - company: '', - phone: '', - email: '' - }; - const [formData, setFormData] = useState(initialState); + const { checkout } = useCheckout(); const onChange = (e: React.ChangeEvent) => { - const newData = { ...formData, [e.target.name]: e.target.value }; - setFormData(newData); - if (handleChangeAction) { - handleChangeAction(newData); - } + onChangeInput?.(e); }; const getLabel = (key: string) => key.charAt(0).toUpperCase() + key.slice(1).replace('_', ' '); @@ -45,7 +31,7 @@ export default function ShippingForm({ return (
    {title &&

    {title}

    } - {Object.entries(formData) + {Object.entries(checkout?.shipping || {}) .filter(([key]) => key !== 'country') .map(([key, value], index) => (
    @@ -58,6 +44,8 @@ export default function ShippingForm({ size="md" onChange={onChange} label={getLabel(key)} + isInvalid={error && !!(error as any)[key]} + errorMessage={error && (error as any)[key]} />
    ))} @@ -74,13 +62,13 @@ export default function ShippingForm({ isRequired name="country" aria-label="Select a country" - value={formData.country} + value={checkout?.shipping.country} onChange={(event) => onChange({ target: { name: 'country', - value: event.target.value, - } as unknown as EventTarget & HTMLInputElement, + value: event.target.value + } as unknown as EventTarget & HTMLInputElement } as React.ChangeEvent) } > diff --git a/components/price.tsx b/components/price.tsx index e31c44663..09dc7ad5f 100644 --- a/components/price.tsx +++ b/components/price.tsx @@ -1,5 +1,3 @@ -import clsx from 'clsx'; - const Price = ({ amount, className, @@ -20,7 +18,6 @@ const Price = ({ currency: currencyCode, currencyDisplay: 'narrowSymbol' }).format(parseFloat(amount) / (needSplit ? 100 : 1))}`} - {`${currencyCode}`}

    ); diff --git a/lib/utils.ts b/lib/utils.ts index 35a6d4b63..4f22bc1b5 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -19,6 +19,6 @@ export const getCountries = (): { name: string; icon: string }[] => export const isStrinInteger = (value: string) => { const parsed = parseInt(value, 10); - + return !isNaN(parsed) && parsed.toString() === value.trim(); -} +}; diff --git a/lib/woocomerce/models/orders.ts b/lib/woocomerce/models/orders.ts index ffc9e8052..e62849ec6 100644 --- a/lib/woocomerce/models/orders.ts +++ b/lib/woocomerce/models/orders.ts @@ -27,6 +27,7 @@ export interface Order { total: string; total_tax: string; prices_include_tax: boolean; + customer: number; customer_id: number; customer_ip_address: string; customer_user_agent: string;