Aufbau von Headless Shops mit WooGraphQL: Kapitel 5 von 5

banner

Heads up: Dieses Tutorial deckt eine frühere WooGraphQL-Version ab und ist nicht mehr auf dem neuesten Stand. Eine neu geschriebene Version ist in Arbeit – wir werden von hier aus darauf verlinken, sobald sie veröffentlicht wurde.

https://www.youtube.com/embed/nhlexP-KtSo

Inhaltsverzeichnis

  1. Einrichten von WordPress für Ihre Headless eCommerce-Anwendung
  2. Erstellen und Einrichten Ihrer eCommerce-App für die Entwicklung
  3. Erstellen von Shop Listing Pages mit WooGraphQL
  4. Erstellen von Benutzer-Login und Navigation
  5. Erstellen der Produktseite und Warenkorboptionen

Willkommen zum letzten Kapitel unserer ausführlichen Tutorial-Serie, in der wir uns auf die Implementierung der Einzelprodukt- und Warenkorboptionen für Ihren E-Commerce-Shop konzentrieren werden. Sie haben es weit gebracht, und jetzt ist es an der Zeit, Ihrer Anwendung den letzten Schliff zu geben und ein leistungsstarkes und flexibles Einkaufserlebnis für Ihre Kunden zu schaffen.

Erstellen der Produktseite und Warenkorboptionen

Einer der wichtigsten Aspekte eines jeden E-Commerce-Shops ist die Möglichkeit, individuelle Produktdetails anzuzeigen. Dies ermöglicht es Benutzern, ein umfassendes Verständnis der Produkte zu erlangen, an denen sie interessiert sind, und erhöht so ihr Vertrauen in einen Kauf. Wir führen Sie durch die Erstellung und Gestaltung einer detaillierten Einzelproduktseite, die die einzigartigen Eigenschaften und Eigenschaften Ihres Produkts zeigt.

Als nächstes werden wir die Funktionalität Ihres Shops mit Warenkorboptionen verbessern. Diese Optionen sind der Schlüssel zu einem reibungslosen und flexiblen Einkaufsprozess, der den Benutzern die Möglichkeit gibt, ihre Bestellungen effektiv zu verwalten. Ob es sich um ein einfaches Produkt ohne Variationen oder ein komplexeres mit mehreren Varianten handelt, wir zeigen, wie man mit beiden Szenarien umgeht.

Wir werden uns speziell mit der Schaffung eines robusten und intuitiven Warenkorbsystems befassen, das in Harmonie mit dem SessionProvider In unserem vorherigen Tutorial entwickelt. Darüber hinaus werden wir einen neuen Haken und eine Routenseite vorstellen, Tools, mit denen Sie erstklassige, nahtlose Einkaufserlebnisse bieten können.

Es ist wichtig zu beachten, dass dieses Tutorial auf den Konzepten und Codes aufbaut, die in früheren Abschnitten eingeführt wurden, also stellen Sie sicher, dass Sie alle vorherigen Tutorials durchlaufen haben und mit dem Inhalt vertraut sind.

Am Ende dieses Tutorials haben Sie einen voll funktionsfähigen, funktionsreichen E-Commerce-Shop, der bereit ist, Ihre Kunden zu begeistern. Lassen Sie uns also ohne weiteres unsere Serie abschließen und in die Erstellung einer umfassenden Single Product-Seite und anspruchsvoller Warenkorboptionen eintauchen.

Teil 1: Erstellen einer einzelnen Produktseite

1. Schaffen /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. Schaffen /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. Schaffen /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>
  );
}

Und das ist es. Wir haben eine einzige Produktseite mit einem Bild und Beschreibung Abschnitte. Keen Augen haben das Fehlen eines Preises, Warenkorboptionen und unvollständige Bewertungen Abschnitte bemerkt.

Bewertungen sind out-of-scope dieser Tutorials. Betrachten Sie es als Hausaufgaben. Was die Preis- und Warenkorboptionen betrifft, werden wir im nächsten Teil daran arbeiten. Genau wie die beiden vorherigen Tutorials müssen wir einen neuen Kontextanbieter implementieren. Dieser verwaltet den Anzeigezustand des Produkts, das wir betrachten. Dies wird sich bei der Arbeit mit variablen Produkten als nützlich erweisen. Lassen Sie uns dann dazu kommen.

Teil 2: Erschaffen ProductProvider

1. Schaffen /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 hat einen primären Job. Um eine Produktvariante zu isolieren und die get Callback dafür.

get nimmt einen Feldnamen a gibt den Feldwert für die ausgewählte Variante zurück. Wenn das Produkt kein variables Produkt ist oder keine Variation ausgewählt wird, get zieht den Feldwert aus dem Hauptproduktobjekt. Jetzt setzen wir die SessionProvider auf der Einzelseitenkomponente.

2. Aktualisierung /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>
  );
}

Wir sind fast bereit, die Warenkorboptionen zu implementieren, aber wir müssen einige Änderungen an einigen Dingen vornehmen, die wir in den letzten Tutorials erstellt haben. Die SessionProvider Bauteil und session.ts Die Utility-Datei muss aktualisiert werden, um Warenkorbaktionen zu bearbeiten. Beginnen wir mit dem session.ts Datei.

Teil 3: Update SessionProvider

1. Aktualisierung /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;
}

Eine kleine Änderung, aber Sie haben vielleicht bemerkt, dass neue updateCart Utility-Funktion, neben der Einnahme der neuen CartAction Geben Sie als Eingabe ein, es sendet auch eine POST-Anfrage an /api/cart, die es nicht gibt. Lassen Sie uns das beheben.

2. Schaffen /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 });
  }
}

Die /api/cart nimmt eine POST-Anfrage an, erwartet eine sessionToken und ein input Objekt in der Form unserer CartActionWenn ein authToken wird bereitgestellt, auf Anfrage mit dem sessionTokenWenn man den richtigen Input erhält, POST Rückrufversuche zur Ausführung der von der input Objekt und Rückgabe des resultierenden cart Objekt für die Sitzung. Damit sind wir bereit, die SessionProvider.

3. Aktualisierung /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) {
  ...

  // Reaktion auf die Aktion des Prozesswagens.
  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);

Mit diesem Veränderungskontext haben die Verbraucher Zugang zum updateCart und findInCart Rückruf.

  • updateCart: Führt eine Cart-Aktion basierend auf bereitgestellt aus CartAction Objekt.
  • findInCart: Nutzt die cartItemSearch Funktion, um zu überprüfen, ob sich ein bereitgestellter Artikel im Warenkorb befindet. Wenn gefunden, die entsprechende CartItem wird zurückgegeben.

Teil 4: Erstellen CartOptions

In diesem Teil werden wir die CartOptions, SimpleCartOptions, und VariableCartOptions Bauteile.

1. Schaffen /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 ist ein einfacher Behälter zum größten Teil, klebrig am Boden auf mobilen Geräten. Bevor Sie auf die SimpleCartOptions und VariableCartOptions, lassen Sie einen Haken erstellen, der den Warenkorbzustand des Produkts zu einer Brise macht.

2. Schaffen /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: Senden Sie Fehler an 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;

Während mit mindestens einem productId das useCartMutations Haken mit Check the cart nächstgelegen SessionProvider für einen Warenkorbartikel, der mit dem productIdBeachten Sie, dass die Suche äußerst genau ist, um Unterstützung für alle Arten von Produkt- und Artikelmeta hinzuzufügen. Das heißt, wenn es sich bei dem betreffenden Gegenstand um eine Variation handelt, muss er mit dem entsprechenden variationId und alle unset-Attributwerte in der variation Objekt. Jetzt lassen Sie uns diesen Haken in der SimpleCartOptions Bauteil.

3. Schaffen /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>
  );
}

Hier, wo der Aufruf useCartMutations Mit nur einem productId weil wir es hier nur mit einfachen Produkten zu tun hatten. Wir erhalten auch ein paar Werte direkt von der product Objekt, damit wir das Warenkorb-Optionsformular entsprechend der Verfügbarkeit des Artikels richtig darstellen können. Schnelle Dinge zu beachten:

  • soldIndividuallyEin boolesches Flag, das anzeigt, dass der Artikel in einer Menge verkauft werden soll, so dass es keinen Grund gibt, das Mengenfeld zu rendern, wenn true.
  • stockStatus: Eine Enumeration hält den Bestandsstatus des Produkts. Mögliche Werte sind OUT_OF_STOCK, ON_BACKORDER, und IN_STOCK
  • manageStock: Eine boolesche Flagge, die anzeigt, dass es eine begrenzte Versorgung mit diesem Produkt gibt.
  • stockQuantityEin numerischer Wert, der die Anzahl der im Lieferumfang verbleibenden Produkte darstellt, wenn manageStock ist trueAnsonsten kann es ignoriert werden.

Wenn Sie dachten, dass die letzte Komponente kompliziert war, möchte ich mich im Voraus entschuldigen. Aber wenn es hilft, Ihren Geist zu beruhigen, ist die nächste Komponente die letzte Komponente in den Anwendungen. Also herzlichen Glückwunsch, dass Sie es so weit gebracht haben, und danke, dass Sie bei mir geblieben sind.

Mit weiteren Verzögerungen VariableCartOptions.

4. Schaffen /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;
  // Fügen Sie alle Attribute hinzu, die nicht in der Variation enthalten sind.
  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>
  );
}

Lassen Sie uns nun dieses Biest einer Komponente zerlegen. Beachten sie, dass dies viel von dem gleichen zeug wie die. SimpleCartOptions und mit einigen kleinen Optimierungen könnte diese Komponente sowohl variable als auch einfache Produkte unterstützen. Es würde es jedoch noch schwieriger machen zu lesen, also habe ich sie für dieses tutorial getrennt gelassen.

Also zuerst bemerken Sie wahrscheinlich, wo viel von den gleichen Werten bekommen wir von der product Objekt 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;

Wir verwenden jedoch die get Rückruf von ProductProvider Auf diese Weise kommt der Wert aus der ausgewählten Variation, wenn eine festgelegt ist.

Als nächstes ist die Zuweisung von Montage hervorzuheben selectedAttributes die aus dem product Objekt defaultAttributesWenn eine nicht eingestellt ist, werden die Attribut-Radio-Tasten auf der Halterung nicht gesetzt.

  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,
        };
      },
      {},
    ),
  );

Einfach genug, als nächstes ist die Suche nach einer passenden Variante, wenn die selectedAttributes aktualisiert wurden.

  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]);

Wie Sie sehen können, useEffect wird jedes Mal ausgelöst, wenn die Haupt product Gegenstand oder selectedAttributes aktualisiert wird. Was es tut, ist zu vergleichen selectedAttributes an den attributes jeder Variation zur Verfügung, um zu sehen, ob es eine Übereinstimmung zu finden. Wenn die variation Wenn ein Wert für ein bestimmtes Attribut fehlt, kann davon ausgegangen werden, dass ein beliebiges Attribut akzeptabel ist, dieses Attribut muss nur angegeben werden, bevor der Artikel dem Warenkorb hinzugefügt werden kann. Und in diesem Sinne kommen wir zum letzten, einzigartigen Element.

  const productId = product.databaseId;
  const variationId = hasSelectedVariation ? get('databaseId') as number : undefined;
  // Fügen Sie alle Attribute hinzu, die nicht in der Variation enthalten sind.
  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);

Nichts Außergewöhnliches außer dem variation konstant. Sie wünscht sich find Callback im useEffect vom letzten snippet, aber umgekehrt, es sucht. selectedAttributes Objekt und sucht nach Attributen, die auf dem selectedVariation und ordnet sie der Gestalt der useCartMutations variation Parameter. Dieser Parameter wird später an unsere findInCart Option, um zu erkennen, ob die Variation im Warenkorb.

Damit bemerken Sie, dass, wenn Sie eine Variation zum Warenkorb hinzufügen, Update und Remove Buttons erscheinen und wenn Sie die ausgewählten Attribute mit dem Radio-Button ändern, Update und Remove verschwinden, weil die neu ausgewählte Variante nicht der Wagen ist. Das ist genau das Verhalten, das wir wollen.

Jetzt könnten wir Pin in diesem und nennen es getan, aber lassen Sie uns eine letzte Sache tun, um die Produktseite Pop-out zu machen, werden wir die aktualisieren ProductImage Komponente zur Nutzung der get aus dem SessionProvider das Anzeigebild in das Bild des selectedVariation.

5. Aktualisierung /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 || '';
  ...

}

Das ist es. Wenn Sie den Wechsel ProductProvider Änderungen selectedVariation das angezeigte Bild ändert sich, wenn die Variation ein Bildset hat.

Schlussfolgerung

Und da haben Sie es, Sie haben erfolgreich das Ende unserer umfassenden Tutorial-Serie erreicht. Im Laufe dieser Tutorials haben Sie von Grund auf einen leistungsstarken und benutzerfreundlichen E-Commerce-Shop mit Next.js, WooCommerce, GraphQL und anderen wertvollen Technologien aufgebaut.

Die Reise bisher

Und da haben Sie es, Sie haben erfolgreich das Ende unserer umfassenden Tutorial-Serie erreicht. Im Laufe dieser Tutorials haben Sie von Grund auf einen leistungsstarken und benutzerfreundlichen E-Commerce-Shop mit Next.js, WooCommerce, GraphQL und anderen wertvollen Technologien aufgebaut.

  • Beginnend mit der Einrichtung der grundlegenden Struktur Ihres Shops haben Sie gelernt, wie Sie Produktlisten dynamisch abrufen und anzeigen können, und ein effizientes Filtersystem entwickelt, das Produkte basierend auf Kategorien, Farben und anderen wichtigen Attributen verfeinert. Sie haben ein robustes Paginierungssystem erstellt, um eine umfangreiche Liste von Produkten effektiv zu verwalten.
  • Darüber hinaus haben Sie gelernt, wie Sie mit Endbenutzersitzungen umgehen und einen nahtlosen Übergang von unserer Next.js-Anwendung zum WooCommerce-Shop ermöglichen, der in unserer WordPress-Installation gehostet wird. Diese Funktionalität ist ein Game-Changer, der es uns ermöglicht, native WooCommerce-Seiten zu verwenden, sodass wir keine komplexen Seiten an unserem Ende erstellen und die Benutzererfahrung verbessern können.
  • Heute haben wir den letzten Schliff hinzugefügt, indem wir eine Einzelproduktseite erstellt haben, um detaillierte Informationen zu jedem Produkt anzuzeigen, und Warenkorboptionen für einfache und variable Produkte implementiert haben. Diese Merkmale wurden mit dem SessionProviderEin reibungsloses Einkaufserlebnis für Ihre Benutzer.

Denken Sie daran, dass alle in diesen Tutorials eingeführten Konzepte, Methoden und Codes als solider Ausgangspunkt dienen. Abhängig von den spezifischen Bedürfnissen Ihres E-Commerce-Shops können Sie immer erweitern, was Sie hier gelernt haben.

Herzlichen Glückwunsch zu Ihrem Erfolg! Sie haben die Grundlagen des Aufbaus einer vielseitigen E-Commerce-Anwendung mit einem modernen Tech-Stack gemeistert. Lernen, experimentieren und verbessern Sie Ihre Anwendung weiter. Die Welt des E-Commerce entwickelt sich ständig weiter, ebenso wie Ihr Geschäft. Wir freuen uns auf die unglaubliche Arbeit, die Sie in Zukunft produzieren werden.



Leave a Reply

Your email address will not be published. Required fields are marked *