Aufbau von Headless Shops mit WooGraphQL: Kapitel 4 von 5

banner

Heads up: Dieses Tutorial deckt eine frühere WooGraphQL-Version ab und ist nicht mehr auf dem neuesten Stand. Eine neu geschriebene Version ist in Arbeit – wir werden von hier aus darauf verlinken, sobald sie veröffentlicht wurde.

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

Inhaltsverzeichnis

  1. Einrichten von WordPress für Ihre Headless eCommerce-Anwendung
  2. Erstellen und Einrichten Ihrer eCommerce-App für die Entwicklung
  3. Erstellen von Shop Listing Pages mit WooGraphQL
  4. Erstellen von Benutzer-Login und Navigation
  5. Erstellen der Produktseite und Warenkorboptionen

Willkommen zur nächsten Ausgabe in unserer Tutorial-Serie, in der wir unseren E-Commerce-Shop mit Benutzernavigation und einer Anmeldeseite bereichern werden. In diesem Kapitel geht es darum, die Benutzeroberfläche und Interaktionsfähigkeiten zu verbessern und so eine ansprechendere und nahtlosere Benutzererfahrung zu schaffen.

Erstellen von Benutzer-Login und Navigation

Eine der Kernfunktionen jeder E-Commerce-Website ist die Möglichkeit für Benutzer, durch verschiedene Abschnitte der Website zu navigieren und sich anzumelden und ihr Konto zu verwalten. Die obere Navigationsleiste dient als zentraler Knotenpunkt für die Navigation, so dass Benutzer einfach und effizient auf verschiedene Teile der Website zugreifen können. Das Hinzufügen der Benutzernavigation zur oberen Navigationsleiste verbessert nicht nur die Benutzerfreundlichkeit, sondern hilft den Benutzern auch, mühelos auf ihre Konto- und Warenkorbdaten zuzugreifen.

Darüber hinaus führen wir Sie durch den Prozess des Hinzufügens einer Anmeldeseite zu Ihrem E-Commerce-Shop. Eine gut gestaltete Anmeldeseite ist für jeden Online-Shop von entscheidender Bedeutung, da sie das Tor zur Personalisierung der Benutzererfahrung darstellt, sodass Benutzer ihre Bestellungen verfolgen, ihre Lieblingsprodukte speichern und den Checkout-Prozess beschleunigen können.

Um den Deal weiter zu versüßen, werden wir einen netten Trick erkunden, der Ihnen Zeit und Mühe sparen könnte. Wir zeigen Ihnen, wie Sie Session-Drop-Off-Links verwenden, um die Endbenutzersitzung von unserer Next.js-Anwendung an die WordPress-Installation zu übergeben, die unseren WooCommerce-Shop hostet. Der Vorteil dieser Methode besteht darin, dass wir die Notwendigkeit, bestimmte Seiten in unserer Anwendung zu erstellen, umgehen können, insbesondere solche, deren Erstellung zeitaufwendig sein könnte oder die auf Funktionen angewiesen sind, die in unserer Front-End-Anwendung nicht verfügbar sind.

Voraussetzungen

Bevor wir beginnen, aktualisieren wir unsere .env.local Datei mit einigen Variablen, die für dieses Kapitel wichtig sein werden.

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]

Die Werte können zufällig sein, außer NONCE_KEY und NONCE_SALT Die müssen ihren Äquivalenten in WordPress entsprechen. Sie können diese Werte normalerweise finden, indem Sie die WordPress-Installation überprüfen wp-config.php Datei. Denken Sie daran, aktualisieren Sie Ihre next.config.js auch

...

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,
  },
}
...

Am Ende dieses Tutorials werden Sie mit dem Wissen ausgestattet, um die Benutzernavigation zu verbessern, eine benutzerfreundliche Anmeldeseite hinzuzufügen und Session-Drop-Off-Links zu implementieren, wodurch Ihr E-Commerce-Shop auf die nächste Ebene der Benutzerinteraktivität und -funktionalität gebracht wird. Lasst uns eintauchen und loslegen!

Teil 1 Anmeldeseite und UserNav erstellen

1. Installieren zod, react-hook-form, und @hookform/resolvers Packungen:

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

Wir werden diese Pakete verwenden, um das Anmeldeformular zu verwalten und zu validieren.

2. Schaffen /app/login/page.tsx:

// app/login/page.tsx
import { Login } from '@/client/Login';

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

Einfach genug. Vorwärts.

3. Schaffen /client/Login:

// client/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>
  );
}

Wenn Sie mit react-hook-form Das sollte für dich nicht ungewöhnlich aussehen. Der Teil, den ich jedoch gerne herausrufen würde, ist

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

useSession ist ein Haken an der SessionProvider die wir noch nicht implementiert haben, also erwarte nicht, dass dies sofort funktioniert. Wir werden bald dazu kommen.

4. Schaffen /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>
      )}
    </>
  )
}

Die UserNav Die Komponente ist für die Anzeige von Benutzeraktionen verantwortlich, die auf Werten basieren, die von der 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();

Fügen wir das dem /server/TopNav vor dem endgültigen Bewegen auf die SessionProvider.

5. Aktualisierung /server/TopNav/TopNav.tsx:

// Server/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>
  )
}

Teil 2: Sitzungsanbieter erstellen

1. Schaffen /client/SessionProvider:

// client/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);

  // Löschen Sie den Logout-Status und speichern Sie die Creds lokal.
  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);

Fügen Sie es nun dem /app/layout.tsx

2. Aktualisierung /app/layout.tsx:

// app/layout.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>
  )
}

Damit wird die Anwendung erfolgreich erstellt und ausgeführt, aber keine der von uns eingeführten Komponenten funktioniert tatsächlich. Diejenigen mit einem scharfen Auge hätten bemerkt, dass wir keine Logik für die Kommunikation mit unserem Endpunkt implementiert haben.

Dies liegt daran, dass der sichere Umgang mit dem Endnutzer eine ausgeklügelte Verwaltung der Sitzungsanmeldeinformationen und eine eingeschränkte Sichtbarkeit des GraphQL-Endpunkts erfordert. Es sollte beachtet werden, dass trotz allem, was bisher erstellt wurde, in der Live-Anwendung Aktionen stattfinden, die den GraphQL-Endpunkt freilegen. Glaube nicht. Öffnen Sie Ihre Browser-Entwickler-Tools und überprüfen Sie die Network Tab. Aktualisieren Sie die Seite und klicken Sie sich um, tun Sie was auch immer, aber Sie sehen keine sichtbaren Anfragen an Ihren GraphQL-Endpunkt.

Bisher wurden alle GraphQL-Anfragen nur bei jedem Durchlauf während der Seitenaufbauzeit auf Serverebene gestellt, aber wir müssen eine clientseitige Anfrage stellen, um die Sitzung des Endbenutzers zu bearbeiten, die den Endpunkt freilegen würde, wenn wir die GraphQL-Abfragen direkt auf dem Client ausführen würden.

Wir werden sie also nicht auf dem Client ausführen, sondern die Next 13 Route-Seiten nutzen. Wir werden nur wenige Route-Seiten erstellen, die unsere GraphQL-Anfragen aus der Sicht des Kunden ausführen. Zuvor müssen wir einige Dienstprogrammdateien erstellen, die die Logik für die Kommunikation mit diesen Routenseiten definieren und die zeitkritischen Sitzungsanmeldeinformationen verwalten. Fangen wir also an.

Teil 3: Utility-Dateien erstellen.

1. Installieren crypto-js und jwt-decode Packungen:

npm install crypto-js jwt-decode

Diese Bibliotheken mit WordPress Hashing-Funktionalität neu erstellen.

2. Schaffen /utils/nonce.ts:

// utils/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 erstellen
  const nonce = createNonce(action, uId, clientSessionId);

  // URL erstellen.
  const param = getNonceParam(actionType);
  let url = `${process.env.BACKEND_URL}/wp/transfer-session?session_id=${uId}&${param}=${nonce}`;

  return url;
}

Ich werde hier nicht auf viele Details in dieser Datei eingehen, aber es ist hauptsächlich ein JS-Klon von ein paar PHP / WP-Funktionen / Konstanten. Im Falle unserer session Utility wir kümmern uns um die time Funktion und MINUTE_IN_SECONDS konstant. Vorwärts.

3. Schaffen /utils/client.ts:

// utils/client.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)) {
    // Anmeldeobjekt mit UserAgent erstellen.
    credentials = {
      userAgent: window?.navigator?.userAgent || '',
      ip: '',
      issued: 0,
    };

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

  // Aktualisieren Sie den Zeitstempel, um sicherzustellen, dass jedes Mal neue Nonces generiert werden
  // Der Endbenutzer startet diese Anwendung.
  credentials.issued = time();
  localStorage.setItem(process.env.CLIENT_CREDENTIALS_LS_KEY as string, JSON.stringify(credentials));

  // Generieren Sie die Client Session ID.
  const clientSessionId = wpNonceHash(JSON.stringify(credentials));
  const timeout = `${credentials.issued + HOUR_IN_SECONDS}`;

  // Speichern Sie die Client Session ID.
  sessionStorage.setItem(process.env.CLIENT_SESSION_SS_KEY as string, clientSessionId);
  sessionStorage.setItem(process.env.CLIENT_SESSION_EXP_SS_KEY as string, timeout);

  // Client Session ID zurückgeben.
  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);
}

Die Logik hier ist für die Schaffung einer clientSessionId. Dies ist erforderlich, um die Nutzung von Limit Session Drop-Off-Links an den Computer des Endnutzers zu binden. Wir werden mehr in seine Verwendung bekommen wir die Route Seiten erstellen. Eine Sache, die vorerst zu beachten ist, ist, dass es zeitsensibel ist und der größte Teil der Logik hier der Bewertung und Erneuerung gewidmet ist.

4. Schaffen /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();

  // Fehler erfassen.
  if (json?.errors || response.status !== 200) {
    throw new Error(json.errors?.message || `Failed to fetch: ${url}`);
  }

  return json;
}

// Auth Management.
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 no-console
    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);
}

Obwohl es ein bisschen beefy diese ziemlich geradeaus ist. login, getSession, und fetchAuthURLs Callback wird von unseren SessionProvider.

  • login: Arbeitet durch Senden einer Anfrage an die /api/login Route mit dem Endnutzer username und password Input, wenn erfolgreich und authToken und refreshToken wird zurückgegeben.
  • getSession: Funktioniert, indem Sie zuerst die Sitzungsanmeldeinformationen des Endbenutzers abrufen localStorage Eine POST-Anfrage an /api/session mit dem Endnutzer authToken und sessionToken zur Identifizierung.
    • Wenn der Endbenutzer eine refreshToken und nicht gültig authToken und neu authToken wird abgerufen, indem eine POST-Anfrage an /api/auth in fetchAuthToken,
    • Wenn der Endbenutzer keinerlei Anmeldeinformationen hat, sessionToken wird abgerufen, indem eine GET-Anfrage an /api/auth in fetchSessionToken
  • fetchAuthURLs: Funktioniert genau wie getSession Es sei denn, es wird clientSessionId und clientSessionIdTimeout auch für Session Drop-Off URL Generation.
    • Wenn der Endbenutzer keine gültige clientSessionIdEine neue wird erzeugt.

Mit allen Utility-Dateien erstellt. Kommen wir zu den Route-Seiten.

Teil 4: Routenseiten erstellen

1. Schaffen app/api/auth/route.ts:

// app/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 });
  }
}

Dies, obwohl diese Route sowohl eine GET und POST Callback, beide versuchen das gleiche ziel nur unter anderen umständen.

  • POST geht davon aus, dass der Benutzer ein wiederkehrender Benutzer ist, mit entweder refreshToken oder authToken Wenn es erfolgreich ist, authToken und sessionToken.
  • GET nimmt an, dass der benutzer neu ist, ohne anmeldeinformationen und eine neue zurückgibt. sessionToken für einen Gastbenutzer.

2. Schaffen /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 });
  }
}

Dieser ist ähnlich wie der letzte, aber einfacher. Es braucht a username und password von der Eingabe und versucht, die Endbenutzer zurückzugeben authToken, refreshToken, und sessionToken.

Hinweis: sessionToken wird aus der HTTP-Antwort von GetSession Abfrage mit dem authToken in der AuthorizationDer Grund dafür ist, die letzte Sitzung mit dem Benutzer in der Datenbank zu verbinden. Wenn die sessionToken von customer Anstelle dieser Abfrage wird eine neue Sitzung mit gestartet login Jedes mal und die alte sitzung wird gelöscht, sobald die neue. sessionToken verwendet wird. Das Muster wurde auch in der POST Rückruf des api/auth Route auch, wenn Sie es bemerkt haben.

3. Schaffen /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 });
  }
}

Dieser erwartet eine sessionToken und möglicherweise und authTokenWenn es die richtigen Anmeldeinformationen erhält, gibt es das customer und cart Daten aus dem GetSession Abfrage.

4. Schaffen /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 });
  }
}

Diese Route erwartet die gleiche Eingabe wie die letzte plus den Endbenutzer clientSessionId und clientSessionIdTimeout. Wenn sie die richtige Eingabe erhält, legt sie die client_session_id und client_session_id_expiration im WooCommerce-Sitzungsobjekt des Endbenutzers auf dem Server. Diese Werte werden dann verwendet, um eine Reihe von Nonces auf dem WP-Backend zu generieren, die in den Session-Drop-Off-URLs verwendet werden.

Jetzt könnten wir nach diesen Nonces oder sogar den gesamten Session-Drop-Off-URLs auf dem GraphQL-Endpunkt abfragen, was jedoch das Risiko birgt, dass die Nonces / URLs intransitiert werden. Stattdessen nutzen wir den Rest der in der utils/nonce.ts Datei und erstellen Sie die gleichen Nonces auf unseren Route-Seiten. generateUrl Generiert eine Session-Drop-Off-URL, die auf den Endbenutzer zugeschnitten und an diesen Computer gebunden ist. Damit wurden die letzten Routenseiten erstellt und alles, was übrig bleibt, ist die Aktualisierung der SessionProvider zur Nutzung der Utility-Dateien und durch Erweiterung der Routenseiten.

Teil 5: Schluss SessionProvider

1. Aktualisierung /client/SessionProvider/SessionProvider.tsx:

// client/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) {
  ...

  // Löschen Sie den Logout-Status und speichern Sie die Creds lokal.
  const logout = () => {
    ...
    deleteCredentials();
    deleteClientCredentials();
    fetchSession();
  };

  // Process Session Fetch Request Antwort.
  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();
    });
  };

  // Kundendaten abrufen.
  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);

Nach dem Aktualisieren des SessionProviders sollten die Anmelde- und anderen Benutzer-Nav-Optionen wie erwartet funktionieren, obwohl Sie möglicherweise Ihre WP-Installation so gestalten müssen, dass sie wie Sie aussieht Nächste Anwendung für ein nahtloses Erlebnis.

Schlussfolgerung

Im nächsten Tutorial vervollständigen wir unsere Bewerbung mit der einzelnen Produktseite und den Warenkorboptionen. Hoffentlich hat dieses Tutorial Sie unterhalten und ich sehe Sie im nächsten.

Weiter zum letzten Kapitel



Leave a Reply

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