Atenção: este tutorial cobre uma versão anterior do WooGraphQL e não está mais atualizado. Uma versão reescrita está em andamento — vamos ligar a ela a partir daqui, uma vez que é publicado.
Sumário
- Configurar WordPress para sua aplicação de comércio eletrônico sem cabeça
- Criando e configurando seu aplicativo de eCommerce para desenvolvimento
- Páginas de Listagem de Loja de Edifícios com WooGraphQL
- Criando login e navegação do usuário
- Criando a página do produto e opções do carrinho
Bem-vindo ao capítulo final da nossa série de tutoriais em profundidade, onde nos concentraremos na implementação das Opções de Produto Único e Carrinho para sua loja de eCommerce. Você fez um longo caminho, e agora é hora de colocar os toques finais em sua aplicação, criando uma experiência de compras poderosa e flexível para seus clientes.
Criando a página do produto e opções do carrinho
Um dos aspectos vitais de qualquer loja de eCommerce é a capacidade de exibir detalhes individuais do produto. Isso permite que os usuários obtenham uma compreensão abrangente dos produtos em que estão interessados, aumentando sua confiança para fazer uma compra. Nós lhe mostraremos como criar e projetar uma página de produto único detalhada que mostra os recursos e atributos únicos do seu produto.
Em seguida, passaremos a melhorar a funcionalidade da sua loja com Opções de Carrinho. Estas opções são a chave para um processo de compras suave e flexível, proporcionando aos usuários o poder de gerenciar seus pedidos de forma eficaz. Seja um produto simples sem variações ou mais complexo com múltiplas variantes, vamos demonstrar como lidar com ambos os cenários.
Procuraremos especificamente criar um sistema de carrinho robusto e intuitivo que funcione em harmonia com o SessionProvider desenvolvido em nosso tutorial anterior. Além disso, introduziremos um novo gancho e uma página de rota, ferramentas que lhe permitirão fornecer experiências de compras perfeitas e de alto nível.
É importante notar que este tutorial baseia-se nos conceitos e códigos introduzidos nas seções anteriores, então certifique-se de que você já passou por todos os tutoriais anteriores e está confortável com o conteúdo.
No final deste tutorial, você terá uma loja de comércio eletrônico totalmente funcional e rica em recursos pronta para encantar seus clientes. Então, sem mais delongas, vamos encerrar nossa série e mergulhar na criação de uma abrangente página de produto único e opções sofisticadas de carrinho.
Parte 1: Criar uma única página de produto
1. Criar /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. Criar /server/ShopProduct:
// servidor/LojaProduto/LojaProduto.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. Criar /client/ProductImage:
// cliente/ProdutoImagem/ProdutoImagem.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 é isso. Temos uma única página de produto com uma seção de imagem e descrição. Olhos atentos têm notado a falta de um preço, opções de carrinho e seções de revisões incompletas.
As revisões estão fora do escopo destes tutoriais. Considere-o trabalho de casa. Quanto às opções de preço e carrinho, vamos trabalhar sobre estes na próxima parte. Assim como os dois tutoriais anteriores, teremos que implementar um novo provedor de contexto. Este vai gerenciar o estado de visualização do produto que estamos vendo. Isso será útil quando trabalhar com produtos variáveis. Vamos ao que interessa.
Parte 2: Criar ProductProvider
1. Criar /client/ProductProvider:
// cliente/ProdutoProvider/ProdutoProvider.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 tem um trabalho primário. Para isolar uma variação do produto e devolver get Liga de volta.
get toma um nome de campo a retorna o valor do campo na variação selecionada. Se o produto não for um produto variável ou não for seleccionada qualquer variação, get puxa o valor do campo do objeto principal do produto. Agora vamos definir o SessionProvider no componente de página única.
2. Actualizar /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>
);
}Estamos quase prontos para implementar as opções do carrinho, mas precisamos fazer algumas mudanças em algumas coisas que criamos nos últimos tutoriais. A SessionProvider componente e session.ts arquivo utilitário precisa ser atualizado para lidar com ações do carrinho. Vamos começar com o session.ts Arquivo.
Parte 3: Actualização SessionProvider
1. Actualizar /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;
}Uma pequena mudança, mas você pode ter notado que novo updateCart função utilitário, além de tomar o novo CartAction digitar como entrada, também envia uma solicitação POST para /api/cart, que não existe. Vamos remediar isso.
2. Criar /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 });
}
}A /api/cart toma um pedido POST esperado um sessionToken e uma input objeto em forma de nosso CartAction. Se authToken é fornecido, definido no pedido com o sessionToken. Quando dada a entrada adequada o POST callback tenta executar a ação específica pela input objeto e devolver o resultado cart objeto para a sessão. Com isso estamos prontos para atualizar o SessionProvider.
3. Actualizar /client/SessionProvider:
// cliente/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) {
...
// Resposta de ação do carrinho de 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);Com esta mudança contexto os consumidores têm acesso ao updateCart e findInCart Liga de volta.
updateCart: Executa uma ação do carrinho com base no fornecidoCartActionobjecto.findInCart: Utiliza ocartItemSearchfunção para verificar se um item fornecido está no carrinho. Se encontrado, o correspondenteCartItemvoltou.
Parte 4: Criar CartOptions
Nesta parte, vamos implementar o CartOptions, SimpleCartOptions, e VariableCartOptions componentes.
1. Criar /server/CartOptions:
// servidor/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 é um recipiente simples para a maioria das partes, pegajoso até o fundo em dispositivos móveis. Antes de se deslocar SimpleCartOptions e VariableCartOptions, vamos criar um gancho que fará o carrinho do produto estado uma brisa.
2. Criar /hooks/useCartMutations.ts:
// ganchos/usarCartMutations.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.');
// Enviar erro ao 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;
Embora fornecido com pelo menos um productId a useCartMutations gancho com verificar o cart mais próximo SessionProvider para um item do carrinho que corresponda a isso productId. Note que a busca é extremamente exata para adicionar suporte para todos os tipos de produto e meta item. Significa que, se o item em questão for uma variação, deve ser fornecido o correspondente variationId e quaisquer valores de atributos não definidos na variation objecto. Agora vamos colocar este gancho para usar no SimpleCartOptions componente.
3. Criar /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>
);
}Aqui onde está a chamar useCartMutations com apenas um productId porque estávamos apenas lidando com produto simples aqui. Também conseguimos alguns valores diretamente do product objeto para que possamos corretamente renderizar o formulário de opções do carrinho de acordo com a disponibilidade do item. Coisas rápidas para notar:
soldIndividually: Uma bandeira booleana significa que o item deve ser vendido em quantidade de um, então não há razão para renderizar o campo de quantidade setrue.stockStatus: Uma enumeração detém o status de estoque do produto. Os valores possíveis sãoOUT_OF_STOCK,ON_BACKORDER, eIN_STOCKmanageStock: Uma bandeira booleana significa que existe um limite de fornecimento deste produto.stockQuantity: Um valor numérico que representa o número de produtos restantes na oferta semanageStockétrue, caso contrário, pode ser ignorado.
Se você pensou que o último componente era complicado, eu gostaria de pedir desculpas com antecedência. Mas se isso ajuda a aliviar sua mente, o próximo componente é o componente final das aplicações. Parabéns por terem chegado até aqui e obrigado por ficarem comigo.
Agora, com mais algum atraso o VariableCartOptions.
4. Criar /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;
// Adicione quaisquer atributos não sobre a variação.
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>
);
}Agora, vamos quebrar esta besta de um componente. Note que isto fazendo muito do mesmo material que o SimpleCartOptions e com alguns ajustes menores este componente poderia ser feito para suportar produtos variáveis e simples. No entanto, seria ainda mais difícil de ler, então eu os deixei separados para este tutorial.
Então, primeiro você provavelmente notar onde obter muito dos mesmos valores que recebemos do product objeto em 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;No entanto, estamos usando o get chamada de volta de ProductProvider desta forma o valor vem da variação selecionada se for definido.
A próxima coisa a destacar é a alocação de montagem selectedAttributes que é proveniente da product objeto defaultAttributes. Se um não estiver definido, os botões de rádio do atributo ficam desactivados na montagem.
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,
};
},
{},
),
);Simples o suficiente, a próxima coisa a olhar é a busca por uma variação correspondente quando o selectedAttributes foram actualizados.
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]);Como você pode ver o useEffect é acionado cada vez que o principal product objeto ou selectedAttributes está actualizado. O que ele faz é comparar o selectedAttributes à attributes de cada variação disponível para ver se encontra uma correspondência. Se a variation está faltando um valor para um atributo específico que pode ser assumido que qualquer atributo é aceitável, que atributo apenas tem que ser especificado antes que o item pode ser adicionado ao carrinho. E nessa nota vamos chegar à última coisa única para o componente.
const productId = product.databaseId;
const variationId = hasSelectedVariation ? get('databaseId') as number : undefined;
// Adicione quaisquer atributos não sobre a variação.
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);Nada fora do comum aqui exceto o variation constante. Gostaria que find a chamada de volta no useEffect do último trecho, mas ao contrário, ele busca selectedAttributes objeto e procura quaisquer atributos que não sejam especificados no selectedVariation e mapeia-os para a forma do useCartMutation‘s variation parâmetro. Este parâmetro é passado mais tarde para o nosso findInCart opção para identificar se a variação no carrinho.
Com isso você nota que quando você adiciona uma variação ao carrinho o Update e Remove os botões aparecem e quando você muda os atributos selecionados usando o botão de rádio Update e Remove desaparecer porque a nova variação selecionada não é o carrinho. Este é o comportamento exacto que queremos.
Agora nós poderíamos colocar pin neste e chamá-lo feito mas vamos fazer uma última coisa para fazer a página do produto pop out, vamos atualizar o ProductImage componente para utilizar o get da SessionProvider para alterar a imagem de exibição para a imagem do selectedVariation.
5. Actualizar /client/ProductImage:
// cliente/ProdutoImagem/ProdutoImagem.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 || '';
...
}É isso. Quando você mudar o ProductProvider altera a selectedVariation a imagem exibida irá mudar se a variação tiver um conjunto de imagem.
Conclusão
E aí está, você chegou ao fim da nossa abrangente série tutorial. Ao longo desses tutoriais, você construiu uma poderosa e amigável loja de eCommerce do zero para cima, utilizando Next.js, WooCommerce, GraphQL, e outras tecnologias valiosas.
A viagem até agora
E aí está, você chegou ao fim da nossa abrangente série tutorial. Ao longo desses tutoriais, você construiu uma poderosa e amigável loja de eCommerce do zero para cima, utilizando Next.js, WooCommerce, GraphQL, e outras tecnologias valiosas.
- Começando com a configuração da estrutura fundamental de sua loja, você aprendeu a buscar e exibir dinamicamente listas de produtos, desenvolveu um sistema de filtragem eficiente que refinar produtos baseados em categorias, cores e outros atributos chave. Você criou um sistema robusto de paginação para lidar com uma extensa lista de produtos de forma eficaz.
- Além disso, você aprendeu como lidar com sessões de usuário final, permitindo uma transição perfeita do nosso aplicativo Next.js para a loja WooCommerce hospedada em nossa instalação WordPress. Esta funcionalidade é uma mudança de jogo, permitindo-nos utilizar páginas nativas WooCommerce, salvando-nos de criar páginas complexas no nosso final e melhorar a experiência do usuário.
- Hoje, adicionamos o toque final criando uma página de Produto Único para exibir informações detalhadas sobre cada produto e implementamos Opções de Carrinho para produtos simples e variáveis. Estas características foram harmonizadas com o
SessionProvider, proporcionando uma experiência de compras suave para seus usuários.
Lembre-se, todos os conceitos, métodos e códigos introduzidos nesses tutoriais servem como um ponto de partida sólido. Dependendo das necessidades específicas de sua loja de eCommerce, você sempre pode expandir sobre o que você aprendeu aqui.
Parabéns pelo seu feito! Você dominou o essencial da construção de uma aplicação de comércio eletrônico versátil usando uma pilha de tecnologia moderna. Continue aprendendo, experimentando e aprimorando sua aplicação. O mundo do comércio eletrônico está sempre evoluindo, assim como sua loja. Estamos ansiosos para ver o incrível trabalho que você vai produzir no futuro.

Leave a Reply