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

banner

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

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

コンテンツの表

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

ユーザーナビゲーションとログインページを組み込むことで、eコマースストアを充実させようとするチュートリアルシリーズの次のインストールへようこそ。 この章は、ユーザーインターフェイスとインタラクション機能を強化し、より魅力的でシームレスなユーザーエクスペリエンスを実現します。

ユーザログインとナビゲーションの作成

任意のeコマースサイトのコア機能の1つは、ユーザーがサイトのさまざまなセクションをナビゲートし、自分のアカウントにログインして管理する能力を持つ能力です。 トップナビゲーションバーは、ナビゲーションの中央ハブとして機能し、ユーザーはサイトのさまざまな部分を簡単に効率的にアクセスすることができます。 トップナビゲーションバーにユーザーナビゲーションを追加すると、ユーザがユーザビリティを向上させるだけでなく、ユーザがアカウントとカートの詳細情報を楽にアクセスするのに役立ちます。

また、eコマースストアにログインページを追加する手順を説明します。 よく設計されたログインページは、ユーザーが注文を追跡し、お気に入りの製品を保存し、チェックアウトプロセスをスピードアップできるようにするためのゲートウェイであるため、任意のオンラインストアに不可欠です。

さらなる取引を甘くするために、時間と労力を両方節約できるきちんとしたトリックを探索します。 セッションのドロップオフリンクを使用して、WooCommerceストアをホスティングするWordPressインストールに、Next.jsアプリケーションからエンドユーザーのセッションを渡す方法を紹介します。 この方法のメリットは、アプリケーション内の特定のページを作成する必要性を迂回することを可能にすることです。特に、ビルドに時間がかかることや、フロントエンドアプリケーションで利用できなくなった機能性に依存する可能性があるもの。

前提条件

開始する前に、更新する必要があります .env.local この章に重要な変数を持つファイル。

SESSION_TOKEN_LS_KEY=[SESSION_TOKEN_LS_KEY]
REFRESH_TOKEN_LS_KEY=[REFRESH_TOKEN_LS_KEY]
AUTH_TOKEN_SS_KEY=[AUTH_TOKEN_SS_KEY]
AUTH_TOKEN_EXPIRY_SS_KEY=[AUTH_TOKEN_EXPIRY_SS_KEY]
CLIENT_CREDENTIALS_LS_KEY=[CLIENT_CREDENTIALS_LS_KEY]
CLIENT_SESSION_SS_KEY=[CLIENT_SESSION_SS_KEY]
CLIENT_SESSION_EXP_SS_KEY=[CLIENT_SESSION_EXP_SS_KEY]
NONCE_KEY=[NONCE_KEY]
NONCE_SALT=[NONCE_SALT]

値がランダムにすることができます。 NONCE_KEY そして、 NONCE_SALT WordPressの同等体と一致しなければなりません。 WordPressのインストールをチェックして、通常、この値を見つけることができます wp-config.php ファイル。 アップデートを忘れないでください next.config.js お問い合わせ

...

const nextConfig = {
  ...
  
  },
  env: {
    ...
    SESSION_TOKEN_LS_KEY: process.env.SESSION_TOKEN_LS_KEY,
    REFRESH_TOKEN_LS_KEY: process.env.REFRESH_TOKEN_LS_KEY,
    AUTH_TOKEN_SS_KEY: process.env.AUTH_TOKEN_SS_KEY,
    AUTH_TOKEN_EXP_SS_KEY: process.env.AUTH_TOKEN_EXP_SS_KEY,
    CLIENT_CREDENTIALS_LS_KEY: process.env.CLIENT_CREDENTIALS_LS_KEY,
    CLIENT_SESSION_SS_KEY: process.env.CLIENT_SESSION_SS_KEY,
    CLIENT_SESSION_EXP_SS_KEY: process.env.CLIENT_SESSION_EXP_SS_KEY,
    NONCE_KEY: process.env.NONCE_KEY,
    NONCE_SALT: process.env.NONCE_SALT,
  },
}
...

このチュートリアルの最後に、ユーザーナビゲーションを改善するための知識を装備し、ユーザーフレンドリーなログインページを追加し、セッションのドロップオフリンクを実装し、eコマースストアを次のレベルのユーザー対話と機能性に引き上げます。 ダイビングをしましょう!

パート1 ログインページとUserNavを作成する

1。 インストール zod, react-hook-form@hookform/resolvers パッケージ:

npm install zod react-hook-form @hookform/resolvers

このパッケージを使用して、ログインフォームの管理と検証を行います。

2。 作成する /app/login/page.tsx: : :

// アプリ/ログイン/ページ.tsx
import { Login } from '@/client/Login';

export default function LoginPage() {
  return (
    <div className="min-h-screen">
      <Login/>
    </div>
  );
}

簡単。 オンワード

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

// クライアント/Login/Login.tsx
import { useEffect } from 'react';
import * as z from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';

import { useSession } from '@/client/SessionProvider';
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/ui/form';
import { Input } from '@/ui/input';
import { Button } from '@/ui/button';
import { LoadingSpinner } from '@/ui/LoadingSpinner';

export const LoginSchema = z.object({
  username: z.string().min(4, {
    message: 'Username must be at least 4 characters long',
  }),
  password: z.string().min(1, {
    message: 'Password must enter a password',
  })
});

export function Login() {
  const { login, isAuthenticated, fetching } = useSession();
  const router = useRouter();
  const form = useForm<z.infer<typeof LoginSchema>>({
    resolver: zodResolver(LoginSchema),
    defaultValues: {
      username: '',
      password: '',
    },
  });

  useEffect(() => {
    if (isAuthenticated) {
      router.push('/');
    }
  }, [isAuthenticated]);

  const onSubmit = (data: z.infer<typeof LoginSchema>) => {
    login(data.username, data.password);
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-full max-w-screen-lg mx-auto px-4">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="Enter your username or e-mail associate with your account." {...field}/>
              </FormControl>
              <FormMessage/>
            </FormItem>
          )}
/>
        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <Input type="password" placeholder="Enter your password." {...field}/>
              </FormControl>
              <FormMessage/>
            </FormItem>
          )}
/>
        <Button
          type="submit"
          disabled={fetching}
          className="flex gap-x-2 items-center"
>
          Submit
          {fetching && <LoadingSpinner noText/>}
        </Button>
      </form>
    </Form>
  );
}

気に入ったら react-hook-form これは、あなたにとって異常に見えないはずです。 しかし、私は呼ぶのが好きです

const { login, isAuthenticated, fetching } = useSession();

useSession とりあえず SessionProvider まだ実装されていないので、すぐに動作するように期待しないでください。 お問い合わせ

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

import { useRouter } from 'next/navigation';

import { cn } from '@/utils/ui';
import { deleteClientSessionId } from '@woographql/utils/client';
import { useSession } from '@/client/SessionProvider';
import { NavLink, linkClassName } from '@/ui/NavLink';
import { Button } from '@/ui/button';

export function UserNav() {
  const { push } = useRouter();
  const {
    cart,
    customer,
    cartUrl,
    checkoutUrl,
    accountUrl,
    logout: killSession,
    isAuthenticated,
    fetching,
  } = useSession();
  
  const goToCartPage = () => {
    deleteClientSessionId();
    console.log('Go to cart page');
    window.location.href = cartUrl;
  };
  const goToCheckoutPage = () => {
    deleteClientSessionId();
    window.location.href = checkoutUrl;
  };
  const goToAccountPage = () => {
    deleteClientSessionId();
    window.location.href = accountUrl;
  };

  const logout = () => {
    killSession(`Goodbye, ${customer?.firstName}`);
  };

  return (
    <>
      <li className="group">
        <Button
          className={cn(
            'flex flex-row gap-x-2 items-center p-0 hover:no-underline text-base font-normal',
            linkClassName,
          )}
          disabled={fetching}
          variant='link'
          onClick={goToCartPage}
>
          <span>{cart?.contents?.itemCount || 0}</span>
          Cart
        </Button>
      </li>
      <li className="group w-auto">
        <Button
          className={cn(
            'p-0 hover:no-underline text-base font-normal',
            linkClassName,
          )}
          disabled={fetching}
          variant='link'
          onClick={goToCheckoutPage}
>
          Checkout
        </Button>
      </li>
      {isAuthenticated ? (
        <>
          <li className="group">
            <Button
              className={cn(
                'p-0 hover:no-underline text-base font-normal',
                linkClassName,
              )}
              disabled={fetching}
              variant='link'
              onClick={goToAccountPage}
>
              Account
            </Button>
          </li>
          <li className="group">
            <Button
              className={cn(
                'p-0 hover:no-underline text-base font-normal',
                linkClassName,
              )}
              disabled={fetching}
              variant='link'
              onClick={logout}
>
              Logout
            </Button>
          </li>
        </>
      ) : (
        <li className="group">
          <NavLink href="/login">
            Login
          </NavLink>
        </li>
      )}
    </>
  )
}

ザ・オブ・ザ・ UserNav コンポーネントは、受信した値に基づいてユーザーアクションを表示する責任があります。 SessionProviderお問い合わせ

const {
    cart, // Cart data object.
    customer, // Customer data object.
    goToCartPage, // Callback for sending the user to the Cart Page.
    goToAccountPage, // Callback for sending the user to the Account Page.
    goToCheckoutPage, // Callback for sending the user to the Checkout Page.
    logout,  // Callback for deleting end-user's session.
    isAuthenticated, // Flag determining end-user's login status
    refetchUrls, // Callback to begin process of generating new session drop-off urls.
    fetching, // Flag determining session handler fetcher status.
} = useSession();

これを追加してみましょう /server/TopNav 最終的に動く前に部品 SessionProviderお問い合わせ

5。 新着情報 /server/TopNav/TopNav.tsx: : :

// サーバ/TopNav/TopNav.tsx
...

import { UserNav } from '@/client/UserNav';
...

export function TopNav({ menu }: TopNavProps) {
  return (
    <nav className="w-full bg-white min-h-24 py-4 px-4">
      <ul className="max-w-screen-lg m-auto w-full flex flex-row gap-x-4 justify-end items-center">
        ...
        <UserNav/>
      </ul>
    </nav>
  )
}

パート2:SessionProviderを作成する

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

// クライアント/SessionProvider/SessionProvider.tsx
import {
  createContext,
  PropsWithChildren,
  useContext,
  useReducer,
  useEffect,
} from 'react';

import {
  Customer,
  Cart,
} from '@/graphql';

import { useToast } from '@/ui/use-toast';

export interface SessionContext {
  isAuthenticated: boolean;
  hasCredentials: boolean;
  cart: Cart|null;
  customer: Customer|null;
  cartUrl: string;
  checkoutUrl: string;
  accountUrl: string;
  fetching: boolean;
  logout: (message?: string) => void;
  login: (username: string, password: string, successMessage?: string) => Promise<boolean>;
  refetch: () => Promise<boolean>;
}

const initialContext: SessionContext = {
  isAuthenticated: false,
  hasCredentials: false,
  cart: null,
  customer: null,
  cartUrl: '',
  checkoutUrl: '',
  accountUrl: '',
  fetching: false,
  logout: (message?: string) => null,
  login: (username: string, password: string) => new Promise((resolve) => { resolve(false); }),
  refetch: () => new Promise((resolve) => { resolve(false); }),
};

export const sessionContext = createContext<SessionContext>(initialContext);

type SessionAction = {
  type: 'UPDATE_STATE';
  payload: SessionContext;
} | {
  type: 'LOGOUT';
};

const reducer = (state: SessionContext, action: SessionAction): SessionContext => {
  switch (action.type) {
    case 'UPDATE_STATE':
      return {
        ...state,
        ...action.payload,
      };
    case 'LOGOUT':
      return {
        ...state,
        customer: null,
        cart: null,
      };
    default:
      throw new Error('Invalid action dispatched to session data reducer');
  }
};

const { Provider } = sessionContext;

export function SessionProvider({ children }: PropsWithChildren) {
  const { toast } = useToast();
  const [state, dispatch] = useReducer(reducer, initialContext);

  // ログアウト状態とクレジットストアをローカルで削除します。
  const logout = () => {
    dispatch({ type: 'LOGOUT' });
  };
      

  const store: SessionContext = {
    ...state,
    isAuthenticated: !!state.customer?.id && 'guest' !== state.customer.id,
    hasCredentials: false,
    logout,
  };
  return (
    <Provider value={store}>{children}</Provider>
  );
}

export const useSession = () => useContext(sessionContext);

今、それを追加します。 /app/layout.tsx

2。 新着情報 /app/layout.tsx: : :

// アプリ/レイアウト.tsx
...

import { SessionProvider } from '@/client/SessionProvider';
...

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  ...

  return (
    <html lang="en">
      <body>
        <SessionProvider>
          <TopNav menu={menu}/>
          <main className="w-full">
            {children}
          </main>
        </SessionProvider>
        <Toaster/>
      </body>
    </html>
  )
}

このアプリケーションは正常にビルドして実行しますが、実際に導入したコンポーネントのどれも動作しません。 keen eye をお持ちの方は、エンドポイントと通信するためのロジックを実装していないことに気づくでしょう。

これは、エンドユーザのセキュアにセッション資格の高度管理とグラフQLエンドポイントの制限された可視性を必要とするためです。 これまでに作成されているすべてのものにもかかわらず、GramQLエンドポイントを公開するライブアプリケーションで動作することに注意してください。 信じないでください。 ブラウザの開発者ツールを開き、ブラウザの開発者ツールを確認してください。 Network タブ。 このページをリフレッシュし、周りを回すと、何でもしますが、グラフQLエンドポイントに表示されていないリクエストが表示されます。

これまで、すべての GraphQL リクエストは、ページビルド時に実行されるすべての実行時にのみサーバーレベルで作成されていますが、クライアント側のリクエストをエンドユーザのセッションに対処するため、クライアントに直接 GraphQL クエリを実行したときにエンドポイントを調べる必要があります。

そのため、クライアントでは実行されませんが、代わりに次の13ルートページを利用しています。 クライアントのビューからグラフQLリクエストを実行するルートページをいくつか作成します。 その前に、これらのルートページと通信するためのロジックを定義し、時間感度セッションの資格情報を管理するユーティリティファイルを作成する必要があります。 それでは始めましょう。

パート3:ユーティリティファイルを作成します。

1。 インストール crypto-js そして、 jwt-decode パッケージ:

npm install crypto-js jwt-decode

WordPressのハッシュ機能を再作成するライブラリ。

2。 作成する /utils/nonce.ts: : :

// ユーティリティ/nonce.ts
import HmacMD5 from 'crypto-js/hmac-md5';
import jwtDecode from 'jwt-decode';

export const MINUTE_IN_SECONDS = 60;
export const HOUR_IN_SECONDS = 60 * MINUTE_IN_SECONDS;
export const DAY_IN_SECONDS = 24 * HOUR_IN_SECONDS;

export function time() {
  return Math.floor(new Date().getTime() / 1000);
}

export function nonceTick() {
  const nonceLife = DAY_IN_SECONDS;
  return Math.ceil(time() / (nonceLife / 2));
}

export function wpNonceHash(data: string) {
  const nonceSalt = process.env.NONCE_KEY as string + process.env.NONCE_SALT as string;
  const hash = HmacMD5(data, nonceSalt).toString();

  return hash;
}

export function createNonce(action: string, uId:string|number, token:string) {
  const i = nonceTick();

  const nonce = wpNonceHash(`${i}|${action}|${uId}|${token}`).slice(-12, -2);

  return nonce;
}

export enum ActionTypes {
  Cart = 'cart',
  Checkout = 'checkout',
  Account = 'account',
}

function getAction(action: ActionTypes, uId: string|number) {
  switch (action) {
    case ActionTypes.Cart:
      return `load-cart_${uId}`;
    case ActionTypes.Checkout:
      return `load-checkout_${uId}`;
    case ActionTypes.Account:
      return `load-account_${uId}`;
    default:
      throw new Error('Invalid nonce action provided.');
  }
}

function getNonceParam(action: ActionTypes) {
  switch (action) {
    case ActionTypes.Cart:
      return '_wc_cart';
    case ActionTypes.Checkout:
      return '_wc_checkout';
    case ActionTypes.Account:
      return '_wc_account';
    default:
      throw new Error('Invalid nonce action provided.');
  }
}

type DecodedToken = {
  data: { customer_id: string };
}
export function getUidFromToken(sessionToken: string) {
  const decodedToken = jwtDecode<DecodedToken>(sessionToken);
  if (!decodedToken?.data?.customer_id) {
    throw new Error('Failed to decode session token');
  }
  return decodedToken.data.customer_id;
}

export function generateUrl(sessionToken:string, clientSessionId:string, actionType: ActionTypes) {
  const uId = getUidFromToken(sessionToken);
  const action = getAction(actionType, uId);

  // nonce を作成する
  const nonce = createNonce(action, uId, clientSessionId);

  // URL を作成します。
  const param = getNonceParam(actionType);
  let url = `${process.env.BACKEND_URL}/wp/transfer-session?session_id=${uId}&${param}=${nonce}`;

  return url;
}

私はここにこのファイルに関する多くの詳細に行くつもりはありません, しかし、それは主にカップルのPHP / WPの機能/コンスタントのJSクローンです. 私達の場合 session 私たちがケアするユーティリティ time 機能および MINUTE_IN_SECONDS 定数。 オンワード

3。 作成する /utils/client.ts: : :

// ユーティリティ/クライアント.ts
import {
  wpNonceHash,
  time,
  HOUR_IN_SECONDS,
  MINUTE_IN_SECONDS,
  DAY_IN_SECONDS,
} from '@/utils/nonce';

type Creds = {
  userAgent: string;
  ip: string;
  issued: number;
}

async function createClientSessionId() {
  const encodedCredentials = localStorage.getItem(process.env.CLIENT_CREDENTIALS_LS_KEY as string);
  let credentials: null|Creds = encodedCredentials ? JSON.parse(encodedCredentials) : null;
  if (!credentials || time()> credentials.issued + (14 * DAY_IN_SECONDS)) {
    // UserAgent で認証オブジェクトを作成します。
    credentials = {
      userAgent: window?.navigator?.userAgent || '',
      ip: '',
      issued: 0,
    };

    // フェッチIP。
    const response = await fetch('https://api.ipify.org/?format=json');
    const { data } = await response.json();
    credentials.ip = data?.ip || '';
  }

  // 毎回新しい nonces が生成されるようにタイムスタンプを更新
  // エンドユーザはアプリケーションを起動します。
  credentials.issued = time();
  localStorage.setItem(process.env.CLIENT_CREDENTIALS_LS_KEY as string, JSON.stringify(credentials));

  // クライアントセッションIDを生成します。
  const clientSessionId = wpNonceHash(JSON.stringify(credentials));
  const timeout = `${credentials.issued + HOUR_IN_SECONDS}`;

  // クライアントセッション ID を保存します。
  sessionStorage.setItem(process.env.CLIENT_SESSION_SS_KEY as string, clientSessionId);
  sessionStorage.setItem(process.env.CLIENT_SESSION_EXP_SS_KEY as string, timeout);

  // クライアントセッションIDを返します。
  return { clientSessionId, timeout };
}

function hasSessionToken() {
  const sessionToken = localStorage.getItem(process.env.SESSION_TOKEN_LS_KEY as string);

  return !!sessionToken;
}

let clientSetter: ReturnType<typeof setInterval>;

/**
 * Creates timed fetcher for renewing client credentials and client session id.
 * 
 * @returns {void}
 */
function setClientFetcher() {
  if (clientSetter) {
    clearInterval(clientSetter);
  }
  clientSetter = setInterval(
    async () => {
      if (!hasSessionToken()) {
        clearInterval(clientSetter);
        return;
      }
      createClientSessionId();
    },
    Number(45 * MINUTE_IN_SECONDS),
  );
}

export async function getClientSessionId() {
  let clientSessionId = sessionStorage.getItem(process.env.CLIENT_SESSION_SS_KEY as string);
  let timeout = sessionStorage.getItem(process.env.CLIENT_SESSION_EXP_SS_KEY as string);
  if (!clientSessionId || !timeout || time()> Number(timeout)) {
    ({ clientSessionId, timeout } = await createClientSessionId());
    setClientFetcher();
  }

  return { clientSessionId, timeout };
}

export function deleteClientSessionId() {
  if (clientSetter) {
    clearInterval(clientSetter);
  }
  sessionStorage.removeItem(process.env.CLIENT_SESSION_SS_KEY as string);
  sessionStorage.removeItem(process.env.CLIENT_SESSION_EXP_SS_KEY as string);
}

export function deleteClientCredentials() {
  deleteClientSessionId();
  localStorage.removeItem(process.env.CLIENT_CREDENTIALS_LS_KEY as string);
}

ここでのロジックは、 clientSessionId. これは、エンドユーザーのマシンにセッションのドロップオフリンクの使用を制限することが必要です。 ルートページを作成する際には、もっと使うことができます。 今のところ注目すべきのは、時間感度とほとんどのロジックが評価と更新に専念しているということです。

4。 作成する /utils/session.ts: : :

import { GraphQLError } from 'graphql/error';

import {
  Customer,
  Cart,
} from '@/graphql';
import { getClientSessionId } from '@/utils/client';
import { MINUTE_IN_SECONDS, time } from '@/utils/nonce';

type ResponseErrors = {
  errors?: {
    message: string;
    data?: unknown;
  }
}
async function apiCall<T>(url: string, input: globalThis.RequestInit) {
  const response = await fetch(
    url,
    {
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      ...input,
    },
  );

  const json: T&ResponseErrors = await response.json();

  // エラーをキャプチャします。
  if (json?.errors || response.status !== 200) {
    throw new Error(json.errors?.message || `Failed to fetch: ${url}`);
  }

  return json;
}

// Auth 管理。
function saveCredentials(authToken: string, sessionToken?: string, refreshToken?: string) {
  sessionStorage.setItem(process.env.AUTH_TOKEN_SS_KEY as string, authToken);
  if (!!sessionToken) {
    localStorage.setItem(process.env.SESSION_TOKEN_LS_KEY as string, sessionToken);
  }
  if (refreshToken) {
    localStorage.setItem(process.env.REFRESH_TOKEN_LS_KEY as string, refreshToken);
  }
}

function saveSessionToken(sessionToken: string) {
  localStorage.setItem(process.env.SESSION_TOKEN_LS_KEY as string, sessionToken);
}

export function hasCredentials() {
  const sessionToken = localStorage.getItem(process.env.SESSION_TOKEN_LS_KEY as string);
  const authToken = sessionStorage.getItem(process.env.AUTH_TOKEN_SS_KEY as string);
  const refreshToken = localStorage.getItem(process.env.REFRESH_TOKEN_LS_KEY as string);

  if (!!sessionToken && !!authToken && !!refreshToken) {
    return true;
  }

  return false;
}

function setAuthTokenExpiry() {
  const authTimeout = time() + (15 * MINUTE_IN_SECONDS);
  sessionStorage.setItem(process.env.AUTH_TOKEN_EXPIRY_SS_KEY as string, `${authTimeout}`);
}

function authTokenIsExpired() {
  const authTimeout = sessionStorage.getItem(process.env.AUTH_TOKEN_EXPIRY_SS_KEY as string);
  if (!authTimeout || Number(authTimeout) < time()) {
    return true;
  }
}

type FetchAuthTokenResponse = {
  authToken: string;
  sessionToken: string;
}

async function fetchAuthToken() {
  const refreshToken = localStorage.getItem(process.env.REFRESH_TOKEN_LS_KEY as string);
  if (!refreshToken) {
    // eslint-disable-next-line ノーコンソール
    isDev() && console.error('Unauthorized');
    return null;
  }

  const json = await apiCall<FetchAuthTokenResponse>(
    '/api/auth',
    {
      method: 'POST',
      body: JSON.stringify({ refreshToken }),
    },
  );

  const { authToken, sessionToken } = json;
  saveCredentials(authToken, sessionToken);
  setAuthTokenExpiry();

  return authToken;
}

let tokenSetter: ReturnType<typeof setInterval>;
function setAutoFetcher() {
  if (tokenSetter) {
    clearInterval(tokenSetter);
  }
  tokenSetter = setInterval(
    async () => {
      if (!hasCredentials()) {
        clearInterval(tokenSetter);
        return;
      }
      fetchAuthToken();
    },
    Number(process.env.AUTH_KEY_TIMEOUT || 30000),
  );
}

type LoginResponse = {
  authToken: string
  refreshToken: string;
  sessionToken: string;
}
export async function login(username: string, password: string): Promise<boolean|string> {
  let json: LoginResponse;
  try {
    json = await apiCall<LoginResponse>(
      '/api/login',
      {
        method: 'POST',
        body: JSON.stringify({ username, password }),
      },
    );
  } catch (error) {
    return (error as GraphQLError)?.message || error as string;
  }

  const { authToken, refreshToken, sessionToken } = json;
  saveCredentials(authToken, sessionToken, refreshToken);
  setAutoFetcher();

  return true;
}

export async function getAuthToken() {
  let authToken = sessionStorage.getItem(process.env.AUTH_TOKEN_SS_KEY as string);
  if (!authToken || authTokenIsExpired()) {
    authToken = await fetchAuthToken();
  }

  if (authToken && !tokenSetter) {
    setAutoFetcher();
  }
  return authToken;
}

type FetchSessionTokenResponse = {
  sessionToken: string;
}

async function fetchSessionToken() {
  const json = await apiCall<FetchSessionTokenResponse>(
    '/api/auth',
    { method: 'GET' },
  );

  const { sessionToken } = json;

  sessionToken && saveSessionToken(sessionToken);
  return sessionToken;
}

async function getSessionToken() {
  let sessionToken = localStorage.getItem(process.env.SESSION_TOKEN_LS_KEY as string);
  if (!sessionToken) {
    sessionToken = await fetchSessionToken();
  }
  return sessionToken;
}

export function hasRefreshToken() {
  const refreshToken = localStorage.getItem(process.env.REFRESH_TOKEN_LS_KEY as string);

  return !!refreshToken;
}

export function hasAuthToken() {
  const authToken = sessionStorage.getItem(process.env.AUTH_TOKEN_SS_KEY as string);

  return !!authToken;
}

export type FetchSessionResponse = {
  customer: Customer;
  cart: Cart;
}
export async function getSession(): Promise<FetchSessionResponse|string> {
  const authToken = await getAuthToken();
  const sessionToken = await getSessionToken();
  let json: FetchSessionResponse;
  try {
    json = await apiCall<FetchSessionResponse>(
      '/api/session',
      {
        method: 'POST',
        body: JSON.stringify({
          sessionToken,
          authToken,
        }),
      },
    );
  } catch (error) {
    return (error as GraphQLError)?.message || error as string;
  }

  const { customer } = json;
  saveSessionToken(customer.sessionToken as string);

  return json;
}



export type FetchAuthURLResponse = {
  cartUrl: string;
  checkoutUrl: string;
  accountUrl: string
}
export async function fetchAuthURLs(): Promise<FetchAuthURLResponse|string> {
  const authToken = await getAuthToken();
  const sessionToken = await getSessionToken();
  const { clientSessionId, timeout } = await getClientSessionId();
  let json: FetchAuthURLResponse;
  try {
    json = await apiCall<FetchAuthURLResponse>(
      '/api/nonce',
      {
        method: 'POST',
        body: JSON.stringify({
          sessionToken,
          authToken,
          clientSessionId,
          timeout,
        }),
      },
    );
  } catch (error) {
    return (error as GraphQLError)?.message || error as string;
  }

  return json;
}

export function deleteCredentials() {
  if (tokenSetter) {
    clearInterval(tokenSetter);
  }
  localStorage.removeItem(process.env.SESSION_TOKEN_LS_KEY as string);
  sessionStorage.removeItem(process.env.AUTH_TOKEN_SS_KEY as string);
  localStorage.removeItem(process.env.REFRESH_TOKEN_LS_KEY as string);
}

ここまではちょっとしたビーフリーです。 login, getSessionfetchAuthURLs コールバックは、 SessionProviderお問い合わせ

  • login: リクエストの送信による作品 /api/login エンドユーザーのルート username そして、 password 首尾よく、および authToken そして、 refreshToken 返されます。
  • getSession: エンドユーザーのセッションの認証情報を最初に取得する localStorage それから POST の要求を送って下さい /api/session エンドユーザの authToken そして、 sessionToken 識別のため。
    • エンドユーザーの場合 refreshToken 有効でない authToken 新規 authToken POSTリクエストを送信することで取得 /api/auth お問い合わせ fetchAuthToken,
    • エンドユーザの認証がない場合、新しい認証がない場合 sessionToken GETリクエストを送信することで取得 /api/auth お問い合わせ fetchSessionToken
  • fetchAuthURLs: 丁度のように働く getSession 送信する以外 clientSessionId そして、 clientSessionIdTimeout セッションドロップオフURL生成だけでなく、
    • エンドユーザが有効でない場合 clientSessionId、新しいものが生成されます。

作成したすべてのユーティリティファイル。 ルートページに移動してみましょう。

Part 4: ルートページを作成する

1。 作成する app/api/auth/route.ts: : :

// アプリケーション/api/auth/route.ts
import { NextResponse } from 'next/server';
import { print } from 'graphql';

import {
  GetSessionDocument,
  GetSessionQuery,
  RefreshAuthTokenDocument,
  RefreshAuthTokenMutation,
  getClient,
} from '@/graphql';

export async function GET(request: Request) {
  try {
    const client = getClient();

    const { data, headers } = await client.rawRequest<GetSessionQuery>(
      print(GetSessionDocument),
    );

    const cart = data?.cart;
    const customer = data?.customer;
    const sessionToken = headers.get('woocommerce-session');

    if (!cart || !customer || !sessionToken) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve session credentials.' } }, { status: 500 });
    }

    return NextResponse.json({ sessionToken });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Sorry, something went wrong' } }, { status: 500 });
  }
}

export async function POST(request: Request) {
  try {
    const client = getClient();

    const body = await request.json();
    let authToken = body.authToken;
    const refreshToken = body.refreshToken;
    if (!authToken && !refreshToken) {
      return NextResponse.json({ errors: { message: 'No refresh token provided' } }, { status: 500 });
    }

    if (!authToken && refreshToken) {
      client.setHeaders({});
      const results = await client.request<RefreshAuthTokenMutation>(
        RefreshAuthTokenDocument,
        { refreshToken },
      );

      authToken = results?.refreshJwtAuthToken?.authToken;

      if (!authToken) {
        return NextResponse.json({ errors: { message: 'Failed to retrieve auth token.' } }, { status: 500 });
      }
    }

    client.setHeaders({ Authorization: `Bearer ${authToken}` });
    const { data: cartData, headers } = await client.rawRequest<GetSessionQuery>(
      print(GetSessionDocument),
    );

    const newSessionToken = cartData?.customer?.sessionToken;
    if (!newSessionToken) {
      return NextResponse.json({ errors: { message: 'Failed to validate auth token.' } }, { status: 500 });
    }

    const sessionToken = headers.get('woocommerce-session') || newSessionToken;

    return NextResponse.json({ authToken, sessionToken });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Sorry, something went wrong' } }, { status: 500 });
  }
}

このルートは両方ありますが、 GET そして、 POST コールバックは、異なる状況下で同じ目標を試みる。

  • POST ユーザは、いずれかのユーザが戻ってくるとします。 refreshToken または authToken 成功すると、 authToken そして、 sessionTokenお問い合わせ
  • GET ユーザは資格情報なしで新規で、新規に返すと仮定します sessionToken ご利用者様へ

2。 作成する /app/api/login/route.ts: : :

import { NextResponse } from 'next/server';
import { print } from 'graphql';

import {
  GetSessionDocument,
  GetSessionQuery,
  LoginDocument,
  LoginMutation,
  getClient,
} from '@/graphql';

type RequestBody = ({
  auth: string;
  username: undefined;
  password: undefined;
} | {
  auth: undefined;
  username: string;
  password: string;
})

export async function POST(request: Request) {
  try {
    const { username, password } = await request.json() as RequestBody;
    const graphQLClient = getClient();

    if (!username || !password) {
      return NextResponse.json({
        errors: {
          message: 'Proper credential must be provided for authentication',
        },
      }, { status: 500 });
    }

    const data = await graphQLClient.request<LoginMutation>(LoginDocument, { username, password });
    if (!data?.login) {
      return NextResponse.json({ errors: { message: 'Login failed.' } }, { status: 500 });
    }

    const { authToken, refreshToken } = data?.login;
    if (!authToken || !refreshToken) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve credentials.' } }, { status: 500 });
    }

    graphQLClient.setHeader('Authorization', `Bearer ${data.login.authToken}`);
    const { data:_, headers } = await graphQLClient.rawRequest<GetSessionQuery>(print(GetSessionDocument));
    
    const sessionToken = headers.get('woocommerce-session');
    if (!sessionToken) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve session token.' } }, { status: 500 });
    }


    return NextResponse.json({ authToken, refreshToken, sessionToken });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Login credentials invalid.' } }, { status: 500 });
  }
}

これは、最後のものとは似ていますが、単純です。 お問い合わせ username そして、 password 入力からエンドユーザの戻りを試みて下さい authToken, refreshTokensessionTokenお問い合わせ

お問い合わせ sessionToken HTTP レスポンスから取得される GetSession 問い合わせ authToken にセット Authorization. この理由は、データベース内のユーザーに接続された最後のセッションを取得することです。 もし、 sessionToken から customer クエリは、この1つの新しいセッションの代わりに使用されます。 login 毎回、古いセッションは新しい秒を消去します sessionToken 使用しています。 パターンも使われていました。 POST コールバックの呼び出し api/auth あなたが気付いた場合と同様に、ルート。

3。 作成する /app/api/session/route.ts: : :

import { NextResponse } from 'next/server';
import {
  GetSessionDocument,
  GetSessionQuery,
  Cart,
  Customer,
  getClient,
} from '@/graphql';

type RequestBody = {
  sessionToken: string;
  authToken?: string;
}

type GraphQLRequestHeaders = {
  'woocommerce-session': string;
  Authorization?: string;
}

export async function POST(request: Request) {
  try {
    const { sessionToken, authToken } = await request.json() as RequestBody;

    if (!sessionToken) {
      return NextResponse.json({ errors: { message: 'Session not started' } }, { status: 500 });
    }
    const headers: GraphQLRequestHeaders = { 'woocommerce-session': `Session ${sessionToken}` };
    if (authToken) {
      headers.Authorization = `Bearer ${authToken}`;
    }
    const graphQLClient = getClient();
    graphQLClient.setHeaders(headers);

    const results = await graphQLClient.request<GetSessionQuery>(
      GetSessionDocument,
    );

    const customer = results?.customer as Customer;
    const cart = results?.cart as Cart;

    if (!customer) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve customer data.' } }, { status: 500 });
    }

    if (!cart) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve cart data.' } }, { status: 500 });
    }

    return NextResponse.json({ customer, cart });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Sorry, something went wrong' } }, { status: 500 });
  }
}

これは、 sessionToken そして、 authToken. 適切な資格情報を取得すると、エンドユーザの customer そして、 cart データから GetSession お問い合わせ

4。 作成する /api/nonce/route.ts: : :

import { NextResponse } from 'next/server';

import { UpdateSessionDocument, UpdateSessionMutation, getClient } from '@/graphql';
import { ActionTypes, generateUrl } from '@/utils/nonce';

type RequestBody = {
  sessionToken: string;
  authToken?: string;
  clientSessionId: string;
  timeout: number;
}

type GraphQLRequestHeaders = {
  'woocommerce-session': string;
  Authorization?: string;
}

export async function POST(request: Request) {
  try {
    const {
      sessionToken,
      authToken,
      clientSessionId,
      timeout,
    } = await request.json() as RequestBody;

    if (!sessionToken) {
      return NextResponse.json({ errors: { message: 'No session started.' } }, { status: 500 });
    }

    if (!clientSessionId || !timeout) {
      return NextResponse.json({ errors: { message: 'Client Session ID and expiration must be provided.' } }, { status: 500 });
    }

    const headers: GraphQLRequestHeaders = { 'woocommerce-session': `Session ${sessionToken}` };
    if (authToken) {
      headers.Authorization = `Bearer ${authToken}`;
    }
    const client = getClient();
    client.setHeaders(headers);

    const input = {
      sessionData: [
        {
          key: 'client_session_id',
          value: clientSessionId,
        },
        {
          key: 'client_session_id_expiration',
          value: timeout,
        },
      ],
    };
    const results = await client.request<UpdateSessionMutation>(
      UpdateSessionDocument,
      { input },
    );

    if (!results.updateSession) {
      const message = 'Failed to update session';
      return NextResponse.json({ errors: { message } }, { status: 500 });
    }

    const cartUrl = generateUrl(sessionToken, clientSessionId, ActionTypes.Cart);
    const checkoutUrl = generateUrl(sessionToken, clientSessionId, ActionTypes.Checkout);
    const accountUrl = generateUrl(sessionToken, clientSessionId, ActionTypes.Account);

    return NextResponse.json({ cartUrl, checkoutUrl, accountUrl });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Sorry, something went wrong' } }, { status: 500 });
  }
}

このルートは、最後のプラスエンドユーザーと同じ入力を期待しています clientSessionId そして、 clientSessionIdTimeout. 適切な入力を与えられた場合、エンドユーザのセット client_session_id そして、 client_session_id_expiration サーバーのエンドユーザーのWooCommerceセッションオブジェクト。 これらの値は、セッションのドロップオフURLで使用されるWPバックエンドの一連の nonces を生成するために使用されます。

これで、これらの非部分や、セッションのドロップオフ URL を GraphQL エンドポイントでクエリできますが、transit で漏れている nonces/URL のリスクを実行します。 代わりに、定義された機能の残りの部分を利用します utils/nonce.ts ルートページ内の同じ部分をファイル化して再作成します。 generateUrl エンドユーザー用のセッションドロップオフURLテーラーを生成し、そこに機械に縛られます。 これにより、ルートページの最後のページが作成され、左が更新されます SessionProvider ユーティリティファイルを利用して、ルートページを拡張します。

パート5:終わり SessionProvider

1。 新着情報 /client/SessionProvider/SessionProvider.tsx: : :

// クライアント/SessionProvider/SessionProvider.tsx
...

import {
  hasCredentials,
  deleteCredentials,
  getSession as getSessionApiCall,
  FetchSessionResponse as Session,
  FetchAuthURLResponse as AuthUrls,
  fetchAuthURLs as fetchAuthURLsApiCall,
  login as loginApiCall,
} from '@/utils/session';
import {
  deleteClientSessionId,
  deleteClientCredentials,
} from '@/utils/client';
import { useToast } from '@/ui/use-toast';
...

export function SessionProvider({ children }: PropsWithChildren) {
  ...

  // ログアウト状態とクレジットストアをローカルで削除します。
  const logout = () => {
    ...
    deleteCredentials();
    deleteClientCredentials();
    fetchSession();
  };

  // プロセスセッションはリクエストの応答を取得します。
  const setSession = (session: Session|string, authUrls: AuthUrls|string) => {
    if (typeof session === 'string') {
      toast({
        title: 'Fetch Session Error',
        description: 'Failed to fetch session.',
        variant: 'destructive'
      });
    }
    if (typeof authUrls === 'string') {
      toast({
        title: 'Session Error',
        description: 'Failed to generate session URLs. Please refresh the page.',
        variant: 'destructive'
      });
    }

    if (typeof session === 'string' || typeof authUrls === 'string') {
      dispatch({
        type: 'UPDATE_STATE',
        payload: { fetching: false } as SessionContext,
      });
      return false;
    }

    dispatch({
      type: 'UPDATE_STATE',
      payload: {
        ...session,
        ...authUrls,
        fetching: false
      } as SessionContext,
    });

    return true;
  };

  const login = (username: string, password: string) => {
    dispatch({
      type: 'UPDATE_STATE',
      payload: { fetching: true } as SessionContext,
    });
    return loginApiCall(username, password)
      .then((success) => {
        if (typeof success === 'string') {
          toast({
            title: 'Login Error',
            description: success,
            variant: 'destructive'
          });
          dispatch({
            type: 'UPDATE_STATE',
            payload: { fetching: false } as SessionContext,
          });
          return false;
        }
        
        return fetchSession();
    });
  };

  // 顧客データを取得します。
  const fetchSession = () => {
    dispatch({
      type: 'UPDATE_STATE',
      payload: { fetching: true } as SessionContext,
    });

    return getSessionApiCall()
      .then(async (sessionPayload) => {
        const authUrlPayload = await fetchAuthURLsApiCall();
        return setSession(sessionPayload, authUrlPayload);
      });
  };

  useEffect(() => {
    if (state.fetching) {
      return;
    }

    if (!state.customer || !state.cart) {
      fetchSession();
    }
  }, []);
      

  const store: SessionContext = {
    ...
    refetch: fetchSession,
    login,
  };
  return (
    <Provider value={store}>{children}</Provider>
  );
}

export const useSession = () => useContext(sessionContext);

セッションプロバイダーを更新した後、ログインやその他のユーザー Nav のオプションは期待通りに機能しますが、WP インストールをスタイル化して、次のアプリケーションをシームレスな体験に見せる必要があります。

コンテンツ

次のチュートリアルでは、単一の製品ページとカートオプションでアプリケーションを完了します。 うまくいけば、このチュートリアルはあなたを楽に保ち、次のチュートリアルであなたを見ます。

最終章の続き



Leave a Reply

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