WooGraphQL : 5 장 5

banner

맨 위: 이 튜토리얼은 이전 WooGraphQL 릴리스를 다루고 더 이상 날짜가 없습니다. rewritten version은 진행 중입니다. 이 버전은 여기에서 공개됩니다.

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

본문 바로가기

  1. Headless eCommerce 응용 프로그램을위한 WordPress 설정
  2. eCommerce 앱 만들기 및 설정
  3. 빌딩 숍 목록 페이지 WooGraphQL
  4. 사용자 로그인 및 탐색
  5. 제품 페이지 및 카트 옵션 만들기

우리의 심층적인 튜토리얼 시리즈의 마지막 장에 오신 것을 환영합니다, 우리는 당신의 전자 상거래 상점을위한 단일 제품 및 카트 옵션 구현에 초점을 맞출 것입니다. 당신은 그것을 길게 만들고, 지금 당신의 신청을 끝내는 접촉을, 당신의 고객을 위한 강력하고 가동 가능한 쇼핑 경험을 창조하는 시간입니다.

제품 페이지 및 카트 옵션 만들기

eCommerce 저장소의 중요한 측면 중 하나는 개별 제품 세부 사항을 표시 할 수있는 능력입니다. 이것은 사용자가 제품에 대한 포괄적 인 이해를 얻고, 그들의 신뢰를 증가시켜 구매를 할 수 있습니다. 우리는 제품의 독특한 기능과 속성을 보여주는 상세한 단일 제품 페이지를 만들고 디자인하는 방법을 통해 걸어갈 것입니다.

다음으로, 우리는 카트 옵션으로 상점의 기능을 강화하기 위해 이동합니다. 이 옵션은 매끄럽고 유연한 쇼핑 프로세스의 핵심이며, 사용자가 주문을 효과적으로 관리 할 수있는 능력을 제공합니다. 변이 없음이나 여러 변이가 더 복잡하지 않은 간단한 제품이든, 우리는 시나리오를 처리하는 방법을 설명합니다.

우리는 특히 견고하고 직관적 인 손수레 시스템을 만들 수 있습니다. SessionProvider 우리의 이전 튜토리얼에서 개발. 또한, 우리는 새로운 후크와 경로 페이지를 소개합니다, 당신이 최고 수준의 서비스를 제공 할 수있는 도구, 원활한 쇼핑 경험.

이 튜토리얼은 이전 섹션에서 소개 된 개념과 코드에 구축하는 것이 중요하다, 그래서 당신은 모든 이전 튜토리얼을 통해 되었는지 확인하고 콘텐츠를 편안하게.

이 튜토리얼의 끝에, 당신은 완전히 기능, 기능 풍부한 전자 상거래 상점이 당신의 고객을 매력에 준비. 또한 ado 없이, 우리의 시리즈를 감싸고 포괄적인 단 하나 제품 페이지 및 정교한 손수레 선택권의 창조로 다이빙하십시오.

Part 1: 단일 제품 페이지 만들기

1. 명세 이름 * /app/product/[slug]/page.tsx::

// 앱/제품/[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::

// 클라이언트/제품이미지/제품Image.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>
  );
}

그리고 그것은. 이미지와 설명 섹션을 가진 단일 제품 페이지가 있습니다. Keen 눈은 가격, 카트 옵션 및 불완전한 리뷰 섹션의 부족을 발견했습니다.

리뷰는 이 튜토리얼의 out-of-scope입니다. 가정을 고려하십시오. 가격 및 카트 옵션에 관해서, 우리는 다음 부분에서 이러한 작업을 할 것입니다. 이전 두 튜토리얼과 마찬가지로 우리는 새로운 컨텍스트 공급자를 구현해야합니다. 이것은 우리가 보는 제품의 보기 국가를 관리할 것입니다. 이것은 변수 제품과 작업 할 때 편리합니다. 다음으로 가자.

부분 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::

// 앱/제품/[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::

// 이용 약관
...

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.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· SimpleCartOptions· VariableCartOptions 부품.

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::

// 걸이/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 : 전송 오류를 보냅니다. 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;

적어도 제공 하는 동안 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: boolean flag signifying that item is to be sold in quantity of one, 그래서 양 필드를 렌더링 하는 이유가 없다 true·
  • stockStatus: 제품의 재고 상태를 유지합니다. 가능한 값은 OUT_OF_STOCK· ON_BACKORDER· IN_STOCK
  • manageStock: boolean flag는 이 제품의 한계 공급이 있음을 나타냅니다.
  • stockQuantity: 공급중인 제품의 수를 나타내는 숫자 값 manageStock · true, 그렇지 않으면 무시 될 수 있습니다.

마지막 구성 요소가 복잡했기 때문에 사전에 apologize하고 싶습니다. 그러나 당신의 마음을 쉽게 돕는 경우에, 다음 성분은 신청에 있는 마지막 성분입니다. 그래서 축하를 만들기에 그것이 멀리하고 나를 찔러 주셔서 감사합니다.

더 많은 지연으로 지금 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 그리고 약간 작은 tweaking이 구성 요소는 변수와 간단한 제품을 모두 지원할 수 있습니다. 그러나, 그것은 더 열심히 읽을 것입니다 그래서 나는이 튜토리얼을 분리 왼쪽.

그래서 첫째로 당신은 아마 우리가 가지고 있는 동일한 가치의 다량을 얻는 것을 주의합니다 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 에서 sourced product 객체의 defaultAttributes. 하나가 놓지 않는 경우에, 특성 라디오 단추는 산에 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::

// 클라이언트/제품이미지/제품Image.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 및 기타 귀중한 기술을 사용하여 지상에서 강력하고 사용하기 쉬운 전자 상거래 매장을 건설했습니다.

여행 So Far

그리고 거기에, 당신은 성공적으로 우리의 포괄적 인 튜토리얼 시리즈의 끝을 도달했습니다. 이 튜토리얼의 과정을 통해 Next.js, WooCommerce, GraphQL 및 기타 귀중한 기술을 사용하여 지상에서 강력하고 사용하기 쉬운 전자 상거래 매장을 건설했습니다.

  • 상점의 기초 구조를 설정으로 시작하면 태그와 디스플레이 제품 목록을 동적으로 변환하는 방법을 배웠습니다. 카테고리, 색상 및 기타 주요 속성을 기반으로 제품을 정제하는 효율적인 필터링 시스템을 개발했습니다. 광범위한 제품을 효과적으로 처리할 수 있는 강력한 pagination 시스템을 만들었습니다.
  • 또한, 최종 사용자 세션을 처리하는 방법을 배웠습니다. 다음.js 애플리케이션에서 WooCommerce 저장소로 원활하게 전환할 수 있습니다. 이 기능은 게임 체인저입니다. 네이티브 WooCommerce 페이지를 활용하고 최종 사용자 경험을 향상시키기 위해 복잡한 페이지를 만들 수 있습니다.
  • 오늘, 우리는 단일 제품 페이지를 생성하여 최종 터치를 추가하여 각 제품에 대한 자세한 정보를 표시하고 간단한 가변 제품에 대한 카트 옵션을 구현했습니다. 이 기능은 조화되어 SessionProvider, 당신의 사용자에게 매끄러운 쇼핑 경험을 제공.

기억, 모든 개념, 방법, 그리고 이 튜토리얼에 소개 된 코드는 단단한 시작점으로 봉사합니다. eCommerce 저장소의 특정 요구에 따라, 당신은 당신이 여기에서 배운 것을 항상 확장 할 수 있습니다.

당신의 업적을 축하합니다! 현대 기술 스택을 사용하여 다양한 전자 상거래 응용 프로그램을 구축하는 데 필수적입니다. 학습 계속, 실험, 당신의 응용 프로그램을 향상. 전자 상거래의 세계는 항상 진화하고 있으므로 상점을해야합니다. 우리는 당신이 미래에 생산 될 놀라운 일을보고 기대합니다.



Leave a Reply

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