Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Shopify Customer Account API #1305

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ SITE_NAME="Next.js Commerce"
SHOPIFY_REVALIDATION_SECRET=""
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
# for customer account api
# SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID=""
# SHOPIFY_CUSTOMER_ACCOUNT_API_URL=""
# SHOPIFY_CUSTOMER_API_VERSION=""
# SHOPIFY_ORIGIN_URL=""
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ yarn-error.log*

# vercel
.vercel
.local
.upm
.replit
.replit.nix

# typescript
*.tsbuildinfo
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,20 @@ Your app should now be running on [localhost:3000](http://localhost:3000/).
## Vercel, Next.js Commerce, and Shopify Integration Guide

You can use this comprehensive [integration guide](http://vercel.com/docs/integrations/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel.

## Shopify Customer Accounts

This fork is designed to provide a basic implementation of [Shopify's new Customer Accounts API](https://shopify.dev/docs/api/customer), which will allow a customer to login into their Next.js Shopify Website to update information and view orders, see Shopify's [launch announcement](https://www.shopify.com/partners/blog/introducing-customer-account-api-for-headless-stores) to learn more.

It is based on Shopify's Hydrogen implementation and uses the concepts of Next.js middleware and server actions to implement the Shopify Customer Accounts API Integration. All the new code for the Customer Accounts API is included in: lib/shopify/customer folder, middleware.ts, and components/account

The following files were changed in the core commerce repo:

- components/cart/index.tsx (to add logged_in true for checkout for Customer Account)
- components/layout/navbar/index.tsx (to add a login button to menu)
- components/cart/modal.tsx (had to fix a TS error here)
- lib/utils.ts (add required ENV)
- README
- env.example

For instructions on how to get everything working properly, please see [Setup for using Shopify Customer Account API](https://www.dalicommerce.com/docs/nextjs/create-a-headless-shopify-nextjs#iii-setup-for-using-shopify-customer-account-api-log-in-and-account-section)
25 changes: 25 additions & 0 deletions app/(auth)/authorize/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { headers } from 'next/headers';
export const runtime = 'edge';
export default async function AuthorizationPage() {
const headersList = headers();
const access = headersList.get('x-shop-access');
if (!access) {
console.log('ERROR: No access header');
throw new Error('No access header');
}
console.log('Authorize Access code header:', access);
if (access === 'denied') {
console.log('Access Denied for Auth');
throw new Error('No access allowed');
}

return (
<>
<div className="mx-auto max-w-screen-2xl px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row lg:gap-8">
<div className="h-full w-full">Loading...</div>
</div>
</div>
</>
);
}
16 changes: 16 additions & 0 deletions app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { LoginMessage } from 'components/auth/login-message';
export const runtime = 'edge'; //this needs to be here on thie page. I don't know why

export default async function LoginPage() {
return (
<>
<div className="mx-auto max-w-screen-2xl px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row lg:gap-8">
<div className="h-full w-full">
<LoginMessage />
</div>
</div>
</div>
</>
);
}
13 changes: 13 additions & 0 deletions app/(auth)/logout/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const runtime = 'edge';

export default async function LogoutPage() {
return (
<>
<div className="mx-auto max-w-screen-2xl px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row lg:gap-8">
<div className="h-full w-full">Loading...</div>
</div>
</div>
</>
);
}
85 changes: 85 additions & 0 deletions app/account/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { headers } from 'next/headers';
import { AccountProfile } from 'components/account/account-profile';
import { AccountOrdersHistory } from 'components/account/account-orders-history';
import { redirect } from 'next/navigation';
import { shopifyCustomerFetch } from 'lib/shopify/customer/index';
import { CUSTOMER_DETAILS_QUERY } from 'lib/shopify/customer/queries/customer';
import { CustomerDetailsData } from 'lib/shopify/customer/types';
import { TAGS } from 'lib/shopify/customer/constants';
export const runtime = 'edge';
export default async function AccountPage() {
const headersList = headers();
const access = headersList.get('x-shop-customer-token');
if (!access) {
console.log('ERROR: No access header account');
//I'm not sure what's better here. Throw error or just log out??
//redirect gets rid of call cookies
redirect('/logout');
//throw new Error("No access header")
}
//console.log("Authorize Access code header:", access)
if (access === 'denied') {
console.log('Access Denied for Auth account');
redirect('/logout');
//throw new Error("No access allowed")
}
const customerAccessToken = access;

//this is needed b/c of strange way server components handle redirects etc.
//see https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#redirecting
//can only redirect outside of try/catch!
let success = true;
let errorMessage;
let customerData;
let orders;

try {
const responseCustomerDetails = await shopifyCustomerFetch<CustomerDetailsData>({
customerToken: customerAccessToken,
cache: 'no-store',
query: CUSTOMER_DETAILS_QUERY,
tags: [TAGS.customer]
});
//console.log("userDetails", responseCustomerDetails)
const userDetails = responseCustomerDetails.body;
if (!userDetails) {
throw new Error('Error getting actual user data Account page.');
}
customerData = userDetails?.data?.customer;
orders = customerData?.orders?.edges;
//console.log ("Details",orders)
} catch (e) {
//they don't recognize this error in TS!
//@ts-ignore
errorMessage = e?.error?.toString() ?? 'Unknown Error';
console.log('error customer fetch account', e);
if (errorMessage !== 'unauthorized') {
throw new Error('Error getting actual user data Account page.');
} else {
console.log('Unauthorized access. Set to false and redirect');
success = false;
}
}
if (!success && errorMessage === 'unauthorized') redirect('/logout');
//revalidateTag('posts') // Update cached posts //FIX

return (
<>
<div className="mx-auto max-w-screen-2xl px-4">
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row lg:gap-8">
<div className="h-full w-full">
<div> Welcome: {customerData?.emailAddress.emailAddress}</div>
</div>
<div className="h-full w-full">
<div className="mt-5">
<AccountProfile />
</div>
</div>
<div className="h-full w-full">
<div className="mt-5">{orders && <AccountOrdersHistory orders={orders} />}</div>
</div>
</div>
</div>
</>
);
}
41 changes: 41 additions & 0 deletions components/account/account-orders-history.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use client';
type OrderCardsProps = {
orders: any;
};

export function AccountOrdersHistory({ orders }: { orders: any }) {
return (
<div className="mt-6">
<div className="grid w-full gap-4 p-4 py-6 md:gap-8 md:p-8 lg:p-12">
<h2 className="text-lead font-bold">Order History</h2>
{orders?.length ? <Orders orders={orders} /> : <EmptyOrders />}
</div>
</div>
);
}

function EmptyOrders() {
return (
<div>
<div className="mb-1">You haven&apos;t placed any orders yet.</div>
<div className="w-48">
<button
className="mt-2 w-full text-sm"
//variant="secondary"
>
Start Shopping
</button>
</div>
</div>
);
}

function Orders({ orders }: OrderCardsProps) {
return (
<ul className="false grid grid-flow-row grid-cols-1 gap-2 gap-y-6 sm:grid-cols-3 md:gap-4 lg:gap-6">
{orders.map((order: any) => (
<li key={order.node.id}>{order.node.number}</li>
))}
</ul>
);
}
46 changes: 46 additions & 0 deletions components/account/account-profile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use client';
import clsx from 'clsx';
import { ArrowRightIcon as LogOutIcon } from '@heroicons/react/24/outline';
import { doLogout } from './actions';
import LoadingDots from 'components/loading-dots';
import { useFormState, useFormStatus } from 'react-dom';

function SubmitButton(props: any) {
const { pending } = useFormStatus();
const buttonClasses =
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
return (
<>
<button
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label="Log Out"
aria-disabled={pending}
className={clsx(buttonClasses, {
'hover:opacity-90': true,
'cursor-not-allowed opacity-60 hover:opacity-60': pending
})}
>
<div className="absolute left-0 ml-4">
{pending ? <LoadingDots className="mb-3 bg-white" /> : <LogOutIcon className="h-5" />}
</div>
{pending ? 'Logging out...' : 'Log Out'}
</button>
{props?.message && <div className="my-5">{props?.message}</div>}
</>
);
}

export function AccountProfile() {
const [message, formAction] = useFormState(doLogout, null);

return (
<form action={formAction}>
<SubmitButton message={message} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}
40 changes: 40 additions & 0 deletions components/account/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use server';

import { TAGS } from 'lib/shopify/customer/constants';
import { removeAllCookiesServerAction } from 'lib/shopify/customer/auth-helpers';
import { redirect } from 'next/navigation';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { SHOPIFY_ORIGIN, SHOPIFY_CUSTOMER_ACCOUNT_API_URL } from 'lib/shopify/customer/constants';
//import {generateCodeVerifier,generateCodeChallenge,generateRandomString} from 'lib/shopify/customer/auth-utils'

export async function doLogout(prevState: any) {
//let logoutUrl = '/logout'
const origin = SHOPIFY_ORIGIN;
const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL;
let logoutUrl;
try {
const idToken = cookies().get('shop_id_token');
const idTokenValue = idToken?.value;
if (!idTokenValue) {
//you can also throw an error here with page and middleware
//throw new Error ("Error No Id Token")
//if there is no idToken, then sending to logout url will redirect shopify, so just
//redirect to login here and delete cookies (presumably they don't even exist)
logoutUrl = new URL(`${origin}/login`);
} else {
logoutUrl = new URL(
`${customerAccountApiUrl}/auth/logout?id_token_hint=${idTokenValue}&post_logout_redirect_uri=${origin}`
);
}
await removeAllCookiesServerAction();
revalidateTag(TAGS.customer);
} catch (e) {
console.log('Error', e);
//you can throw error here or return - return goes back to form b/c of state, throw will throw the error boundary
//throw new Error ("Error")
return 'Error logging out. Please try again';
}

redirect(`${logoutUrl}`); // Navigate to the new post page
}
69 changes: 69 additions & 0 deletions components/auth/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//See https://react.dev/reference/react-dom/hooks/useFormState
//https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#forms
'use server';

import { TAGS } from 'lib/shopify/customer/constants';
import { redirect } from 'next/navigation';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
//import { getOrigin } from 'lib/shopify/customer'
import {
generateCodeVerifier,
generateCodeChallenge,
generateRandomString
} from 'lib/shopify/customer/auth-utils';
import {
SHOPIFY_CUSTOMER_ACCOUNT_API_URL,
SHOPIFY_CLIENT_ID,
SHOPIFY_ORIGIN
} from 'lib/shopify/customer/constants';

export async function doLogin(prevState: any) {
const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL;
const clientId = SHOPIFY_CLIENT_ID;
const origin = SHOPIFY_ORIGIN;
const loginUrl = new URL(`${customerAccountApiUrl}/auth/oauth/authorize`);
//console.log ("previous", prevState)

try {
//await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
loginUrl.searchParams.set('client_id', clientId);
loginUrl.searchParams.append('response_type', 'code');
loginUrl.searchParams.append('redirect_uri', `${origin}/authorize`);
loginUrl.searchParams.set(
'scope',
'openid email https://api.customers.com/auth/customer.graphql'
);
const verifier = await generateCodeVerifier();
//const newVerifier = verifier.replace("+", '_').replace("-",'_').replace("/",'_').trim()
const challenge = await generateCodeChallenge(verifier);
cookies().set('shop_verifier', verifier as string, {
// @ts-ignore
//expires: auth?.expires, //not necessary here
});
const state = await generateRandomString();
const nonce = await generateRandomString();
cookies().set('shop_state', state as string, {
// @ts-ignore
//expires: auth?.expires, //not necessary here
});
cookies().set('shop_nonce', nonce as string, {
// @ts-ignore
//expires: auth?.expires, //not necessary here
});
loginUrl.searchParams.append('state', state);
loginUrl.searchParams.append('nonce', nonce);
loginUrl.searchParams.append('code_challenge', challenge);
loginUrl.searchParams.append('code_challenge_method', 'S256');
//console.log ("loginURL", loginUrl)
//throw new Error ("Error") //this is how you throw an error, if you want to. Then the catch will execute
} catch (e) {
console.log('Error', e);
//you can throw error here or return - return goes back to form b/c of state, throw will throw the error boundary
//throw new Error ("Error")
return 'Error logging in. Please try again';
}

revalidateTag(TAGS.customer);
redirect(`${loginUrl}`); // Navigate to the new post page
}
Loading