WooGraphQLでヘッドレスショップの構築:第5章

banner

ヘッドアップ: このチュートリアルでは、以前のWooGraphQLリリースをカバーし、最新のものはありません。 書き換え版が進行中です。公開したらここからリンクします。

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

コンテンツの表

  1. ヘッドレスeコマースアプリケーション用のWordPressの設定
  2. 開発のためのeコマースアプリの作成と設定
  3. WooGraphQL の店舗一覧ページ
  4. ユーザログインとナビゲーションの作成
  5. 製品ページとカートオプションの作成

弊社の詳細なチュートリアルシリーズの最終章へようこそ。Eコマースストアのシングル製品とカートオプションの実装に集中します。 あなたはそれを長い道にしました, そして今、それはあなたのアプリケーションに仕上げのタッチを置く時間です, あなたの顧客のための強力で柔軟なショッピング体験を作成します.

製品ページとカートオプションの作成

eコマースストアの重要な側面の1つは、個々の製品の詳細を表示する機能です。 ユーザーが関心のある製品の包括的な理解を得ることができ、購入をするための自信を高めることができます。 製品のユニークな機能や属性を紹介する詳細な単品ページを作成および設計する方法について説明します。

次に、カートオプションで店舗の機能性を高めていきます。 これらのオプションは、スムーズで柔軟なショッピングプロセスの鍵であり、ユーザーが注文を効果的に管理するための力を提供します。 バリエーションのないシンプルな製品や、複数のバリエーションを持つ複雑な製品であっても、両方のシナリオを処理する方法を示します。

堅牢で直感的なカートシステムを作り上げていきます。 SessionProvider 前回のチュートリアルで開発されました。 さらに、新しいホックやルートページ、トップノッチ、シームレスなショッピング体験を提供できるツールをご紹介します。

このチュートリアルは、以前のセクションで導入された概念とコードに基づいて構築されていることに注意することが重要です。そのため、以前のすべてのチュートリアルを経由して、コンテンツと快適であることを確認してください。

このチュートリアルの最後に、あなたは完全に機能的、豊富なeコマースストアがあなたの顧客に魅力的に準備されています。 そのため、さらには、当社のシリーズをラップし、包括的な単品ページと洗練されたカートオプションの作成に飛び込みましょう。

パート1:単一のプロダクトページを作成して下さい

1。 作成する /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。 作成する /server/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。 作成する /client/ProductImage: : :

// クライアント/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>
  );
}

です。 画像と説明セクションを1つの製品ページで紹介しています。 Keen eyeは、価格、カートオプション、不完全なレビューセクションの欠如に気付きました。

レビューはこのチュートリアルのアウトオブスコープです。 宿題を考えます。 価格とカートのオプションに関しては、次の部分で作業します。 以前の2つのチュートリアルと同様に、新しいコンテキストプロバイダを実装する必要があります。 閲覧中の商品の閲覧状況を管理します。 可変的な製品を扱うときに便利です。 それでは、

パート2:作成 ProductProvider

1。 作成する /client/ProductProvider: : :

// クライアント/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 1つの主要な仕事があります。 製品のバリエーションを分離し、返品する get それをコールバックします。

get フィールド名は、選択したバリエーションのフィールド値を返します。 製品が可変的な製品でない、またはバリエーションが選択されていない場合は、 get フィールド値をメイン製品オブジェクトから引きます。 これで、 SessionProvider 単一ページのコンポーネント。

2。 新着情報 /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>
  );
}

カートのオプションを実装する準備はほぼ完了ですが、最後のチュートリアルで作成したいくつかの変更を行う必要があります。 ザ・オブ・ザ・ SessionProvider コンポーネントとコンポーネント session.ts ユーティリティファイルは、カートのアクションを処理するために更新する必要があります。 始めましょう。 session.ts ファイル。

パート3:更新 SessionProvider

1。 新着情報 /utils/session.ts: : :

// ユーティリティ/セッション.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;
}

マイナーな変更ですが、新しいことに気づくかもしれません updateCart ユーティリティ関数は、新しいものを撮る以外 CartAction 入力としてタイプして下さい、それにまたPOSTの要求を送ります /api/cart存在しない。 それを救済しましょう。

2。 作成する /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 });
  }
}

ザ・オブ・ザ・ /api/cart POSTリクエストを期待する sessionToken そして、 input 私達の形の目的 CartAction. もし authToken リクエストに応じて設定します。 sessionToken. 適切な入力を与えられたとき POST callback は、特定のアクションを実行しようとします。 input オブジェクトと結果を返す cart セッションのオブジェクト。 これをアップデートする準備が整いました SessionProviderお問い合わせ

3。 新着情報 /client/SessionProvider: : :

// クライアント/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) {
  ...

  // 処理カートアクション応答。
  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);

この変化のコンテキスト消費者がアクセスできるように updateCart そして、 findInCart コールバック。

  • updateCart: 提供元に基づいてカートアクションを実行 CartAction オブジェクト。
  • findInCart: 利用する cartItemSearch 提供された項目がカートにあるかどうかを確認する機能。 見つかった場合、対応する CartItem 返されます。

パート4:作成 CartOptions

この部分では、実装します。 CartOptions, SimpleCartOptionsVariableCartOptions コンポーネント。

1。 作成する /server/CartOptions: : :

// サーバ/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 ほとんどの部分のためのシンプルな容器で、モバイル機器の下部に粘着性があります。 移動する前に SimpleCartOptions そして、 VariableCartOptions製品のカートの状態を風にさせるホックを作成しましょう。

2。 作成する /hooks/useCartMutations.ts: : :

// hooks/useCartミュテーション.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: Sentryにエラーを送信します。 ログイン
    }

    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;

最低限に渡される間 productId お問い合わせ useCartMutations チェックでホック cart 最寄り駅 SessionProvider それに一致するカート項目のため productId. すべての種類の製品やアイテムのメタのサポートを追加するには、検索は非常に正確であることに注意してください。 質問の項目が変化であるかどうかを意味するそれは対応すると提供されなければならない variationId および unset 属性の値が variation オブジェクト。 今度はこのホックをで使用するために置いて下さい SimpleCartOptions コンポーネント。

3。 作成する /client/SimpleCartOptions: : :

// クライアント/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>
  );
}

ここで呼ばれる useCartMutations お問い合わせ productId ここのシンプルな商品を扱うだけでしたから。 我々はまた、直接オフいくつかの値を取得します product オブジェクトなので、アイテムの可用性に応じてカートオプションフォームを適切にレンダリングできます。 注目すべきこと:

  • soldIndividually: アイテムが 1 の数量で販売されているということを示す boolean フラグなので、量フィールドをレンダリングする理由はありません。 trueお問い合わせ
  • stockStatus: 製品の在庫状況を保持する列挙。 可能値 OUT_OF_STOCK, ON_BACKORDERIN_STOCK
  • manageStock: 本製品の限界供給があることを示すブールフラグ。
  • stockQuantity: 製品の残数を表す数値 manageStock お問い合わせ trueそれ以外の場合は無視できます。

最後のコンポーネントが複雑だったのですが、事前にお詫び申し上げます。 しかし、それがあなたの心を緩和するのに役立ちますならば、次のコンポーネントは、アプリケーション内の最終コンポーネントです。 ここまで作り上げていただきありがとうございました。

これ以上の遅延で VariableCartOptionsお問い合わせ

4。 作成する /client/VariableCartOptions: : :

// クライアント/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;
  // バリエーションにない属性を追加します。
  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>
  );
}

これでコンポーネントのこの獣を破壊してみましょう。 同じものの多くを同じものとしてやっていることに注意してください。 SimpleCartOptions このコンポーネントを微調整することで、変数と単純な製品の両方をサポートできます。 しかし、読みにくいので、このチュートリアルに分けておきます。

最初は、私たちが得た同じ値の多くを取得する場所をおそらく気づく product オブジェクト 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;

しかし、私たちは使用しています get コールバックから ProductProvider この方法は、一つが設定されている場合、選択したバリエーションから値が来る方法です。

次にハイライトするべきことは土台の配分です selectedAttributes ソースから product オブジェクトの defaultAttributes. もし 1 が設定されていない場合、属性のラジオボタンはマウントに unset を残します。

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

見てみると、次のことはマッチングのバリエーションの検索です。 selectedAttributes 更新しました。

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

あなたが見ることができるように useEffect メインが毎回トリガーされる product オブジェクトまたはオブジェクト selectedAttributes 新着情報 それが比較しているもの selectedAttributes お問い合わせ attributes マッチが見つかられば、利用可能なすべてのバリエーション。 もし、 variation 属性が許容されると仮定できる特定の属性の値は欠落しています。その属性は、アイテムがカートに追加される前に指定する必要があります。 そして、そのノートでは、コンポーネントに固有のものを最後に取得してみましょう。

  const productId = product.databaseId;
  const variationId = hasSelectedVariation ? get('databaseId') as number : undefined;
  // バリエーションにない属性を追加します。
  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);

上記以外の一般のもの variation 定数。 お問い合わせ find コールバック useEffect 最後のスニペットからしかし逆に、それは検索します selectedAttributes オブジェクトは、未指定の属性を探し、 selectedVariation それらを形状にマップし、 useCartMutationお問い合わせ variation パラメータ。 このパラメータは後ほどに渡されます。 findInCart カートのバリエーションかどうかを識別するオプション。

これにより、カートにバリエーションを追加したときに気づく Update そして、 Remove ボタンが表示され、選択した属性をラジオボタンで変更すると、 Update そして、 Remove 新しく選択したバリエーションがカートではないため消えます。 これは私たちが望む正確な動作です。

今、私たちはこれをピンを置くことができ、それを実行して呼び出しますが、製品ページをポップアップさせるための最後のことをしてみましょう、我々はそれを更新するつもりです ProductImage コンポーネントを使用して、 get から SessionProvider ディスプレイイメージをイメージに変える selectedVariationお問い合わせ

5。 新着情報 /client/ProductImage: : :

// クライアント/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 || '';
  ...

}

です。 変更するとき ProductProvider 変更する selectedVariation バリエーションに画像が設定されている場合、表示された画像が変更されます。

コンテンツ

そして、あなたはそれを持っている、あなたは成功した私たちの包括的なチュートリアルシリーズの終了に達しました。 これらのチュートリアルのコースでは、Next.js、WooCommerce、GraphQL、その他の貴重な技術を活用した、地上から強力で使いやすいeコマースストアを構築しています。

旅程 遠く

そして、あなたはそれを持っている、あなたは成功した私たちの包括的なチュートリアルシリーズの終了に達しました。 これらのチュートリアルのコースでは、Next.js、WooCommerce、GraphQL、その他の貴重な技術を活用した、地上から強力で使いやすいeコマースストアを構築しています。

  • ストアのファウンデーション構造を設定し始めて、製品リストを動的に取得し表示する方法を学び、カテゴリ、色、およびその他のキー属性に基づいて製品を精製する効率的なフィルタリングシステムを開発しました。 製品の広範なリストを効果的に扱うために、堅牢なペジネーションシステムを作成しました。
  • さらに、エンドユーザーセッションを処理する方法を学び、WordPressインストールでホストされているWooCommerceストアへのNext.jsアプリケーションからのシームレスな移行を可能にします。 この機能は、ネイティブWooCommerceページを利用し、エンドに複雑なページを作成し、ユーザーエクスペリエンスを向上させることで、ゲームチェンジャーです。
  • 本日は、製品ごとに詳細情報を表示し、シンプルで可変的な製品に「カートオプション」を導入することで最終タッチを追加いたしました。 これらの特徴は調和されています SessionProviderスムーズなショッピング体験をユーザーにお届けします。

これらのチュートリアルで導入されたすべてのコンセプト、メソッド、およびコードは、固体の開始点として機能します。 eコマースストアの特定のニーズに応じて、ここで学んだことを常に拡大することができます。

おめでとうございます! 現代のテックスタックを使用して、多目的なeコマースアプリケーションを構築する上で不可欠を習得しました。 アプリケーションの学習、実験、強化を続けましょう。 eコマースの世界は常に進化しています。 未来を創り出す、信じられないほどの仕事をお待ちしています。



Leave a Reply

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