Testa su: questo tutorial copre un precedente rilascio WooGraphQL e non è più aggiornato. Una versione riscritta è in corso — ci collegheremo da qui una volta pubblicato.
Tabella dei contenuti
- Impostazione di WordPress per il tuo Headless eCommerce Applicazione
- Creare e configurare la tua App eCommerce per lo sviluppo
- Pagine delle inserzioni di Building Shop con WooGraphQL
- Creazione di Login utente e navigazione
- Creazione della pagina del prodotto e delle opzioni del carrello
Benvenuti nel capitolo finale della nostra serie di tutorial approfonditi, dove ci concentreremo sull’implementazione delle opzioni Single Product and Cart per il tuo negozio di eCommerce. Hai fatto una lunga strada, e ora è il momento di mettere i tocchi di finitura alla vostra applicazione, creando un’esperienza di shopping potente e flessibile per i vostri clienti.
Creazione della pagina del prodotto e delle opzioni del carrello
Uno degli aspetti vitali di qualsiasi negozio di eCommerce è la capacità di visualizzare i dettagli dei singoli prodotti. Ciò consente agli utenti di acquisire una comprensione completa dei prodotti a cui sono interessati, aumentando la loro fiducia per fare un acquisto. Ti guideremo attraverso come creare e progettare una pagina dettagliata di prodotto singolo che mostra le caratteristiche e gli attributi unici del tuo prodotto.
Successivamente, andremo avanti per migliorare la funzionalità del tuo negozio con Opzioni Cart. Queste opzioni sono la chiave per un processo di shopping fluido e flessibile, fornendo agli utenti la potenza di gestire i loro ordini in modo efficace. Che si tratti di un prodotto semplice senza variazioni o più complesso con varianti multiple, dimostreremo come gestire entrambi gli scenari.
In particolare cercheremo di creare un robusto e intuitivo sistema di carrelli che funziona in armonia con SessionProvider sviluppato nel nostro tutorial precedente. Inoltre, introdurremo un nuovo gancio e una pagina di percorso, strumenti che vi permetteranno di fornire esperienze di shopping senza interruzioni.
E ‘importante notare che questo tutorial si basa sui concetti e codici introdotti nelle sezioni precedenti, in modo da assicurarsi di essere stato attraverso tutti i tutorial precedenti e sono confortevoli con il contenuto.
Alla fine di questo tutorial, avrai un negozio eCommerce completamente funzionale e ricco di funzionalità pronto a affascinare i tuoi clienti. Quindi, senza ulteriori indugi, incartiamo la nostra serie e tuffamo nella creazione di una pagina completa di prodotto singolo e sofisticate opzioni Cart.
Parte 1: Creare una pagina di prodotto singola
1. Creare /app/product/[slug]/page.tsx:
// app/product/[slug]/page.tsx
import { fetchProductBy, ProductIdTypeEnum } from '@/graphql';
import { ShopProduct } from '@/server/ShopProduct';
export interface ProductPageProps {
params: {
slug: string
}
}
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = params;
const product = await fetchProductBy(slug, ProductIdTypeEnum.SLUG);
if (!slug || !product) return (
<h1>Page not found</h1>
);
return (
<ShopProduct product={product}/>
);
}2. Creare /server/ShopProduct:
// server/ShopProduct/ShopProduct.tsx
import { Product, SimpleProduct, VariationAttribute } from '@/graphql';
import { ProductImage } from '@/client/ProductImage';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/ui/tabs';
export interface ShopProductProps {
product: Product;
}
export function ShopProduct(props: ShopProductProps) {
const {
product,
} = props;
const attributes: Record<string, string[]> = (product as SimpleProduct).defaultAttributes?.nodes?.reduce(
(attributesList, attribute) => {
const {
value,
label
} = attribute as VariationAttribute;
const currentAttributes = attributesList[label as string] || [];
return {
...attributesList,
[label as string]: [...currentAttributes, value as string],
};
},
{} as Record<string, string[]>
) || {};
return (
<div className="w-full flex flex-wrap gap-4 max-w-screen-lg mx-auto mb-36 lg:mb-0">
<ProductImage product={product}/>
<div className="basis-full md:basis-1/2 pt-4 px-4 flex flex-col">
<h1 className="font-serif text-2xl font-bold mb-2">{product.name}</h1>
<p className="text-lg font-bold mb-2">{product.shortDescription}</p>
<CartOptions product={product}/>
</div>
<Tabs defaultValue="description" className="w-full px-4">
<TabsList className="w-full md:w-auto">
<TabsTrigger value="description">Description</TabsTrigger>
<TabsTrigger value="attributes">Attributes</TabsTrigger>
<TabsTrigger value="reviews">Reviews</TabsTrigger>
</TabsList>
<TabsContent value="description">
<div dangerouslySetInnerHTML={{ __html: product.description as string }}/>
</TabsContent>
<TabsContent value="attributes">
<ul>
{Object.entries(attributes).map(([label, values]) => (
<li key={label}>
<p><span className="font-serif font-medium">{label}:</span> {values.join(', ')}</p>
</li>
))}
</ul>
</TabsContent>
<TabsContent value="reviews">
<p>To Be Continued...</p>
</TabsContent>
</Tabs>
</div>
);
}
3. Creare /client/ProductImage:
// client/ProductImage/ProductImage.tsx
import { Product } from '@/graphql';
import { Image } from '@/ui/Image';
export interface ProductImageProps {
product: Product;
}
export function ProductImage({ product }: ProductImageProps) {
const sourceUrl = product?.image?.sourceUrl;
const altText = product?.image?.altText || '';
if (!sourceUrl) {
return null;
}
return (
<div className="basis-full md:basis-auto grow">
<Image
className="md:rounded-br"
src={sourceUrl}
alt={altText}
ratio={3 / 4}
/>
</div>
);
}
E basta. Abbiamo una singola pagina di prodotto con sezioni di immagine e descrizione. Gli occhi Keen hanno notato la mancanza di un prezzo, opzioni di carrello e sezioni di recensioni incomplete.
Le recensioni sono fuori campo di questo tutorial. Consideralo i compiti. Per quanto riguarda le opzioni di prezzo e carrello, lavoreremo su questi nella parte successiva. Proprio come i due tutorial precedenti dovremo implementare un nuovo fornitore di contesto. Questo gestirà lo stato di visualizzazione del prodotto che stiamo visualizzando. Questo sarà utile quando si lavora con prodotti variabili. Allora andiamo.
Parte 2: Creare ProductProvider
1. Creare /client/ProductProvider:
// client/ProductProvider/ProductProvider.tsx
import {
useContext,
useReducer,
useEffect,
createContext,
PropsWithChildren
} from 'react';
import get from 'lodash/get';
import {
Product,
ProductTypesEnum,
ProductVariation,
SimpleProduct,
VariableProduct,
} from '@/graphql';
export interface ProductContext {
data: Product|null;
isVariableProduct: boolean;
hasSelectedVariation: boolean;
selectedVariation: ProductVariation | null;
get: (field: keyof ProductVariation | keyof SimpleProduct | keyof VariableProduct) => unknown;
selectVariation: (variation?: ProductVariation) => void;
}
const initialState: ProductContext = {
data: null,
isVariableProduct: false,
hasSelectedVariation: false,
selectedVariation: null,
get: () => null,
selectVariation: () => null,
};
const productContext = createContext<ProductContext>(initialState);
export function useProductContext() {
return useContext(productContext);
}
type ProductAction = {
type: 'SET_PRODUCT';
payload: Product;
} | {
type: 'SET_VARIATION';
payload: ProductVariation|null;
} | {
type: 'SET_IS_VARIABLE_PRODUCT';
payload: boolean;
} | {
type: 'SET_HAS_SELECTED_VARIATION';
payload: boolean;
};
const null_variation = {
id: '',
databaseId: 0,
} as ProductVariation;
function reducer(state: ProductContext, action: ProductAction) {
switch(action.type) {
case 'SET_PRODUCT':
return {
...state,
data: action.payload,
};
case 'SET_VARIATION':
return {
...state,
selectedVariation: action.payload,
};
case 'SET_IS_VARIABLE_PRODUCT':
return {
...state,
isVariableProduct: action.payload,
};
case 'SET_HAS_SELECTED_VARIATION':
return {
...state,
hasSelectedVariation: action.payload,
};
default:
return state;
}
}
const { Provider } = productContext;
export interface ProductProviderProps {
product: Product;
}
export function ProductProvider({ product, children}: PropsWithChildren<ProductProviderProps>) {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
dispatch({
type: 'SET_PRODUCT',
payload: product,
});
if (product.type === ProductTypesEnum.VARIABLE) {
dispatch({
type: 'SET_IS_VARIABLE_PRODUCT',
payload: true,
});
dispatch({
type: 'SET_HAS_SELECTED_VARIATION',
payload: false,
});
dispatch({
type: 'SET_VARIATION',
payload: null_variation,
});
}
}, );
const store = {
...state,
get: (field: keyof ProductVariation | keyof SimpleProduct | keyof VariableProduct) => {
if (!state.data) {
return null;
}
if (state.selectedVariation) {
return get(state.selectedVariation, field as keyof ProductVariation)
|| get(state.data as VariableProduct, field as keyof VariableProduct);
}
return get(state.data as SimpleProduct, field as keyof SimpleProduct);
},
selectVariation: (variation?: ProductVariation) => {
if (!variation) {
dispatch({
type: 'SET_VARIATION',
payload: null_variation,
});
dispatch({
type: 'SET_HAS_SELECTED_VARIATION',
payload: false,
});
return;
}
dispatch({
type: 'SET_VARIATION',
payload: variation,
});
dispatch({
type: 'SET_HAS_SELECTED_VARIATION',
payload: true,
});
},
};
return (
<Provider value={store}>
{children}
</Provider>
)
}
ProductProvider ha un lavoro primario. Per isolare una variazione di prodotto e restituire get Callback per questo.
get prende un nome di campo un restituisce il valore di campo sulla variazione selezionata. Se il prodotto non è un prodotto variabile o non viene selezionata alcuna variazione, get tira il valore del campo dall’oggetto principale del prodotto. Ora impostamo il SessionProvider sul componente pagina singola.
2. Aggiornamento /app/product/[slug]/page.tsx:
// app/product/[slug]/page.tsx
...
import { ShopProduct } from '/server/ShopProduct';
...
export default async function ProductPage({ params }: ProductPageProps) {
...
return (
<ProductProvider product={product}>
<ShopProduct product={product}/>
</ProductProvider>
);
}Siamo quasi pronti a implementare le opzioni del carrello, ma dobbiamo fare alcune modifiche a alcune cose che abbiamo creato negli ultimi tutorial. The SessionProvider componente e session.ts il file di utilità deve essere aggiornato per gestire le azioni del carrello. Cominciamo con il session.ts file.
Parte 3: Aggiornamento SessionProvider
1. Aggiornamento /utils/session.ts:
// utils/session.ts
...
type FetchCartResponse = {
cart: Cart;
sessionToken: string;
}
export type CartAction = {
mutation: 'add';
productId: number;
quantity: number;
variationId?: number;
variation?: {
attributeName: string;
attributeValue: string;
}[]
extraData?: string;
} | {
mutation: 'update';
items: { key: string, quantity: number }[];
} | {
mutation: 'remove';
keys: string[];
all?: boolean;
}
export async function updateCart(input: CartAction): Promise<Cart|string> {
const sessionToken = await getSessionToken();
const authToken = await getAuthToken();
let json: FetchCartResponse;
try {
json = await apiCall<FetchCartResponse>(
'/api/cart',
{
method: 'POST',
body: JSON.stringify({
sessionToken,
authToken,
input,
}),
},
);
} catch (error) {
return (error as GraphQLError)?.message || error as string;
}
const { cart } = json;
saveSessionToken(json.sessionToken);
return cart;
}Un piccolo cambiamento ma si potrebbe aver notato che updateCart funzione di utilità, oltre a prendere il nuovo CartAction tipo come input, invia anche una richiesta POST a /api/cart, che non esiste. Rimettiamolo.
2. Creare /app/api/cart/route.ts:
import { NextResponse } from 'next/server';
import { print } from 'graphql';
import {
AddToCartMutation,
AddToCartDocument,
UpdateCartItemQuantitiesMutation,
UpdateCartItemQuantitiesDocument,
RemoveItemsFromCartMutation,
RemoveItemsFromCartDocument,
Cart,
getClient,
} from '@/graphql';
import { CartAction } from '@/utils/session';
type RequestBody = {
sessionToken: string;
authToken?: string;
input: CartAction;
}
type GraphQLRequestHeaders = {
Authorization?: string;
'woocommerce-session': string;
}
export async function POST(request: Request) {
try {
const body = await request.json() as RequestBody;
if (!body.sessionToken) {
return NextResponse.json({ errors: { message: 'Session not started' } }, { status: 500 });
}
const headers: GraphQLRequestHeaders = { 'woocommerce-session': `Session ${body.sessionToken}` };
if (body.authToken) {
headers.Authorization = `Bearer ${body.authToken}`;
}
const graphQLClient = getClient();
graphQLClient.setHeaders(headers);
if (!body.input) {
return NextResponse.json({ errors: { message: 'No input provided' } }, { status: 500 });
}
const { mutation, ...input } = body.input;
if (!mutation) {
return NextResponse.json({ errors: { message: 'No mutation provided' } }, { status: 500 });
}
let cart: Cart;
let sessionToken: string|null = null;
let results;
switch (mutation) {
case 'add':
results = await graphQLClient.rawRequest<AddToCartMutation>(
print(AddToCartDocument),
{ ...input },
);
cart = results.data?.addToCart?.cart as Cart;
sessionToken = results.headers.get('woocommerce-session');
break;
case 'update':
results = await graphQLClient.rawRequest<UpdateCartItemQuantitiesMutation>(
print(UpdateCartItemQuantitiesDocument),
{ ...input },
);
cart = results.data?.updateItemQuantities?.cart as Cart;
sessionToken = results.headers.get('woocommerce-session') || body.sessionToken;
break;
case 'remove':
results = await graphQLClient.rawRequest<RemoveItemsFromCartMutation>(
print(RemoveItemsFromCartDocument),
{ ...input },
);
cart = results.data?.removeItemsFromCart?.cart as Cart;
sessionToken = results.headers.get('woocommerce-session') || body.sessionToken;
break;
default:
return NextResponse.json({ errors: { message: 'Invalid mutation provided' } }, { status: 500 });
}
if (!cart || !sessionToken) {
const message = 'No cart or session token returned from WooCommerce';
return NextResponse.json({ errors: { message } }, { status: 500 });
}
return NextResponse.json({ cart, sessionToken });
} catch (err) {
console.log(err);
return NextResponse.json({ errors: { message: 'Sorry, something went wrong' } }, { status: 500 });
}
}The /api/cart richiede una richiesta POST prevista sessionToken e una input oggetto a forma del nostro CartAction. Se un authToken è fornito, impostato su richiesta con il sessionToken. Quando dato l’ingresso corretto POST callback tenta di eseguire l’azione specifica da input oggetto e restituire il risultato cart oggetto per la sessione. Con questo siamo pronti ad aggiornare SessionProvider.
3. Aggiornamento /client/SessionProvider:
// client/SessionProvider/SessionProvider.tsx
...
import {
...
CartItem,
MetaData,
VariationAttribute,
} from '@/graphql';
import {
...
updateCart as updateCartApiCall,
CartAction,
} from '@/utils/session';
...
export interface SessionContext {
...
updateCart: (action: CartAction) => Promise<boolean>;
findInCart: (
productId: number,
variationId?: number,
variation?: {
attributeName: string;
attributeValue: string;
}[],
extraData?: string,
) => CartItem|undefined;
}
const initialContext: SessionContext = {
...
updateCart: (action: CartAction) => new Promise((resolve) => { resolve(false); }),
findInCart: (
productId: number,
variationId?: number,
variation?: {
attributeName: string;
attributeValue: string;
}[],
extraData?: string,
) => undefined,
};
...
/**
* Checks if product matches the provided cart/registry item.
*
* @param {number} productId Item product ID.
* @param {number} variationId Item variation ID.
* @param {string} extraData Item metadata JSON string.
* @returns
*/
const cartItemSearch = (
productId: number,
variationId?: number,
variationData?: {
attributeName: string;
attributeValue: string;
}[],
extraData?: string,
skipMeta = false,
) => ({
product,
variation,
extraData:
existingExtraData = [],
}: CartItem) => {
if (product?.node?.databaseId && productId !== product.node.databaseId) {
return false;
}
if (
variation?.node?.databaseId
&& variationId !== variation.node.databaseId
) {
return false;
}
if (variationData?.length && !variation?.attributes?.length) {
return false;
}
if (variationData?.length && variation?.attributes?.length) {
const variationAttributes = variation.attributes as VariationAttribute[];
const found = variationData
.filter(({ attributeName, attributeValue }) => !!variationAttributes?.find(
({ label, value }) => (label as string).toLowerCase() === attributeName && value === attributeValue,
))
.length;
if (!found) return false;
}
if (skipMeta) {
return true;
}
if (existingExtraData?.length && !extraData) {
return false;
}
if (!!extraData && typeof extraData === 'string') {
const decodeMeta = JSON.parse(extraData);
let found = false;
Object.entries(decodeMeta).forEach(([targetKey]) => {
found = !!(existingExtraData as MetaData[])?.find(
({ key, value }) => key === targetKey && value === `${decodeMeta[targetKey]}`,
);
});
if (!found) {
return false;
}
}
return true;
};
...
export function SessionProvider({ children }: PropsWithChildren) {
...
// Risponde all'azione del carrello di processo.
const setCart = (cart: Cart|string) => {
if (typeof cart === 'string') {
toast({
title: 'Cart Action Error',
description: 'Cart mutation failed.',
variant: 'destructive'
});
dispatch({
type: 'UPDATE_STATE',
payload: { fetching: false } as SessionContext,
});
return false;
}
dispatch({
type: 'UPDATE_STATE',
payload: { cart, fetching: false } as SessionContext,
});
return true;
};
const updateCart = (action: CartAction) => {
dispatch({
type: 'UPDATE_STATE',
payload: { fetching: true } as SessionContext,
});
return updateCartApiCall(action)
.then(setCart);
};
const findInCart = (
productId: number,
variationId?: number,
variation?: {
attributeName: string;
attributeValue: string;
}[],
extraData?: string) => {
const items = state?.cart?.contents?.nodes as CartItem[];
if (!items) {
return undefined;
}
return items.find(cartItemSearch(productId, variationId, variation, extraData, true)) || undefined;
};
useEffect(() => {
if (state.fetching) {
return;
}
if (!state.customer || !state.cart) {
fetchSession();
}
}, []);
const store: SessionContext = {
...
updateCart,
findInCart,
};
return (
<Provider value={store}>{children}</Provider>
);
}
export const useSession = () => useContext(sessionContext);Con questo contesto i consumatori hanno accesso al updateCart e findInCart Callback.
updateCart: Esegui un’azione del carrello basata suCartActionoggetto.findInCart: UtilizzazionecartItemSearchfunzione per verificare se un elemento fornito è nel carrello. Se trovato, il corrispondenteCartItemviene restituito.
Parte 4: Creare CartOptions
In questa parte metteremo in atto CartOptions♪ SimpleCartOptionse VariableCartOptions componenti.
1. Creare /server/CartOptions:
// server/CartOptions/CartOptions.tsx
import { PropsWithChildren } from 'react';
import {
Product,
ProductTypesEnum,
SimpleProduct,
} from '@/graphql';
import { cn } from '@/utils/ui';
import { SimpleCartOptions } from '@/client/SimpleCartOptions';
import { VariableCartOptions } from '@/client/VariableCartOptions';
function Container({ className, children }: PropsWithChildren<{ className?: string }>) {
return (
<div
className={cn(
className && className,
'fixed inset-x-0 mx-auto p-4',
'lg:relative lg:inset-auto lg:mx-0 lg:p-0',
'bg-white bottom-0 z-30 w-screen',
'lg:bg-inherit lg:bottom-auto lg:z-auto lg:w-auto',
)}
>
{children}
</div>
);
}
export interface CartOptionsProps {
product: Product;
className?: string;
}
export function CartOptions(props: CartOptionsProps) {
const { product, className } = props;
const { type } = product as unknown as SimpleProduct;
let Component: (props: CartOptionsProps) => JSX.Element|null = () => null;
if (type === ProductTypesEnum.SIMPLE) {
Component = SimpleCartOptions;
} else if (type === ProductTypesEnum.VARIABLE) {
Component = VariableCartOptions;
}
return (
<Container className={className}>
<Component product={product}/>
</Container>
);
}CartOptions è un semplice contenitore per la maggior parte, appiccicoso al fondo sui dispositivi mobili. Prima di muoversi SimpleCartOptions e VariableCartOptions, creiamo un gancio che renderà il carrello del prodotto una brezza.
2. Creare /hooks/useCartMutations.ts:
// hooks/useCartMutations.ts
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '@woographql/client/SessionProvider';
export interface CartMutationInput {
mutation?: 'add'|'update'|'remove';
quantity?: number;
all?: boolean;
variation?: {
attributeName: string;
attributeValue: string;
}[]
}
const useCartMutations = (
productId: number,
variationId?: number,
variation?: {
attributeName: string;
attributeValue: string;
}[],
extraData?: string,
) => {
const {
cart,
updateCart,
findInCart,
fetching,
} = useSession();
const [quantityFound, setQuantityInCart] = useState(
findInCart(productId, variationId, variation, extraData)?.quantity as number || 0,
);
const itemKey = useMemo(
() => findInCart(productId, variationId, variation, extraData)?.key,
[findInCart, productId, variationId, variation, extraData],
);
useEffect(() => {
setQuantityInCart(
findInCart(productId, variationId, variation, extraData)?.quantity || 0,
);
}, [findInCart, productId, variationId, variation, extraData, cart?.contents?.nodes]);
async function mutate<T extends CartMutationInput>(values: T) {
const {
quantity = 1,
all = false,
mutation = 'update',
} = values;
if (!cart) {
return;
}
if (!productId) {
throw new Error('No item provided.');
// TODO: Invia errore a Sentry. IO.
}
switch (mutation) {
case 'remove': {
if (!quantityFound) {
throw new Error('Provided item not in cart');
}
if (!itemKey) {
throw new Error('Failed to find item in cart.');
}
updateCart({
mutation: 'remove',
keys: [itemKey],
all,
});
break;
}
case 'update':
if (!quantityFound) {
throw new Error('Failed to find item in cart.');
}
if (!itemKey) {
throw new Error('Failed to find item in cart.');
}
updateCart({
mutation: 'update',
items: [{ key: itemKey, quantity }]
});
break;
default:
updateCart({
mutation: 'add',
quantity,
productId,
variationId,
variation,
extraData,
});
break;
}
}
const store = {
fetching,
quantityFound,
mutate
};
return store;
};
export default useCartMutations;
Mentre fornito con almeno un productId il useCartMutations gancio con controllo cart più vicino SessionProvider per un carrello che corrisponde a quello productId. Si noti che la ricerca è estremamente esatto per aggiungere il supporto per tutti i tipi di prodotto e meta voce. Significato se l’oggetto in questione è una variazione che deve essere fornito con il corrispondente variationId e tutti i valori dell’attributo non impostati variation oggetto. Ora mettiamo questo gancio da usare nel SimpleCartOptions componente.
3. Creare /client/SimpleCartOptions:
// client/SimpleCartOptions/SimpleCartOptions.tsx
import {
FormEvent,
useState,
useEffect,
} from 'react';
import { cn } from '@/utils/ui';
import { Product, StockStatusEnum } from '@/graphql';
import type { ProductWithPrice } from '@/client/ShopProvider';
import useCartMutations from '@/hooks/useCartMutations';
import { Input } from '@/ui/input';
import { Button } from '@/ui/button';
import { LoadingSpinner } from '@/ui/LoadingSpinner';
import { useToast } from '@/ui/use-toast';
interface CartOptionsProps {
product: Product;
className?: string;
}
export function SimpleCartOptions(props: CartOptionsProps) {
const { toast } = useToast();
const [quantity, setQuantity] = useState(1);
const [executing, setExecuting] = useState<'add'|'update'|'remove'|null>(null);
const { product } = props;
const {
rawPrice,
databaseId,
soldIndividually,
stockStatus,
stockQuantity,
manageStock,
} = product as ProductWithPrice;
const { fetching, mutate, quantityFound } = useCartMutations(databaseId);
const outOfStock = stockStatus === StockStatusEnum.OUT_OF_STOCK;
const mutation = quantityFound ? 'update' : 'add';
let submitButtonText = quantityFound ? 'Update' : 'Add To Basket';
if (outOfStock) {
submitButtonText = 'Out of Stock';
}
const maxQuantity = manageStock ? stockQuantity as number : undefined;
const onAddOrUpdate = async (event: FormEvent) => {
event.preventDefault();
setExecuting(mutation);
await mutate({ mutation, quantity });
if (mutation === 'add') {
toast({
title: 'Added to cart',
description: `${quantity} × ${product.name}`,
});
} else {
toast({
title: 'Updated cart',
description: `${quantity} × ${product.name}`,
});
}
};
const onRemove = async() => {
setExecuting('remove');
await mutate({ mutation: 'remove' });
toast({
title: 'Removed from cart',
description: `${quantity} × ${product.name}`,
});
};
useEffect(() => {
if (!fetching) {
setExecuting(null);
}
}, [fetching]);
useEffect(() => {
if (quantityFound) {
setQuantity(quantityFound);
}
}, [quantityFound]);
return (
<form
onSubmit={onAddOrUpdate}
className="flex flex-wrap gap-x-2 gap-y-4 items-center"
>
{(!soldIndividually || outOfStock) && (
<Input
className="basis-1/2 shrink"
type="number"
min={1}
max={maxQuantity}
value={quantity}
disabled={fetching}
onChange={(event) => setQuantity(Number(event.target.value))}
/>
)}
<p className="basis-auto grow text-center font-serif text-lg">
{outOfStock && 'Out Of Stock'}
{(!soldIndividually || outOfStock) && `× $${rawPrice} = `}
{!outOfStock && (<strong>{`$${Number(rawPrice) * quantity}`}</strong>)}
</p>
<div className="basis-full md:basis-auto flex gap-x-2">
<Button
type="submit"
className={cn(
"basis-full md:basis-auto inline-flex gap-2"
)}
disabled={fetching || outOfStock}
>
{submitButtonText}
{fetching && executing !== 'remove' && <LoadingSpinner noText/>}
</Button>
{!!quantityFound && (
<Button
type="button"
className="basis-full md:basis-auto inline-flex gap-2 bg-red-500"
onClick={onRemove}
disabled={fetching}
>
Remove
{fetching && executing === 'remove' && <LoadingSpinner noText/>}
</Button>
)}
</div>
</form>
);
}Qui dove si chiama useCartMutations con solo un productId perché si trattava solo di prodotto semplice qui. Abbiamo anche alcuni valori direttamente fuori product oggetto in modo da poter correttamente rendere il carrello opzioni forma in base alla disponibilità della voce. Cose veloci da notare:
soldIndividually: Una bandiera booleana che significa che l’oggetto deve essere venduto in quantità di uno, quindi non c’è motivo di rendere il campo di quantità setrue.stockStatus: Un’enumerazione detiene lo stato del prodotto. I valori possibili sonoOUT_OF_STOCK♪ON_BACKORDEReIN_STOCKmanageStock: Una bandiera booleana che significa che c’è una fornitura limitata di questo prodotto.stockQuantity: un valore numerico che rappresenta il numero di prodotto rimasto in approvvigionamento semanageStockètrue, altrimenti può essere ignorato.
Se l’ultimo componente è stato complicato, vorrei scusarmi in anticipo. Ma se aiuta a facilitare la mente, il componente successivo è il componente finale nelle applicazioni. Quindi congratulazioni per averlo fatto finora e grazie per esserti appiccicato con me.
Ora con più ritardo il VariableCartOptions.
4. Creare /client/VariableCartOptions:
// client/VariableCartOptions/VariableCartOptions.tsx
import {
FormEvent,
useState,
useEffect,
CSSProperties,
} from 'react';
import { cn } from '@/utils/ui';
import {
Product,
VariableProduct,
StockStatusEnum,
GlobalProductAttribute,
TermNode,
VariationAttribute,
ProductVariation,
} from '@/graphql';
import useCartMutations from '@/hooks/useCartMutations';
import { useProductContext } from '@/client/ProductProvider';
import { Input } from '@/ui/input';
import { Button } from '@/ui/button';
import { LoadingSpinner } from '@/ui/LoadingSpinner';
import { useToast } from '@/ui/use-toast';
import { RadioGroup, RadioGroupItem } from '@/ui/radio-group';
import { Label } from '@/ui/label';
function ucfirst(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
type SelectedProductAttributes = { [key: string]: string };
interface CartOptionsProps {
product: Product;
className?: string;
}
export function VariableCartOptions({ product }: CartOptionsProps) {
const { toast } = useToast();
const [quantity, setQuantity] = useState(1);
const [executing, setExecuting] = useState<'add'|'update'|'remove'|null>(null);
const { get, selectVariation, hasSelectedVariation, selectedVariation } = useProductContext();
const rawPrice = get('rawPrice' as keyof Product) as string;
const soldIndividually = get('soldIndividually') as boolean;
const stockStatus = get('stockStatus') as StockStatusEnum;
const stockQuantity = get('stockQuantity') as number;
const manageStock = get('manageStock') as boolean;
const attributes = product.attributes?.nodes || [];
const variations = ((product as VariableProduct).variations?.nodes || []) as ProductVariation[];
const defaultAttributes = (product as VariableProduct).defaultAttributes?.nodes || [];
const [selectedAttributes, selectAttributes] = useState<SelectedProductAttributes>(
(defaultAttributes || []).reduce(
(results, attribute) => {
const { value, label } = attribute as VariationAttribute;
return {
...results,
[label as string]: value as string,
};
},
{},
),
);
useEffect(() => {
const variation = variations && variations.find(
({ attributes: variationAttributes }) => (
variationAttributes?.nodes as VariationAttribute[] || []
)?.every(
({ value, label }) => {
const index = ucfirst((label as string).replace(/_|-/g, ' '));
return !value || selectedAttributes[index] === value;
}
),
);
selectVariation(variation);
}, [selectedAttributes, product]);
const productId = product.databaseId;
const variationId = hasSelectedVariation ? get('databaseId') as number : undefined;
// Aggiungi qualsiasi attributo non sulla variazione.
const variationAttributes = selectedVariation?.attributes?.nodes || [];
const variation = Object.entries((selectedAttributes))
.filter(([attributeName, attributeValue]) => {
return !!variationAttributes.find((variationAttribute) => {
const { value, label } = variationAttribute as VariationAttribute;
return !value && label === attributeName;
});
})
.map(([attributeName, attributeValue]) => ({ attributeName: attributeName.toLowerCase(), attributeValue }));
const { fetching, mutate, quantityFound } = useCartMutations(productId, variationId, variation);
const outOfStock = stockStatus === StockStatusEnum.OUT_OF_STOCK;
const mutation = quantityFound ? 'update' : 'add';
let submitButtonText = quantityFound ? 'Update' : 'Add To Basket';
if (outOfStock) {
submitButtonText = 'Out of Stock';
}
const maxQuantity = manageStock ? stockQuantity as number : undefined;
const onAddOrUpdate = async (event: FormEvent) => {
event.preventDefault();
setExecuting(mutation);
await mutate({ mutation, quantity });
if (mutation === 'add') {
toast({
title: 'Added to cart',
description: `${quantity} × ${product.name}`,
});
} else {
toast({
title: 'Updated cart',
description: `${quantity} × ${product.name}`,
});
}
};
const onRemove = async() => {
setExecuting('remove');
await mutate({ mutation: 'remove' });
toast({
title: 'Removed from cart',
description: `${quantity} × ${product.name}`,
});
};
useEffect(() => {
if (!fetching) {
setExecuting(null);
}
}, [fetching]);
useEffect(() => {
if (quantityFound) {
setQuantity(quantityFound);
} else {
setQuantity(1);
}
}, [quantityFound]);
return (
<form
onSubmit={onAddOrUpdate}
className="flex flex-wrap gap-x-2 gap-y-4 items-center"
>
<div className="w-full">
{(attributes || []).map((attribute) => {
const {
id,
name,
label,
options,
variation: isVariationAttribute,
terms,
} = attribute as GlobalProductAttribute;
if (!isVariationAttribute) {
return null;
}
return (
<div key={id} className="w-full flex gap-x-4">
<p className="text-lg font-serif font-medium">{label || name}</p>
<RadioGroup
className="flex gap-2"
name={name as string}
onValueChange={(value) => {
selectAttributes({
...selectedAttributes,
[name as string]: value,
});
}}
>
{(terms?.nodes || options)?.map((option) => {
let value: string;
let buttonLabel: string;
let style: CSSProperties|undefined;
let id;
if (typeof option !== 'object') {
id = `${name}-${option}`;
value = option as string;
buttonLabel = option
.replace('-', ' ')
.replace(/^\w/, (c) => c.toUpperCase());
} else {
const { id: globalId, name: termName, slug } = option as TermNode;
id = globalId;
value = termName as string;
buttonLabel = termName as string;
if (name?.toLowerCase() === 'color') {
style = {
backgroundColor: slug as string,
}
}
}
return (
<div key={id} className="flex items-center space-x-2">
<RadioGroupItem
id={id}
className="w-6 h-6 text-lg"
value={value}
checked={selectedAttributes[name as string] === value}
style={style}
/>
<Label htmlFor={id}>{buttonLabel}</Label>
</div>
);
})}
</RadioGroup>
</div>
);
})}
</div>
{(hasSelectedVariation) ? (
<>
{(!soldIndividually || outOfStock) && (
<Input
className="basis-1/2 shrink"
type="number"
min={1}
max={maxQuantity}
value={quantity}
disabled={fetching}
onChange={(event) => setQuantity(Number(event.target.value))}
/>
)}
<p className="basis-auto grow text-center font-serif text-lg">
{outOfStock && 'Out Of Stock'}
{(!soldIndividually || outOfStock) && `× $${rawPrice} = `}
{!outOfStock && (<strong>{`$${Number(rawPrice) * quantity}`}</strong>)}
</p>
<div className="basis-full md:basis-auto flex gap-x-2">
<Button
type="submit"
className={cn(
"basis-full md:basis-auto inline-flex gap-2"
)}
disabled={fetching || outOfStock}
>
{submitButtonText}
{fetching && executing !== 'remove' && <LoadingSpinner noText/>}
</Button>
{!!quantityFound && (
<Button
type="button"
className="basis-full md:basis-auto inline-flex gap-2 bg-red-500"
onClick={onRemove}
disabled={fetching}
>
Remove
{fetching && executing === 'remove' && <LoadingSpinner noText/>}
</Button>
)}
</div>
</>
) : (
<p className="basis-full md:basis-auto text-center font-serif text-lg">
This product is not available at this time. Sorry, for the inconvenience.
</p>
)}
</form>
);
}Ora, spezziamo questa bestia di un componente. Si noti che questo facendo gran parte della stessa roba del SimpleCartOptions e con qualche modifica minore questo componente potrebbe essere fatto per supportare sia prodotti variabili che semplici. Tuttavia, sarebbe ancora più difficile da leggere in modo da li ho lasciati separati per questo tutorial.
Quindi prima probabilmente si nota dove ottenere molti degli stessi valori che abbiamo ottenuto da product oggetto in SimpleCartOptions.
const rawPrice = get('rawPrice' as keyof Product) as string;
const soldIndividually = get('soldIndividually') as boolean;
const stockStatus = get('stockStatus') as StockStatusEnum;
const stockQuantity = get('stockQuantity') as number;
const manageStock = get('manageStock') as boolean;Tuttavia stiamo usando get callback da ProductProvider in questo modo il valore viene dalla variazione selezionata se si imposta.
La prossima cosa da evidenziare è l’assegnazione del montaggio selectedAttributes che è fonte dal product oggetto defaultAttributes. Se uno non è impostato, i pulsanti dell’attributo sono lasciati unset sul montaggio.
const defaultAttributes = (product as VariableProduct).defaultAttributes?.nodes || [];
const [selectedAttributes, selectAttributes] = useState<SelectedProductAttributes>(
(defaultAttributes || []).reduce(
(results, attribute) => {
const { value, label } = attribute as VariationAttribute;
return {
...results,
[label as string]: value as string,
};
},
{},
),
);Semplice abbastanza, la prossima cosa da guardare è la ricerca di una variazione corrispondente quando la selectedAttributes sono stati aggiornati.
useEffect(() => {
const variation = variations && variations.find(
({ attributes: variationAttributes }) => (
variationAttributes?.nodes as VariationAttribute[] || []
)?.every(
({ value, label }) => {
const index = ucfirst((label as string).replace(/_|-/g, ' '));
return !value || selectedAttributes[index] === value;
}
),
);
selectVariation(variation);
}, [selectedAttributes, product]);Come puoi vedere useEffect viene attivato ogni volta che il principale product oggetto o selectedAttributes è aggiornato. Ciò che fa è confrontare selectedAttributes al attributes di ogni variazione disponibile per vedere se trova una corrispondenza. Se variation manca un valore per un attributo specifico si può presumere che qualsiasi attributo sia accettabile, che l’attributo deve essere specificato appena prima che l’elemento possa essere aggiunto al carrello. E su quella nota arriviamo a durare qualcosa di unico al componente.
const productId = product.databaseId;
const variationId = hasSelectedVariation ? get('databaseId') as number : undefined;
// Aggiungi qualsiasi attributo non sulla variazione.
const variationAttributes = selectedVariation?.attributes?.nodes || [];
const variation = Object.entries((selectedAttributes))
.filter(([attributeName, attributeValue]) => {
return !!variationAttributes.find((variationAttribute) => {
const { value, label } = variationAttribute as VariationAttribute;
return !value && label === attributeName;
});
})
.map(([attributeName, attributeValue]) => ({ attributeName: attributeName.toLowerCase(), attributeValue }));
const { fetching, mutate, quantityFound } = useCartMutations(productId, variationId, variation);Non c’e’ niente fuori dal comune, tranne il variation costante. Mi piacerebbe find il richiamo nel useEffect dall’ultimo snippet ma in retromarcia, cerca selectedAttributes oggetto e cerca eventuali attributi non specificati sul selectedVariation e li mappa alla forma del useCartMutation‘ variation parametro. Questo parametro viene successivamente trasmesso al nostro findInCart opzione per identificare se la variazione del carrello.
Con questo si nota che quando si aggiunge una variazione al carrello Update e Remove vengono visualizzati i pulsanti e quando si modificano gli attributi selezionati utilizzando il pulsante radio Update e Remove scomparire perché la variazione appena selezionata non è il carrello. Questo è il comportamento esatto che vogliamo.
Ora potremmo inserire il pin in questo e chiamarlo fatto, ma facciamo un’ultima cosa per far risaltare la pagina del prodotto, aggiorneremo il ProductImage componente per utilizzare get dal SessionProvider per cambiare l’immagine del display all’immagine selectedVariation.
5. Aggiornamento /client/ProductImage:
// client/ProductImage/ProductImage.tsx
...
import { useProductContext } from '@/client/ProductProvider';
...
export function ProductImage({ product }: ProductImageProps) {
const { get } = useProductContext();
const sourceUrl = get('image.sourceUrl' as keyof Product) as string || product?.image?.sourceUrl;
const altText = get('image.altText' as keyof Product) as string || product?.image?.altText || '';
...
}Tutto qui. Quando si cambia ProductProvider modifiche selectedVariation l’immagine visualizzata cambierà se la variazione ha un set di immagini.
Conclusioni
E li hai, hai raggiunto con successo la fine della nostra serie completa di tutorial. Nel corso di questi tutorial, hai costruito un negozio di eCommerce potente e facile da usare da zero, utilizzando Next.js, WooCommerce, GraphQL e altre tecnologie preziose.
Il viaggio così lontano
E li hai, hai raggiunto con successo la fine della nostra serie completa di tutorial. Nel corso di questi tutorial, hai costruito un negozio di eCommerce potente e facile da usare da zero, utilizzando Next.js, WooCommerce, GraphQL e altre tecnologie preziose.
- A partire dall’impostazione della struttura di base del tuo negozio, hai imparato a catturare e visualizzare le liste dei prodotti dinamicamente, sviluppato un efficiente sistema di filtraggio che affina i prodotti in base a categorie, colori e altri attributi chiave. Hai creato un robusto sistema di impaginazione per gestire una vasta lista di prodotti in modo efficace.
- Inoltre, hai imparato come gestire le sessioni degli utenti finali, consentendo una transizione senza soluzione di continuità dalla nostra applicazione Next.js al negozio WooCommerce ospitato sulla nostra installazione WordPress. Questa funzionalità è un game-changer, che ci permette di utilizzare pagine WooCommerce native, salvandoci dalla creazione di pagine complesse alla nostra fine e migliorare l’esperienza dell’utente.
- Oggi, abbiamo aggiunto il tocco finale creando una pagina del singolo prodotto per visualizzare informazioni dettagliate su ogni prodotto e implementato Opzioni del carrello per prodotti semplici e variabili. Queste caratteristiche sono state armonizzate con
SessionProvider, offrendo un’esperienza di shopping fluida ai tuoi utenti.
Ricordate, tutti i concetti, i metodi e i codici presentati in questi tutorial servono come punto di partenza solido. A seconda delle esigenze specifiche del tuo negozio di eCommerce, puoi sempre espanderti su quello che hai imparato qui.
Congratulazioni per il vostro successo! Hai imparato l’essenziale di costruire un’applicazione eCommerce versatile utilizzando un moderno stack di tecnologia. Continua a imparare, sperimentare e migliorare la tua applicazione. Il mondo dell’eCommerce è sempre in evoluzione, e così dovrebbe il vostro negozio. Non vediamo l’ora di vedere l’incredibile lavoro che produrrete in futuro.

Leave a Reply