以WooGraphQL建造无头商店:第五章

banner

抬头: 此教程涵盖较早的 WooGraphQL 发布, 并且不再更新 。 重写版本正在展开,

https://www.youtube.com/embed/nhlexP-KtSo (中文(简体) ).

目录

  1. 为您的无头电子商务应用程序设置文字通讯
  2. 创建和设置您的电子商务应用促进发展
  3. 使用 WooGraphQL 创建商店列表页面
  4. 创建用户登录和导航
  5. 创建产品页面和墨盒选项

欢迎收看我们深入的辅导系列的最后一章, 我们将集中 执行单一产品和Cart选项 为您的电子商务商店。 你做了很长的路,现在是时候把 完成的触摸到你的应用程序, 为你的客户创造 强大的和灵活的购物经验。

创建产品页面和墨盒选项

任何eCommerce商店的一个重要方面是能够显示单个产品的细节. 这使得用户能够全面了解他们感兴趣的产品,提高了他们购买的信心. 我们将教你如何创建和设计一个详细的单一产品页面,展示出你产品的独特特征和属性.

接下来,我们将着手加强 你商店的功能 与Cart选项。 这些选项是平稳而灵活的购物过程的关键,为用户提供了有效管理订单的权力. 无论它是一个没有变异的简单产品,还是一个具有多种变异的更复杂的产品,我们将演示如何处理这两种情况.

我们将特别考虑建立一个强大和直观的手推车系统,该系统与人权理事会协调运作。 SessionProvider 在之前的教程中开发。 此外,我们将推出一个新的钩子和一条路线页,这些工具将使你能够提供顶尖、无缝的购物经验。

需要注意的是,这个教程建立在前几节引入的概念和代码上,所以要确保您已经通过之前所有的教程,并对内容感到舒适.

到了这个教程结束时,你将会有一个功能齐全,功能丰富的eCommerce商店,准备吸引你的顾客. 因此,不做进一步的事情,让我们结束我们的系列, 潜入创建一个全面的单一产品页面和复杂的Cart选项。

第1部分:创建单一产品页

1. 联合国 创建 /app/product/[slug]/page.tsx数字 :

// app/产品/[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数字 :

// 服务器/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个 创建 /client/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>
  );
}

且道. 我们有一个单一的产品页 并有图像和描述部分。 Keen眼睛已经注意到缺乏价格,推车选项和不完整的评论部分.

检讨是本教程外. 就当是作业吧 至于价格和推车的选择,我们将在下一部分中探讨这些选择。 和前两部教程一样,我们不得不实施一个新的上下文提供者. 这个可以管理我们所看到产品 的收视状态 在与可变产品合作时,这将是有益的。 那就开始吧

第2部分:创建 ProductProvider

1. 联合国 创建 /client/ProductProvider数字 :

// 客户/生产者/生产者
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 拥有一项主要工作。 分离出产品变异并返回 get 呼唤回击

get 使用字段名称返回选定变量的字段值。 如果产品不是可变产品或者没有选择可变产品, get 从主要产品对象中拉出字段值。 现在,让我们设置 SessionProvider 单页组件。

2. 联合国 更新 /app/product/[slug]/page.tsx数字 :

// app/产品/[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 作为输入,它也会向 /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 调用 input 对象并返回结果 cart 对象。 有了这个,我们准备更新 SessionProvider。 。 。 。

3个 更新 /client/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);

随着这一变化,消费者可以进入 updateCartfindInCart 调用。

  • updateCart: 执行基于所提供的推车动作 CartAction 对象。
  • findInCart: 使用 cartItemSearch 函数以检查是否在推车中提供了项目。 如果找到, 相应的 CartItem 已返回。

第4部分: 创建 CartOptions

在这一部分中,我们将执行 CartOptions, (中文(简体) ). SimpleCartOptions,以及 VariableCartOptions 组件。

1. 联合国 创建 /server/CartOptions数字 :

// 服务器/艺术选项/艺术选项
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 是一个简单的容器,在移动设备上粘到底. 在前进之前 SimpleCartOptionsVariableCartOptions,让我们建立一个钩子, 使产品车的状态微风。

2. 联合国 创建 /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.');
      // 向哨兵发送错误 。 木卫一.
    }

    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;

提供至少a productId 页:1 useCartMutations 请检查 cart 最接近 SessionProvider 对于一个匹配的手推车项目 productId。请注意,搜索非常精确,可以添加对各类产品和物品元的支持。 如果所涉项目是一种变异,则必须向其提供相应的 variationIdvariation 对象。 现在把这个钩子放进 SimpleCartOptions 组件。

3个 创建 /client/SimpleCartOptions数字 :

// 客户端/ SimpleCart 选项/ SimpleCart 选项.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:表示该物品的布尔旗以一号数量出售,因此,如果 true。 。 。 。
  • stockStatus: 盘点可以维持产品的库存状况。 可能的值为 OUT_OF_STOCK, (中文(简体) ). ON_BACKORDER,以及 IN_STOCK
  • manageStock:一面布尔旗,表示这种产品的供应有限。
  • stockQuantity: 数字值,如果 manageStock 实值 true,否则可以忽略。

如果最后一个部分很复杂,我想提前道歉。 但是,如果有助于缓解你的思想,下一个组件是应用程序中的最后组件. 恭喜你走这么远 感谢你坚持我

现在再拖一拖 VariableCartOptions。 。 。 。

4.四. 创建 /client/VariableCartOptions数字 :

// 客户端/可变Cart 选项/可变Cart 选项.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;

但是,我们使用 getProductProvider 这样,如果设置了一个变量,则该值来自选定的变量。

接下来要强调的是 安装的分配 selectedAttributes 来源于 product 对象的 defaultAttributes。如果没有设置,则属性收音机按钮将保留在挂载上。

  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 页:1 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 选项以识别车内的变化。

有了这个你就会注意到 当你在推车上添加一个变体时 UpdateRemove 按钮,当您使用收音机按钮更改选定的属性时, UpdateRemove 消失,因为新选择的变体不是推车。 这是我们想要的准确行为。

现在我们可以把钉子 并称它完成 但让我们做最后一件事 使产品页出, 我们要更新 ProductImage 构成部分 getSessionProvider 将显示图像更改为图像 selectedVariation。 。 。 。

5 (韩语). 更新 /client/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和其他有价值的技术。

远道而来

有了,你成功地完成了我们综合辅导系列的结束。 在这些教学过程中,您从地面上建造了一个强大和方便用户的电子商务商店,利用了Next.js、WooCommerce、GraphQL和其他有价值的技术。

  • 从建立你商店的基础结构开始,你学会了如何动态取出和显示产品清单,开发了高效的过滤系统,根据类别,颜色,以及其他关键属性来完善产品. 你创造了一个强大的pagination系统来有效处理大量的产品清单.
  • 此外,你还学会了如何处理终端用户会话,从而能够从我们的Next.js应用程序无缝地过渡到WordPress安装上的WooCommerce商店. 这个功能是一个游戏改变器,让我们可以使用本地的WooCommerce网页,从我们端面创建复杂的网页中拯救我们,增强用户体验.
  • 今天,我们通过创建一个单一产品页面来显示每个产品的详细信息,并针对简单和可变产品实施Cart Option. 这些特点已经与《公约》的 SessionProvider,向用户提供平滑的购物体验.

记住,这些教程中引入的所有概念,方法和代码都作为一个坚实的起点. 根据你eCommerce商店的具体需要,你总是可以扩展你在这里学到的知识.

恭喜你的成就! 您已经掌握了使用现代技术堆建多功能电子商务应用程序的基本要素。 继续学习,实验,增强你的应用能力. eCommerce的世界总是在演变,你的商店也一样。 我们期待着看到你们今后能完成的令人难以置信的工作。



Leave a Reply

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