En haut : ce tutoriel couvre une version WooGraphQL antérieure et n’est plus à jour. Une version réécrite est en cours — nous y lierons une fois qu’elle sera publiée.
Sommaire
- Configuration WordPress pour votre application eCommerce sans tête
- Création et mise en place de votre application eCommerce pour le développement
- Pages d’annonce de boutique de construction avec WooGraphQL
- Création de connexion utilisateur et navigation
- Création de la page Produit et des options de panier
Bienvenue dans le dernier chapitre de notre série de tutoriels approfondis, où nous nous concentrerons sur la mise en œuvre des options de produit et de panier uniques pour votre magasin eCommerce. Vous avez fait un long chemin, et maintenant il est temps de mettre la touche finale à votre application, créant une expérience d’achat puissante et flexible pour vos clients.
Création de la page Produit et des options de panier
L’un des aspects essentiels de tout magasin eCommerce est la possibilité d’afficher les détails individuels du produit. Cela permet aux utilisateurs d’acquérir une compréhension complète des produits qui les intéressent, augmentant leur confiance pour faire un achat. Nous vous expliquerons comment créer et concevoir une page produit unique détaillée qui présente les caractéristiques et attributs uniques de votre produit.
Ensuite, nous passerons à l’amélioration de la fonctionnalité de votre magasin avec les Options de Panier. Ces options sont la clé d’un processus d’achat fluide et flexible, permettant aux utilisateurs de gérer efficacement leurs commandes. Qu’il s’agisse d’un produit simple sans variations ou d’un produit plus complexe avec plusieurs variantes, nous démontrerons comment gérer les deux scénarios.
Nous chercherons spécifiquement à créer un système de chariot robuste et intuitif qui fonctionne en harmonie avec le SessionProvider développé dans notre précédent tutoriel. De plus, nous allons introduire un nouveau crochet et une page d’itinéraire, des outils qui vous permettront de fournir des expériences d’achat de haut niveau et sans couture.
Il est important de noter que ce tutoriel s’appuie sur les concepts et les codes introduits dans les sections précédentes, alors assurez-vous que vous avez passé par tous les tutoriels précédents et êtes à l’aise avec le contenu.
À la fin de ce tutoriel, vous aurez un magasin eCommerce entièrement fonctionnel et riche en fonctionnalités prêt à charmer vos clients. Donc, sans plus attendre, finissons notre série et plongeons dans la création d’une page complète du produit unique et des options de panier sophistiquées.
Partie 1: Créer une page de produit unique
1. Créer /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. Créer /server/ShopProduct:
// serveur/ShopProduit/ShopProduit.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. Créer /client/ProductImage:
// client/ProduitImage/ProduitImage.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>
);
}
Et c’est tout. Nous avons une seule page de produit avec une image et des sections de description. Keen yeux ont remarqué l’absence d’un prix, options de panier et des sections d’évaluation incomplètes.
Les commentaires sont hors de portée de ces tutoriels. Considérez ça comme un devoir. Quant aux options de prix et de chariot, nous allons travailler sur celles-ci dans la prochaine partie. Tout comme les deux tutoriels précédents, nous devrons mettre en place un nouveau fournisseur de contexte. Celui-ci gérera l’état de visionnement du produit que nous visualisons. Cela sera utile lorsque vous travaillez avec des produits variables. Allons-y.
Partie 2: Créer ProductProvider
1. Créer /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 a un travail principal. Pour isoler une variation de produit et retourner la get C’est un rappel.
get prend un nom de champ un renvoie la valeur de champ sur la variation sélectionnée. Si le produit n’est pas un produit variable ou si aucune variation n’est sélectionnée, get tire la valeur du champ de l’objet principal du produit. Maintenant, mettons le SessionProvider sur le composant d’une seule page.
2. Mise à jour /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>
);
}Nous sommes presque prêts à mettre en œuvre les options de panier, mais nous devons apporter quelques changements à certaines choses que nous avons créées dans les derniers tutoriels. Les SessionProvider composante et session.ts Le fichier utilitaire doit être mis à jour pour gérer les actions du panier. Commençons par session.ts fichier.
Partie 3: Mise à jour SessionProvider
1. Mise à jour /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 changement mineur mais vous auriez pu remarquer que updateCart fonction utilitaire, en plus de prendre le nouveau CartAction type comme entrée, il envoie également une requête POST à /api/cart, qui n’existe pas. Réparons ça.
2. Créer /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 });
}
}Les /api/cart prend une demande POST attendue a sessionToken et une input objet en forme de notre CartAction. Si authToken est fourni, fixé sur la demande avec sessionToken. Lorsque l’entrée appropriée est donnée POST callback tente d’exécuter l’action spécifique par le input objet et retourner le résultat cart Object pour la session. Avec cela, nous sommes prêts à mettre à jour le SessionProvider.
3. Mise à jour /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) {
...
// Réaction à l'action du chariot de procédé.
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);Dans ce contexte de changement, les consommateurs ont accès à updateCart et findInCart callback.
updateCart: Exécute une action de chariot basée sur fourniCartActionObjet.findInCart: Utilise lacartItemSearchfonction pour vérifier si un élément fourni est dans le panier. S’il est trouvé,CartItemest retourné.
Partie 4: Créer CartOptions
Dans cette partie, nous mettrons en œuvre CartOptions, SimpleCartOptionset VariableCartOptions composants.
1. Créer /server/CartOptions:
// serveur/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 est un conteneur simple pour la plupart, collant au fond sur les appareils mobiles. Avant de passer à la SimpleCartOptions et VariableCartOptions, laissez créer un crochet qui fera le chariot du produit état une brise .
2. Créer /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.');
// Envoyer une erreur à Sentry. Oui.
}
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;
Bien que fourni avec au moins une productId des useCartMutations crochet avec vérifier cart la plus proche SessionProvider pour un article de panier qui correspond à productId. Notez que la recherche est extrêmement exacte pour ajouter un support pour tous les types de produit et d’élément méta. Signification si l’élément en question est une variation, il doit être fourni avec le variationId et toute valeur d’attribut non définie dans variation Objet. Maintenant mettons ce crochet à utiliser dans le SimpleCartOptions composante.
3. Créer /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>
);
}Ici où appeler useCartMutations avec juste un productId parce qu’il s’agissait de produits simples ici. Nous obtenons également quelques valeurs directement de la product objet afin que nous puissions rendre correctement le formulaire d’options de panier selon la disponibilité de l’article. Des choses rapides à noter :
soldIndividually: Un drapeau booléen signifiant que l’article doit être vendu en quantité d’un, il n’y a donc aucune raison de rendre le champ de quantité sitrue.stockStatus: Un dénombrement conserve le statut de stock du produit. Les valeurs possibles sont:OUT_OF_STOCK,ON_BACKORDERetIN_STOCKmanageStock: Un drapeau booléen signifiant qu’il y a une limite d’approvisionnement de ce produit.stockQuantity: Une valeur numérique qui représente le nombre de produits laissés en stock simanageStockesttrue, sinon il peut être ignoré.
Si le dernier composant était compliqué, je voudrais m’excuser à l’avance. Mais s’il aide à faciliter votre esprit, le prochain composant est le composant final dans les applications. Alors, félicitations pour avoir réussi et merci d’être resté avec moi.
Maintenant avec plus de retard VariableCartOptions.
4. Créer /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;
// Ajouter tous les attributs qui ne sont pas sur la variation.
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>
);
}Détruisons cette bête d’un composant. Notez que cela fait beaucoup de la même chose que le SimpleCartOptions et avec un léger ajustement de ce composant pourrait être fait pour soutenir les produits à la fois variables et simples. Cependant, il serait encore plus difficile de lire alors je les ai laissés séparés pour ce tutoriel.
Donc d’abord vous remarquez probablement où obtenir la plupart des mêmes valeurs que nous avons obtenu de la product objet dans 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;Cependant, nous utilisons get rappel depuis ProductProvider de cette façon la valeur vient de la variation sélectionnée si on est défini.
La prochaine chose à souligner est l’attribution du montage selectedAttributes d ‘ origine product objet defaultAttributes. Si l’on n’est pas réglé, les boutons radio d’attribut sont laissés en mode non réglé sur le montage.
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,
};
},
{},
),
);Si simple, la prochaine chose à regarder est la recherche d’une variation correspondante lorsque le selectedAttributes ont été mises à jour.
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]);Comme vous pouvez le voir useEffect est déclenché à chaque fois que le principal product objet ou selectedAttributes est mis à jour. Ce qu’il fait est de comparer les selectedAttributes aux attributes de chaque variation disponible pour voir si elle trouve une correspondance. Si la variation manque une valeur pour un attribut spécifique on peut supposer que tout attribut est acceptable, cet attribut doit juste être spécifié avant que l’élément puisse être ajouté au panier. Et sur cette note nous allons arriver à la dernière chose unique au composant.
const productId = product.databaseId;
const variationId = hasSelectedVariation ? get('databaseId') as number : undefined;
// Ajouter tous les attributs qui ne sont pas sur la variation.
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);Rien d’extraordinaire ici, sauf les variation constante. Il aimerait que le find rappel dans le useEffect à partir du dernier extrait mais à l’inverse, il recherche selectedAttributes objet et recherche tous les attributs qui ne sont pas spécifiés sur le selectedVariation et les cartographie à la forme du useCartMutation‘s variation paramètre. Ce paramètre est ensuite passé à notre findInCart option pour identifier si la variation dans le panier.
Avec cela, vous remarquez que lorsque vous ajoutez une variation au panier le Update et Remove boutons apparaissent et lorsque vous changez les attributs sélectionnés en utilisant le bouton radio Update et Remove disparaître parce que la variation nouvellement sélectionnée n’est pas le panier. C’est le comportement exact que nous voulons.
Maintenant nous pourrions mettre pin dedans et l’appeler fait mais faisons une dernière chose pour faire apparaître la page Produit, nous allons mettre à jour le ProductImage composant pour utiliser get des SessionProvider pour changer l’image d’affichage à l’image de la selectedVariation.
5. Mise à jour /client/ProductImage:
// client/ProduitImage/ProduitImage.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 || '';
...
}C’est ça. Lorsque vous changez ProductProvider change la selectedVariation l’image affichée changera si la variation a un jeu d’images.
Conclusion
Et là, vous l’avez, vous avez réussi à atteindre la fin de notre série complète de tutoriels. Au cours de ces tutoriels, vous avez construit un magasin eCommerce puissant et convivial à partir de la base, en utilisant Next.js, WooCommerce, GraphQL, et d’autres technologies précieuses.
Le voyage si loin
Et là, vous l’avez, vous avez réussi à atteindre la fin de notre série complète de tutoriels. Au cours de ces tutoriels, vous avez construit un magasin eCommerce puissant et convivial à partir de la base, en utilisant Next.js, WooCommerce, GraphQL, et d’autres technologies précieuses.
- En commençant par définir la structure fondamentale de votre magasin, vous avez appris à récupérer et afficher dynamiquement les listes de produits, développé un système de filtrage efficace qui raffine les produits en fonction des catégories, des couleurs et d’autres attributs clés. Vous avez créé un système de pagination robuste pour gérer efficacement une vaste liste de produits.
- En outre, vous avez appris à gérer les sessions de l’utilisateur final, permettant une transition transparente de notre application Next.js au magasin WooCommerce hébergé sur notre installation WordPress. Cette fonctionnalité est un changement de jeu, nous permettant d’utiliser les pages natives WooCommerce, nous sauvent de créer des pages complexes à notre fin et d’améliorer l’expérience utilisateur.
- Aujourd’hui, nous avons ajouté la touche finale en créant une page Produit unique pour afficher des informations détaillées sur chaque produit et implémenté Options de Panier pour les produits simples et variables. Ces caractéristiques ont été harmonisées avec la
SessionProvider, offrant une expérience d’achat en douceur à vos utilisateurs.
Rappelez-vous, tous les concepts, méthodes et codes introduits dans ces tutoriels servent de point de départ solide. Selon les besoins spécifiques de votre magasin eCommerce, vous pouvez toujours développer ce que vous avez appris ici.
Félicitations pour votre réussite! Vous avez maîtrisé l’essentiel de la construction d’une application eCommerce polyvalente en utilisant une pile technologique moderne. Continuez à apprendre, à expérimenter et à améliorer votre application. Le monde de l’eCommerce est toujours en évolution, et devrait votre magasin. Nous sommes impatients de voir le travail incroyable que vous allez produire à l’avenir.

Leave a Reply