WooCommerceをNextPressで午後にNext.jsアプリに追加

ブログができました。 ストアを追加します。 このウォークスルーは、どこにも正確に拾う ヘッドレスブログチュートリアル 消えます。 2つの新しいWordPressプラグイン、ルートグループが分割され、WPレンダリングされたページは独自のルートレイアウトと単一のヘッダーを取得します。 Cart-Token — Next.js セッションを WooCommerce の Store API にブリッジします。 ブログを提供しているのと同じアプリは、すでにインストールしたWooCommerce拡張機能を介して実行されているカートとチェックアウトで、ストアフロントにサービスを提供しています。

コンパニオンレポ: github.com/AxisTaylor/nextpress-woographql-quickstart. それはです nextpress-quickstart この投稿で適用されるすべての記事でブログチュートリアルから — クローン作成、実行 npm install && npm run devと、フロー全体が対向している woographqldemo.wpengine.comお問い合わせ

Headless WooCommerce on Next.js — in-app product pages, NextPress-proxied cart and checkout, Cart-Token bridging both.

お問い合わせ

あなたは既存のNext.jsアプリを持っています – あなたのもの、またはブログチュートリアルに従って構築したもの。 商取引:製品、カート、チェックアウト、支払い、在庫、税金、配送、すべて。 最後の6つのものをスクラッチから書くことは興味がありません。 WooCommerceは、~5,000,000のライブストアフロントを実行し、エクステンションマーケットプレイスは、任意のオープンソースの取引プラットフォームで最大です。 ブログ用に構築されたヘッドレスWordPressのセットアップも、ヘッドレスWooCommerceセットアップです。同じバックエンド、同じプロキシ、もう2つのプラグインとレイアウトのリファクタです。

注意: 何もない

このチュートリアルでは、 ゲスト専用 ストアフロント。 ログインページ、アカウントダッシュボードなし、JWTリフレッシュループなし。 ザ・オブ・ザ・ Cart-Token WooCommerceハンドバックは、セッション識別子です。ゲストは、カートをブラウズしたり、チェックアウトしたり、注文を何も表示したりできますが、請求メールや注文キーが確認から確認することができます。 ほとんどの店主にとって、幸せな道全体をカバーします。 認証された顧客アカウントが必要な場合は、 wp-graphql-headlessログイン または wp-graphql-jwt-authentication の使い方 別々に — カート・トークン・フローを邪魔せずに接続するように設計されています。

ブログチュートリアルからの変更点

  • 2つの新しいWPプラグイン: WooCommerceと WooCommerceのWPGraphQLお問い合わせ
  • 路線グループ分割 削除 app/layout.tsx. アプリ内のルートを移動 app/(main)/ 独自のルートレイアウトで。 WPレンダリングされたルート(カート、チェックアウト、ブログ)を移動 app/(wordpress-pages)/ 別のルートレイアウトで、 <WPHead /> インスタグラム <head>. インポートマップとスクリプトモジュールは、インポートマップタグがドキュメントに住んでいたときにのみ、WCのチェックアウトブロックによって生成されます。 <head>, ネストされたレイアウトは、外部のルートレイアウトを所有している場合、そこに置くことはできません <html>お問い合わせ
  • ミドルウェアはセッションをブリッジします。 読む sessionToken すべてのプロキシされたWP REST/AJAXの要求のクッキーは、それをとして付けます Cart-Token ヘッダー。 任意の回転をキャプチャ Cart-Token 応答でクッキーに戻り、次のリクエストは同じWCセッションにとどまります。
  • Server-side GraphQL は同じヘッダーを転送します。 両方とも fetchPageByUri お問い合わせ fetchAssetsByUri 含まれるもの Cart-Token. それをスキッピング fetchAssetsByUri 単一の最もシャープなロッチャです。WCのチェックアウトブロックサーバーレンダーは空のカートに反してバケします "Cannot create order from empty cart" エラー wcSettings.checkoutDataと、ページは永続的に立ち往生しています。
  • 洗練された製品ページ 変数プロダクトのためのバリエーションセレクターを使って(属性によって一致される) name, ない label — ローカルの属性には、微妙なゲッチャがあります。
  • サーバのアクションを介したゲストの注文。 /view-order 請求メール + 注文キーを受け取り、注文をレンダリングします。 サーバのアクションは、メールを現在のものに結合します。 Cart-Token セッション updateCustomer順序のキーによって順序の関係を狭くして下さい。 アプリケーションパスワード、ショップマネージャーアカウントはありません。
  • 自動レシート /checkout/order-received/[id]お問い合わせ クライアントコンポーネントは、WC のチェックアウトブロックからクッキーに請求メールをキャプチャします。 領収書ページはクッキー+を読み取ります ?key= URL で、同じサーバーのアクションで注文を調べる – 記入するフォームはありません。
  • 製品レビュー 小さな線で配線 /api/product/review WooGraphQL’s を呼び出すルート writeReview カート・トークンによる変異。

ステップ1 – 2つのWPプラグインをインストールする

  1. WooCommerceについて — プラグインディレクトリから。 セットアップウィザードを実行します。, 支払いのためのStripeテストでドロップします。, いくつかのデモ製品を追加するので、クエリに何かがあります。. お問い合わせ /cart そして、 /checkout 従来のショートコードではなく、ブロックテンプレート(Woo 8.3以降にデフォルト)を使用して、NextPressがプロキシするものです。
  2. WooCommerceのWPGraphQL — 製品、カート、顧客、注文タイプを追加 /graphql. Postman で確認 (またはレスポンスヘッダを表す HTTP クライアント — GraphiQL はそれらを隠します)。 addToCart 実質に対する変異 productId なし Cart-Token リクエスト WP は、新しいゲストセッションを作成し、JWT を返します。 Cart-Token 応答ヘッダー。
Postman
mutation BootGuestSession {
  addToCart(input: { productId: 13, quantity: 1 }) {
    cart {
      contents { itemCount }
      total
    }
  }
}

ザ・オブ・ザ・ Cart-Token ヘッダは、カートとセッションのみで出荷 ミューテーション — — — addToCart, updateItemQuantity, removeItemsFromCart, applyCoupon友達、友達 明確な質問に対する /graphql GraphiQL は 1 つを生成せず、レスポンスヘッダは表示されません。そのため、この検証ステップは Postman を使用します。 ミドルウェアが、そのヘッダがプロキシされた応答で表示すると、新しい値が新しい値になります。 sessionToken Cookie とそれ以降のすべてのリクエスト — REST は、チェックアウトブロック、サーバー側 GraphQL クエリから、Next.js アプリケーションで呼び出します。

Postman executing an addToCart mutation against /graphql, with the response Headers tab expanded to show the Cart-Token JWT WooCommerce returned for the new guest session.

ステップ2 – ルートグループ分割

これは、単一の最大の構造変化です。 削除 app/layout.tsx. 2つのトップレベルのルートグループの下にすべてのルートを移動し、それぞれ独自のルートレイアウトで移動します。

src/app/
src/app/
├── api/
│   ├── cart/route.ts             ← cart mutations (server-only)
│   └── product/review/route.ts   ← review submission (server-only)
├── globals.css
├── (main)/                       ← in-app routes
│   ├── layout.tsx                ← <html>/<body> for the catalog + lookup surface
│   ├── page.tsx                  ← /
│   ├── products/
│   │   ├── page.tsx              ← /products
│   │   └── [slug]/page.tsx       ← /products/[slug] (Simple + Variable)
│   └── view-order/page.tsx       ← /view-order (guest order lookup)
└── (wordpress-pages)/            ← WP-rendered routes
    ├── layout.tsx                ← <html><head><WPHead /></head><body>
    ├── cart/page.tsx             ← /cart
    ├── checkout/
    │   ├── page.tsx              ← /checkout (+ CheckoutEmailCapture)
    │   └── order-received/[id]/page.tsx  ← auto-receipt
    └── blog/
        ├── page.tsx
        └── [slug]/page.tsx

なぜ? Next.js のみ <html> そして、 <body> ルートレイアウトで。 なし app/layout.tsxそれぞれのトップレベルのルートグループが独自のルートになります。 ザ・オブ・ザ・ (wordpress-pages) 根は置くことができます <WPHead /> 直接内部 <head> — どこにあるか <script type="importmap"> WC の module-form スクリプトで出力 お問い合わせ 生きる。 ネストされたレイアウトを1つのルートで試し、インポートマップが終わる <body>; モジュールスクリプトは、解決できません @wordpress/plugins カートブロックツリー全体が入る

src/app/(wordpress-pages)/layout.tsx
import { headers } from 'next/headers';
import { WPHead, WPFooter } from '@axistaylor/nextpress';
import { fetchAssetsByUri, fetchGlobalStyles } from '@/lib/wp';

export const dynamic = 'force-dynamic';

const CRITICAL_STYLESHEETS = [
  'wp-block-library', 'wp-block-library-theme', 'global-styles',
  'classic-theme-styles', 'wc-blocks-style', 'wc-blocks-vendors-style',
];

export default async function WordPressLayout({ children }) {
  const uri = (await headers()).get('x-uri') || '/';
  const [{ scripts, stylesheets, importMap }, globalStyles] = await Promise.all([
    fetchAssetsByUri(uri),
    fetchGlobalStyles(),
  ]);

  return (
    <html lang="en">
      <head>
        <WPHead
          scripts={scripts}
          stylesheets={stylesheets}
          globalStyles={globalStyles}
          importMap={importMap}
          pathname={uri}
          criticalHandles={CRITICAL_STYLESHEETS}
        />
      </head>
      <body>
        <main>{children}</main>
        <WPFooter scripts={scripts} pathname={uri} />
      </body>
    </html>
  );
}

ステップ3 — ミドルウェアはカートトークンを橋渡し

新着情報 src/proxy.ts すべてのプロキシされたWP REST/AJAXリクエストにカートトークンを添付し、回転されたトークンWPが応答に戻ります。 カートブロックのフロントエンドのフェッチ /wc/store/v1/cart ブラウザから、それはあなたのNextPressプロキシを通過します。そして、そのプロキシはあなたの翻訳をする必要があります sessionToken クッキーを Cart-Token ヘッダーWooCommerceは期待しています。 WooCommerceは時々トークンを回転させます(カートのミューテーションの後、クーポンが適用されます。)。応答ヘッダーは、それが新しい値をどのように信号するかです。

src/proxy.ts
import { NextResponse, NextRequest } from 'next/server';
import {
  proxyByWCR, isProxiedRoute,
  isWCAjaxRequest, isWPAjaxRequest, isWPRestRequest,
} from '@axistaylor/nextpress/proxyByWCR';

export const proxy = async (request: NextRequest) => {
  const pathname = request.nextUrl.pathname;

  // 1。 すべてのプロキシされたREST/AJAXコールでカートトークンを転送するので、
  //  ブラウザ側のWCブロックは、ゲストの実際のカートセッションで土地をブロックします。
  if (
    isProxiedRoute(pathname) &&
    (isWPAjaxRequest(pathname) || isWCAjaxRequest(pathname) || isWPRestRequest(pathname))
  ) {
    const sessionToken = request.cookies.get('sessionToken')?.value;
    if (sessionToken) request.headers.set('Cart-Token', sessionToken);
  }

  // 2. リクエストをプロキシし、回転したカートトークンWP書き込みをキャプチャ
  //  応答に戻り、sessionTokenクッキーとして主張します。
  if (isProxiedRoute(pathname)) {
    const response = await proxyByWCR(request);
    const rotated = response.headers.get('Cart-Token');
    if (rotated) {
      const next = new NextResponse(response.body, {
        status: response.status,
        statusText: response.statusText,
        headers: response.headers,
      });
      next.cookies.set({
        name: 'sessionToken',
        value: rotated,
        path: '/',
        maxAge: 30 * 24 * 60 * 60,
        secure: process.env.NODE_ENV === 'production',
        httpOnly: true,
        sameSite: 'lax',
      });
      return next;
    }
    return response;
  }

  const headers = new Headers(request.headers);
  headers.set('x-uri', pathname);
  return NextResponse.next({ request: { headers } });
};

export const config = {
  matcher: [
    '/atx/:instance/proxiee',
    '/atx/:instance/wp',
    '/atx/:instance/wc',
    '/atx/:instance/wp-internal-assets/:path*',
    '/atx/:instance/wp-assets/:path*',
    '/atx/:instance/wp-json/:path*',
    '/((?!_next|api|favicon.ico|sw.js|.*\\.).*)',
  ],
};
Chrome DevTools showing the Cart-Token header attached by middleware to a /wc/store/v1/cart request.

ステップ4 — トークンを転送するGraphQLヘルパー

2人のヘルパー src/lib/wp.ts WPGraphQL について: gqlWithSession カートセッションが必要な問い合わせ fetchAssetsByUrifetchPageByUri どちらを使うか。 これらの両方ともカートトークンを転送する必要があります — と2つ目は、ゲッチャです。

src/lib/wp.ts (excerpt)
import 'server-only';
import { cookies } from 'next/headers';

const endpoint = process.env.GRAPHQL_ENDPOINT as string;

interface FetchOptions { sessionToken?: string | null }

export async function gqlWithSession<T>(
  query: string,
  variables: Record<string, unknown> | undefined,
  options: FetchOptions = {},
) {
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
  if (options.sessionToken) headers['Cart-Token'] = `${options.sessionToken}`;

  const res = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({ query, variables }),
    cache: 'no-store',
  });
  const json = await res.json();
  return { data: json.data ?? null, errors: json.errors };
}

// クリティカル: カートトークンを転送 ここにも. アセットByUri ソルバー
// WP-sideスクリプトのエンキューイングをトリガーします。
// checkout ブロックの hydrate data from api request です。 なし
// ヘッダ、wc()->cart は、そのリクエストに対して空です。
// "空のカートから注文を作成できません。"
// wcSettings.checkoutData → checkout ブロックの hasCheckoutError
// ゲートは永久に消火します。
export async function fetchAssetsByUri(uri: string) {
  const c = await cookies();
  const sessionToken = c.get('sessionToken')?.value ?? null;

  const { data } = await gqlWithSession<{ assetsByUri: any }>(
    `query ($uri: String!) {
       assetsByUri(uri: $uri) {
         importMap(scheme: RELATIVE) { name path }
         enqueuedStylesheets(first: 500) {
           nodes { handle src version before after }
         }
         enqueuedScripts(first: 500) {
           nodes { handle src strategy version group location type before after
                   dependencies { handle } }
         }
       }
     }`,
    { uri },
    { sessionToken },
  );
  const a = data?.assetsByUri;
  return a
    ? { scripts: a.enqueuedScripts.nodes, stylesheets: a.enqueuedStylesheets.nodes, importMap: a.importMap ?? [] }
    : { scripts: [], stylesheets: [], importMap: [] };
}

export async function fetchPageByUri(uri: string) {
  const c = await cookies();
  const sessionToken = c.get('sessionToken')?.value ?? null;

  const { data } = await gqlWithSession<{ page: any }>(
    `query ($uri: ID!) {
       page(id: $uri, idType: URI) { id title content contentCssClasses }
     }`,
    { uri },
    { sessionToken },
  );
  return data?.page ?? null;
}

コメント fetchAssetsByUri 装飾ではありません – それは理由全体です /checkout 忘れたら、静かに失敗します。 WPGraphQL は、ブラウザの REST 呼び出し作業で、 カート REST は正しい項目を返し、 WC ブロックのサーバサイドのプリロードされたデータが空のカートセッションに対して生成されたため、ページはまだ空のエラーをレンダリングします。 トークンを転送する 両方とも server-side GraphQL はギャップを閉じます。

ステップ5 — API ルートとしてのカート

Add-to-cart、update-quantity、remo-from-cart — 1つのフロー /api/cart ハンドラ。 ルート読み込み sessionToken Cookie から、WPGraphQL のミューテーションを実行します。 Cart-Token 添付して、更新されたカートを返します。 ステップ3のミドルウェアは、途中で任意の回転トークンを主張するのを世話します。

src/app/api/cart/route.ts (excerpt)
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { gqlWithSession } from '@/lib/wp';

const COOKIE_OPTS = { httpOnly: true, secure: true, sameSite: 'lax' as const, path: '/' };

async function readSession() {
  return { sessionToken: (await cookies()).get('sessionToken')?.value ?? null };
}

interface CartActionPayload {
  action: 'add' | 'update' | 'remove' | 'clear' | 'applyCoupon' | 'removeCoupon';
  productId?: number;
  variationId?: number;
  variation?: { attributeName: string; attributeValue: string }[];
  quantity?: number;
  key?: string;
  code?: string;
}

export async function POST(req: NextRequest) {
  const payload = (await req.json()) as CartActionPayload;
  const auth = await readSession();

  if (payload.action === 'add') {
    const result = await gqlWithSession<{ addToCart: { cart: unknown } }>(
      `mutation Add($input: AddToCartInput!) {
         addToCart(input: $input) { cart { contents { itemCount } total } }
       }`,
      { input: {
        productId: payload.productId,
        variationId: payload.variationId,
        variation: payload.variation,  // local-attribute variations need this
        quantity: payload.quantity ?? 1,
      } },
      auth,
    );
    if (result.errors?.length) {
      return NextResponse.json({ error: result.errors[0].message }, { status: 400 });
    }
    return NextResponse.json({ cart: result.data?.addToCart?.cart ?? null });
  }

  // ...更新 / 削除 / ApplyCoupon / クリア 同じパターンに従う
}

お問い合わせ variation 入力の配列。 変数-product add-to-cart は、グローバルとローカルの属性を混合するバリエーションのためにそれを必要とします — ステップ 7 を参照してください。

DevTools showing the POST /api/cart request and JSON response after add-to-cart.

ステップ6 – 洗練された製品ページ

製品ページはブラウザを買い手に変換します。そのため、実際の設計時間を費やす場所です。 サーバーコンポーネント、ISR-cached、シングルグラフQL クエリー全体ビュー:タイトル、ヒーローイメージ、ギャラリー(各画像の自然な側面比を照らす) mediaDetails), 価格 + 販売バッジ, 株式バッジ, ショート + 長い説明, 関連する製品, パン粉, 製品のスキーマ.org GoogleショッピングのためのJSON-LD. ページの先頭へ __typename 2つのヒーローコンポーネントの1つに。 両方ともモバイルファーストをレンダリングします。ほとんどのストアフロントトラフィックはモバイルで、下のスクリーンショットは小規模なビューポートビューで証明できます。

src/app/(main)/products/[slug]/page.tsx (shape)
import { notFound } from 'next/navigation';
import { fetchProductBySlug, fetchProductSlugs } from '@/lib/wp';
import { SimpleProductHero } from '@/components/SimpleProductHero';
import { VariableProductHero } from '@/components/VariableProductHero';
import { ProductTabs } from '@/components/ProductTabs';

export const revalidate = 300;

export async function generateStaticParams() {
  return (await fetchProductSlugs()).map((slug) => ({ slug }));
}

export default async function ProductPage({ params }) {
  const { slug } = await params;
  const product = await fetchProductBySlug(slug);
  if (!product) notFound();

  return (
    <>
      <article className="grid lg:grid-cols-[1.1fr_1fr] gap-12 max-w-wide mx-auto px-x-small py-medium">
        {product.__typename === 'VariableProduct'
          ? <VariableProductHero product={product} />
          : <SimpleProductHero product={product} />}
      </article>
      <ProductTabs product={product} />
    </>
  );
}
Polished product page rendered by Next.js with WooGraphQL data and schema.org JSON-LD.

ステップ7 – 可変的な製品のバリエーション

VariableProductHero 属性選択状態を所有し、価格、イメージ、およびマッチしたバリエーションにバッジをストックするクライアントコンポーネントです。 2つの小さいが負荷軸受け細部:

  • マッチによる name, ない labelお問い合わせ グローバルな属性 (taxonomy-backed): pa_color, pa_size), 両方 name そして、 label 同意する ProductAttribute そして、 VariationAttribute. ローカルの属性(プロダクトの自由形態、分類無し)のため、 ProductAttribute.label 人間の形(「ロゴ」)ですが、 VariationAttribute.label お問い合わせ sanitize_title()‘d(“ロゴ”)。 2つ label フィールドは比較できません。 name 通る sanitize_title() 両側で、それは常に同意するので、 nameお問い合わせ
  • 送信する variation add-to-cart の配列。 顧客の選択が実質に対応する場合 variationIdWooGraphQL は、属性がグローバルかローカルであるかにかかわらず、そのバリエーションにカートアイテムをピントさせます。
src/lib/variation-helpers.ts
// VariationAttribute.label は、LOCAL 属性の sanitize title() です。
// (例:「ロゴ」→「ロゴ」) ラベルは人間の形態です
// (ログ) BOTH で sanitize title() である `name` を expose する
// つまり、`name` でマッチするので、`label` でもありません。
export interface VariationAttrSel { name: string; value: string }

export function variationMatches(
  variationAttrs: { name: string; value: string }[],
  selection: VariationAttrSel[],
): boolean {
  if (variationAttrs.length === 0) return false;
  return variationAttrs.every((va) => {
    if (va.value === null) return true; // "any" — accepts everything
    const sel = selection.find((s) => s.name === va.name);
    return sel?.value === va.value;
  });
}

export function findMatchingVariation<V extends {
  attributes: { nodes: { name: string; value: string }[] };
}>(
  variations: V[],
  selection: VariationAttrSel[],
): V | undefined {
  return variations.find((v) =>
    variationMatches(v.attributes.nodes, selection));
}
src/components/VariableProductHero.tsx (shape)
'use client';
import { useState } from 'react';
import { findMatchingVariation } from '@/lib/variation-helpers';
import { AddToCartButton } from './AddToCartButton';

export function VariableProductHero({ product }) {
  const [selection, setSelection] = useState({});
  const current = findMatchingVariation(product.variations.nodes,
    Object.entries(selection).map(([name, value]) => ({ name, value })));

  const price = current?.price ?? product.price;
  const image = current?.image ?? product.image;
  const inStock = (current?.stockStatus ?? product.stockStatus) !== 'OUT_OF_STOCK';
  const variationPayload = Object.entries(selection)
    .map(([attributeName, attributeValue]) => ({ attributeName, attributeValue }));

  return (
    <>
      <ProductGallery image={image} gallery={product.galleryImages.nodes} />
      <ProductInfo price={price} inStock={inStock}>
        {product.attributes.nodes.map((attr) => (
          <AttributePicker
            key={attr.name}
            attribute={attr}
            value={selection[attr.name]}
            onChange={(v) => setSelection((s) => ({ ...s, [attr.name]: v }))}
          />
        ))}
        <AddToCartButton
          productId={product.databaseId}
          variationId={current?.databaseId}
          variation={variationPayload}
          inStock={inStock && !!current}
        />
      </ProductInfo>
    </>
  );
}
Variable product page showing matched variation image, price, and stock badge updating live with attribute-picker selection.

ステップ8 — NextPressプロキシによるカートとチェックアウト

2つのルート、12行それぞれ。 生きる (wordpress-pages) そのため、ステップ2からWPレンダリングレイアウトを選択します。

src/app/(wordpress-pages)/cart/page.tsx
import { notFound } from 'next/navigation';
import { Content, nextImageParser } from '@axistaylor/nextpress';
import { fetchPageByUri } from '@/lib/wp';

export default async function CartPage() {
  const page = await fetchPageByUri('/cart');
  if (!page) notFound();
  return (
    <article>
      <Content
        content={page.content}
        contentCssClasses={page.contentCssClasses}
        parsers={[nextImageParser()]}
      />
    </article>
  );
}

ザ・オブ・ザ・ (wordpress-pages) レイアウトは資産グラフをフェッチします(with Cart-Token!)、ページはWCのカートブロックマークアップをレンダリングし、カートブロックのフロントエンドスクリプトは水和に引き継ぎます。 Cart-Token ミドルウェアの橋を渡る。 同じ形のための /checkout/page.tsx; ブログのルートと同じ形状。

WooCommerce cart block rendered on a Next.js host via the NextPress proxy — same UI, same Next.js domain.

ステップ9 – サーバーアクションによるゲスト注文検索

注文が存在すると、顧客はそれを見る方法を必要とします。 標準的なWooCommerceの流れはレシートをに置きます /checkout/order-received/[id], しかし、それはまた、繰り返し訪問のための手動ルックアップページを必要とします, 失った確認タブ, 電子メールフォローアップリンク. /view-order そのページ — フォームを持つサーバーコンポーネントで、サーバーアクションに投稿します。

アクションは何か微妙です。 updateCustomer なし id 顧客を現物に取り付ける Cart-Token セッション。 セットアップ billing.email そのセッションを電子メールにバインドします。 orders 接続は、ゲストの注文を含む、そのアドレスに対して配置されたすべての注文を返します。 アクションが狭い orderKey, 共有秘密として機能します。: 電子メールだけで他の人の注文を直面するのに十分ではありません知っている.

src/actions/order.ts
'use server';
import { cookies } from 'next/headers';
import { revalidatePath } from 'next/cache';
import { gqlWithSession, type Order, ORDER_FRAGMENT } from '@/lib/wp';
import { LOOKUP_COOKIE, ERROR_COOKIE } from '@/utils/constants';

const FIND_ORDER_MUTATION = /* GraphQL */ `
  ${ORDER_FRAGMENT}
  mutation BindEmailAndFindOrder($input: UpdateCustomerInput!) {
    updateCustomer(input: $input) {
      customer {
        orders(first: 100) {
          nodes { ...OrderFields }
        }
      }
    }
  }
`;

// 実行する updateid` なしで顧客はミューテーションを適用します
// 現行のカートトークンセッションに取り付けたお客様。 セットアップ
// billing.email は、そのセッションを電子メールにバインドします。
// 順序の関係はそれからそれに対して置かれるあらゆる順序を戻します
// アドレス — ゲストの注文を含む。 orderKeyで絞ります。
// 共有秘密として機能する: 一人でメールを知っていることは十分ではありません
// 他の人の注文を覆います。
//
// これはサーバアクションの呼び出しでのみ実行されるため、GraphQL
// エンドポイントと顧客結合のセマティクスは決して到達しません
// クライアント。 チュートリアルリーダーは、アプリケーションパスワードを必要としません。
// セッション独自のカートトークンがミューテーションを認証します。
export async function lookupOrder(
  email: string,
  orderKey: string,
): Promise<{ order?: Order; error?: string }> {
  if (!email?.trim() || !orderKey?.trim()) {
    return { error: 'Email and order key are required.' };
  }

  const sessionToken = (await cookies()).get('sessionToken')?.value ?? null;
  const result = await gqlWithSession<{
    updateCustomer: { customer: { orders: { nodes: Order[] } } | null } | null;
  }>(
    FIND_ORDER_MUTATION,
    { input: { billing: { email: email.trim() } } },
    { sessionToken },
  );

  if (result.errors?.length) return { error: result.errors[0].message };
  const orders = result.data?.updateCustomer?.customer?.orders?.nodes ?? [];
  const match = orders.find((o) => o.orderKey === orderKey.trim());
  if (!match) return { error: 'No order found for that email and order key.' };
  return { order: match };
}

export async function lookupOrderFormAction(formData: FormData): Promise<void> {
  const email = String(formData.get('email') ?? '').trim();
  const orderKey = String(formData.get('orderKey') ?? '').trim();
  const result = await lookupOrder(email, orderKey);

  const jar = await cookies();
  if (result.error || !result.order) {
    jar.set(ERROR_COOKIE, result.error ?? 'Order not found.',
      { path: '/', sameSite: 'lax', maxAge: 60 });
    jar.delete(LOOKUP_COOKIE);
  } else {
    jar.set(LOOKUP_COOKIE, JSON.stringify({ email, orderKey }), {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24,
    });
    jar.delete(ERROR_COOKIE);
  }
  revalidatePath('/view-order');
}

これを回避する: ショップマネージャーのアプリケーションパスワードをデモにハンドロールします。 チュートリアルリーダーは1を持っていません。チュートリアルを「here」で出荷し、管理者資格情報を貼り付けます。 .env「 い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い す い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い い Cart-Token-as-shared-secret パターンは、既存のセッションモデル内ですべてを保持します。

/view-order form and resulting OrderSummary after a successful lookup.

ステップ10 – /checkout /order-receivedの自動レシート

WooCommerceが成功したチェックアウト後に顧客をリダイレクトする場合、URLは /checkout/order-received/[id]?key=wc_order_…. ザ・ key 注文キーです。 電子メールはURLではありません。これは、WCのチェックアウトブロックの請求フォームです。 クライアント側をクッキーにキャプチャし、レシートページはクッキー+ URLキーを読み、同じ実行します。 lookupOrder ステップ9からサーバーアクション

キャプチャコンポーネントは、直感的に聞こえます(メール入力のカップルを読んで、クッキーに書きます)が、WCのチェックアウトブロックは、そのフィールドを非同期にマウントし、請求フィールドセットを「請求のために配送アドレスを使用する」トグル、そして — サプライズ — 独自のセッションストレージからフィールドの値を水和させます フィリングなし input または change イベント. 委任される input リスナーだけでオートフィルを完全に見逃します。 3つのソースはギャップをカバーします:

  1. 初期 DOM スキャン マウント — レンダー時に既に存在する値(オートフィル、ブラウザオートコンプリート)をキャッチします。
  2. ミュテーションObserver お問い合わせ document.body お問い合わせ attributeFilter: ['value'] — 遅延マウントされた入力とセッションを修復した値の書き込みをキャッチします。
  3. キャプチャフェーズ inputchange デリゲーション お問い合わせ document — ライブタイピングをキャッチします。
src/components/CheckoutEmailCapture.tsx
'use client';
import { useEffect } from 'react';
import { CHECKOUT_EMAIL_COOKIE } from '@/utils/constants';

// WCのチェックアウトブロックは、そのフィールドを非同期で再マウントします
// 顧客のトグルが「のための船積みを使用すれば請求のフィールドセット
// 請求する。 また、独自のセッションストレージから価値を付与する
// 入力/変更イベントを提出しないように、委任されたリスナーだけ
// 自動入力メールを逃します。 3つのソースが必要です。
//  1。 初期の DOM はマウントでスキャンします。 — 既に存在する値をキャッチします。
//  2. MutationObserver — マウントされた入力をキャッチし、
//  session-restored 値が書き込みます。
//  3.入力/変更の委任 — ライブユーザの入力をキャッチします。
function isEmailField(el: Element | null): el is HTMLInputElement {
  if (!(el instanceof HTMLInputElement)) return false;
  if (el.type === 'email') return true;
  const probe = `${el.name} ${el.id} ${el.autocomplete}`.toLowerCase();
  return probe.includes('email');
}

function writeCookie(value: string): void {
  const trimmed = value.trim();
  if (!trimmed) return;
  const secure = location.protocol === 'https:' ? '; secure' : '';
  document.cookie = `${CHECKOUT_EMAIL_COOKIE}=${encodeURIComponent(trimmed)}; ` +
    `path=/; max-age=3600; samesite=lax${secure}`;
}

function scan(root: ParentNode): void {
  root.querySelectorAll<HTMLInputElement>(
    'input[type="email"], input[name*="email" i], input[id*="email" i], input[autocomplete*="email" i]',
  ).forEach((el) => { if (isEmailField(el) && el.value) writeCookie(el.value); });
}

export function CheckoutEmailCapture() {
  useEffect(() => {
    scan(document);

    const handler = (e: Event) => {
      if (e.target instanceof Element && isEmailField(e.target)) {
        writeCookie((e.target as HTMLInputElement).value);
      }
    };
    document.addEventListener('input', handler, true);
    document.addEventListener('change', handler, true);

    const observer = new MutationObserver((muts) => {
      for (const m of muts) {
        m.addedNodes.forEach((n) => { if (n instanceof Element) scan(n); });
        if (m.type === 'attributes' && m.target instanceof HTMLInputElement
            && isEmailField(m.target)) {
          writeCookie(m.target.value);
        }
      }
    });
    observer.observe(document.body, {
      subtree: true, childList: true,
      attributes: true, attributeFilter: ['value'],
    });

    return () => {
      document.removeEventListener('input', handler, true);
      document.removeEventListener('change', handler, true);
      observer.disconnect();
    };
  }, []);
  return null;
}

マウントする /checkout/page.tsx 次へ <Content> ブロックとクッキーは、顧客のクリック時に設定されます 注文. 領収書ページでは、フォームではなく、余計なクリックもありません。

src/app/(wordpress-pages)/checkout/order-received/[id]/page.tsx
import Link from 'next/link';
import { cookies } from 'next/headers';
import { OrderSummary } from '@/components/OrderSummary';
import { lookupOrder } from '@/actions/order';
import { CHECKOUT_EMAIL_COOKIE } from '@/utils/constants';

interface Props {
  params: Promise<{ id: string }>;
  searchParams: Promise<{ key?: string }>;
}

export default async function OrderReceivedPage({ params, searchParams }: Props) {
  const { id } = await params;
  const { key } = await searchParams;
  const email = (await cookies()).get(CHECKOUT_EMAIL_COOKIE)?.value ?? '';
  const fallback = key ? `/view-order?key=${encodeURIComponent(key)}` : '/view-order';

  if (!key || !email) {
    return (
      <main>
        <h1>Thanks for your order</h1>
        <p>We couldn't auto-load the receipt because your billing email isn't
          available on this device. Look up the order with the email you used at
          checkout — the order key from your confirmation email is the shared
          secret.</p>
        <Link href={fallback}>Look up the order</Link>
      </main>
    );
  }

  const { order, error } = await lookupOrder(email, key);
  if (!order) {
    return (
      <main>
        <h1>Thanks for your order</h1>
        <p className="error">{error ?? "We couldn't find that order."}</p>
        <Link href={fallback}>Look up the order</Link>
      </main>
    );
  }

  return (
    <main>
      <OrderSummary
        order={order}
        headline="Thanks for your order"
        intro="Your order has been received. A copy of this receipt is on its way to your inbox."
      />
    </main>
  );
}

フォールバックリンク /view-order?key=… “customerは、新しいデバイスで3日後にレシートメールを再開しました” の場合、クッキーは消えていますが、メールの注文キーは手動ルックアップのために動作します。

ステップ11 – 商品レビュー

レビューは、WooGraphQL の呼び出しの小さな POST ハンドラです。 writeReview 変異。 クライアントフォームが送信する productId, 著者名, 電子メール, 評価, コンテンツ; ルートの添付 Cart-Token、変異を実行し、WPバックエンドは、モデレーションキュー、スパムチェック、ストレージを処理します。

src/app/api/product/review/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { gqlWithSession } from '@/lib/wp';

export async function POST(req: NextRequest) {
  const { productId, author, email, content, rating } = await req.json();
  const sessionToken = (await cookies()).get('sessionToken')?.value ?? null;

  const result = await gqlWithSession<{ writeReview: { rating: number } }>(
    `mutation Write($input: WriteReviewInput!) {
       writeReview(input: $input) {
         rating
         review { id }
       }
     }`,
    { input: {
      commentOn: productId,
      author, authorEmail: email,
      content, rating,
    } },
    { sessionToken },
  );

  if (result.errors?.length) {
    return NextResponse.json({ error: result.errors[0].message }, { status: 400 });
  }
  return NextResponse.json({ ok: true, rating: result.data?.writeReview?.rating });
}

ワイヤー それまで <ReviewForm /> クライアントコンポーネント内部 <ProductTabs /> プロダクトページ。 レビューリスト自体は、同じ製品クエリからサーバー側をレンダーしています。 pagination は、あなたが望むものです。

煙テストは、フルフロー

エンドツーエンド: 訪問 /products/hoodie、色+ロゴの変形を選んで下さい、カート、土地に加えて下さい /cart アイテムを表示して、Proceedをクリックしてチェックアウト、Stripeテストカードを記入 4242 4242 4242 4242、順序を、土地に置いて下さい /checkout/order-received/[id]?key=… 完全なレシートによって自動的にレンダリングされる。 5つのクリック、シングルNext.jsホスト、フルWooCommerceバックエンドオーケストレーション。 ログイン、アカウントなし、JWTリフレッシュループなし。

チェックアウトページが “空のカートから注文を作成できません” をレンダリングした場合、それは fetchAssetsByUri ステップ4からgetcha リクエストを開く /graphql 実行する assetsByUri SSR 側で、SSR 側を運ぶことを確認します。 Cart-Token ヘッダー。 カートブロックのフロントエンドは、REST から無関係にポップアップしますが、 チェックアウト ブロックのプレロード wcSettings.checkoutData アセット・フェッチ・タイムでサーバー・サイドを生成し、UI全体をゲートします。

Order-received page after a successful test checkout — payment processed, inventory updated, email sent, all server-side via WooCommerce; receipt rendered automatically on the Next.js host.

ビルドしなかったこと

  • 支払いゲートウェイ — ストライプ、ペイパル、スクエア、クララナ、〜50 その他
  • 税務機関 — WooCommerce 税 (無料), Avalara, TaxJar
  • 船積み率 API — USPS、UPS、Federal Express、DHL、注文の平レートの論理
  • サブスクリプション — 請求サイクル, トライアル, 調整, アップグレード
  • 在庫 — 在庫レベル, 在庫の通知, バックオーダー, per-variant トラッキング
  • クーポンエンジン — 使用制限、顧客セグメント、製品/カテゴリ制限
  • 注文管理 — 払い戻し、部分的な払い戻し、注文メモ、顧客のメール
  • モデレーションのレビュー — スパムチェック、プロキシフィルタ、キューUI

WooCommerceは、そのすべてを処理します。 製品ページ UX と storefront chrome を処理します。 NextPressは「自分が望む方法を見る」と「購入したエクステンションで機能する」を選択せずに2つを接続します。


ショートカット: WooGraphQL プロサブスクリプション

上記はすべてオープンソースパスです。 ブログのチュートリアル、追加の依存関係なし、デモリポジトリの上位に新しいコードの数百ラインがクローンに準備されています。 チームの時間は、セッション管理のライフサイクルコードを書くよりも、ストアフロントUXに費やす方が良い場合、それはまさにそのものです ウーグラフQL プロサブスクリプション お問い合わせ

サブスクリプションは3つのものをバンドルします。

WooGraphQL Proプラグイン

実際に販売するWooCommerce製品の種類、クエリ、およびミューテーションでGraphQLスキーマを拡張するWordPressプラグイン: サブスクリプション、合成製品、製品バンドル、製品アドオン. 無料のWooGraphQLスキーマは、シンプルで可変的な製品のみを公開します。カタログにバンドルまたはサブスクリプションが含まれている瞬間、RESTフォールバックをハンドロールしています。 Pro プラグインでは、これらの製品タイプはスキーマで一流で、単一 { product(id: …) } クエリは、製品が起こるどのような形状を返す。

create-woonext-app CLI の作成

ツイート のような作品の足場 create-next-appですが、生成するボイラプレートは、作業中の Next.js + WooGraphQL ストアフロントです。 npx create-woonext-app my-shop, WPのバックエンドでそれを指す, あなたが製品ページを持っている, カート, チェックアウト, そして、あなたがファイルに触れる前に、エンドツーエンドを実行しているアカウントの流れ — 下のProのホックやコンポーネントで配線.

@woographql/* JS パッケージ

  • @woographql/次へ — shadcn/ui と同じ方法で動作するコンポーネント・ジェネレーション・ツールキット: コマンドを実行し、リポジトリ内のコンポーネント・ランドは、コードを所有してカスタマイズします。 コンポーネントライブラリは、完全なストアフロント表面をカバーしています。 CartOptions 単独では、フォームの状態と検証が既に有線されているすべての製品タイプWooサポート(シンプル、変数、コンポジット、バンドル、サブスクリプション、アドオン)のカートアクションUIをレンダリングします。
  • @woographql / リアクト・ホック — 上記のライフサイクルコードを破棄するタイプされたホック。 useSessionManager() Cookie-juggling、カートトークン回転、およびSSR-timeヘッダー転送を置き換えます。 useCartMutations() 置換 /api/cart ルーティングとクライアントの接着剤、楽観的な更新とキーのエラーロールバックが含まれている。
  • @woographql/セッションユーティリティ — トークンの暗号化、クッキー/ストレージの抽象化、署名されたステートのシリアライズの背後にある低レベルのビルディングブロック。 次回に2番目のWooストアフロントを出荷したら、再インストールされていないビット。

フードの下に何が起こっているかを理解したい場合は、オープンソースのパスを選ぶ – それは始めるための素晴らしい場所です、そしてコードはあなたの永遠にとどまります。 ストアフロントを出荷し、セッション管理ライブラリの作成者でないとサブスクリプションを選択します。 いずれにしても、このチュートリアルのアーキテクチャは正しいアーキテクチャです。 サブスクリプションは、タイプされたテストされた依存性として、ライフサイクルコードの洗練されたバージョンを提供します。

これは間違った呼び出しであるとき

  • 商取引の依存性および単一プロダクト カタログ無しのGreenfieldのプロジェクト。 ストライプチェックアウト+カスタムReactストアフロント船が速くなります。 WP + Wooで引き出すと、税金、配送、または複数のSKUが必要な場合があります。
  • ハンドプライス B2B 引用符. 「引用符を要求し、価格を入手し、注文に変換する」のカートビルダーワークフローは、Wooのカタログモデルに重要なカスタム作業なしできれいにマップしません。
  • 複数のベンダーの市場。 Wooにはマルチベンダーの拡張機能がありますが、複雑です。Medusaのようなマーケットプレースネイティブプラットフォームは、通常より優れたフィット感です。

残りのために — 商取引を必要とするほとんどのアプリ, 特にWordPressからコンテンツを提供するもの – コマースのバックエンドとしてWooを追加し、橋としてNextPressは「同じNext.jsホスト上の製品を販売する」午後に. サブスクリプション、カスタム支払いゲートウェイ、マルチ通貨、放棄されたカートの回復、B2B価格設定層 – 彼らは、あなたのアプリに対するリファクタではなく、WP管理者に対して拡張インストールしています。



Leave a Reply

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