Additional Files

This commit is contained in:
Shlomo 2024-03-04 18:02:30 +00:00
parent d4fd03d3d5
commit 3ed3e34758
13 changed files with 461 additions and 0 deletions

View File

@ -74,3 +74,31 @@ Your app should now be running on [localhost:3000](http://localhost:3000/).
## Vercel, Next.js Commerce, and Shopify Integration Guide ## 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. 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](), which will allow a customer to login into their Next.js Shopify Website to update information and view orders. It uses the concepts of Next.js middleware and server actions to implemnt the Shopify Customer Accounts API Integration. All the new code for the Customer Accounts API is included in: lib/shopify/customer folder, middleware
The code for this repo is adapted for Next.js from code provided by Shopify
To Set This Up, please follow:
1. Get
2. Set up URLs
3.
to do: env settings file
https://shopify.dev/docs/custom-storefronts/building-with-the-customer-account-api/hydrogen
https://shopify.dev/docs/api/customer
There are several issues that make this code much more complex on NextJs:
1. Get can't origin in RSC - you can get this in middleware and pass down as props
https://blog.stackademic.com/how-next-js-middlewares-work-103cae315163
2. Can't set Cookies in RSC!
So to do this correctly, we have to use a fixed origin based on ENV variables, which makes testing difficult. Can only test in one environment.
And 2, we need to pass the tokens to a client component, which sets the cookies client side. We couldn't figure out any other way to get this to work.

View File

@ -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
app/(auth)/login/page.tsx Normal file
View File

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

View File

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

View File

@ -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>
);
}

View File

@ -0,0 +1,53 @@
'use client';
import clsx from 'clsx';
import { LogOutIcon, TriangleIcon } from '@heroicons/react/24/outline';
import { doLogout } from './actions';
import LoadingDots from 'components/loading-dots';
import { useFormState, useFormStatus } from 'react-dom';
import { Alert, AlertDescription, AlertTitle } from 'components/ui/alert';
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 && (
<Alert className="my-5" variant="destructive">
<TriangleIcon className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{props?.message}</AlertDescription>
</Alert>
)}
</>
);
}
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>
);
}

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1,62 @@
'use client';
import { ExclamationTriangleIcon, UserIcon as LogInIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { doLogin } from './actions';
import LoadingDots from 'components/loading-dots';
import { useFormState, useFormStatus } from 'react-dom';
import { Alert, AlertTitle } from 'components/ui/alert';
import { Button } from 'components/ui/button';
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';
//const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
return (
<>
{props?.message && (
<Alert className="my-5" variant="destructive">
<ExclamationTriangleIcon className="mr-2 h-4 w-4" />
<AlertTitle>{props?.message}</AlertTitle>
</Alert>
)}
<Button
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label="Log in"
aria-disabled={pending}
className={clsx(buttonClasses, {
'hover:opacity-90': true,
'cursor-not-allowed opacity-60 hover:opacity-60': pending
})}
>
{pending ? (
<>
<LoadingDots className="mr-2 h-4 w-4" />
<span>Logging In</span>
</>
) : (
<>
<LogInIcon className="hidden md:mr-2 md:flex md:h-4 md:w-4" />
<span>Log-In</span>
</>
)}
</Button>
</>
);
}
export function LoginShopify() {
const [message, formAction] = useFormState(doLogin, null);
return (
<form action={formAction}>
<SubmitButton message={message} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}

View File

@ -0,0 +1,12 @@
import { TriangleIcon } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from 'components/ui/alert';
export function LoginMessage() {
return (
<Alert variant="destructive">
<TriangleIcon className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Your session has expired. Please log in again.</AlertDescription>
</Alert>
);
}

19
components/auth/login.tsx Normal file
View File

@ -0,0 +1,19 @@
import { cookies } from 'next/headers';
import { LoginShopify } from 'components/auth/login-form';
import { UserIcon } from 'components/auth/user-icon';
export default async function Login() {
const customerToken = cookies().get('shop_customer_token')?.value;
const refreshToken = cookies().get('shop_refresh_token')?.value;
let isLoggedIn;
//obviously just checking for the cookies without verifying the cookie itself is not ideal. However, the cookie is validated on the
//account page, so a "fake" cookie does nothing, except show the UI and then it would be deleted when clicking on account
//so for now, just checking the cookie for the UI is sufficient. Alternatively, we can do a query here, or a custom JWT
if (!customerToken && !refreshToken) {
isLoggedIn = false;
} else {
isLoggedIn = true;
}
console.log('LoggedIn', isLoggedIn);
return isLoggedIn ? <UserIcon /> : <LoginShopify />;
}

View File

@ -0,0 +1,33 @@
'use client';
import { User2Icon } from 'lucide-react';
import clsx from 'clsx';
import { Button } from 'components/ui/button';
function UserButton(props: any) {
const buttonClasses =
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
//const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
return (
<>
<Button
asChild
variant="link"
aria-label="My Profile"
className={clsx(buttonClasses, {
'hover:opacity-90': true
})}
>
{/*Purposesly a href here and NOT Link component b/c of router caching*/}
<a href="/account">
<User2Icon className="mr-2 h-4 w-4" />
<span>Profile</span>
</a>
</Button>
</>
);
}
export function UserIcon() {
return <UserButton />;
}

50
middleware.ts Normal file
View File

@ -0,0 +1,50 @@
import type { NextRequest } from 'next/server';
import { isLoggedIn, getOrigin, authorizeFn, logoutFn } from 'lib/shopify/customer';
// This function can be marked `async` if using `await` inside
export async function middleware(request: NextRequest) {
/****
Authorize Middleware to get access tokens
*****/
if (request.nextUrl.pathname.startsWith('/authorize')) {
console.log('Running Initial Authorization Middleware');
const origin = getOrigin(request);
//console.log ("origin", origin)
return await authorizeFn(request, origin);
}
/****
END OF Authorize Middleware to get access tokens
*****/
/****
LOGOUT -
*****/
if (request.nextUrl.pathname.startsWith('/logout')) {
console.log('Running Logout middleware');
const origin = getOrigin(request);
return await logoutFn(request, origin);
}
/****
END OF LOGOUT
*****/
/****
Account
*****/
if (request.nextUrl.pathname.startsWith('/account')) {
console.log('Running Account middleware');
//const newHeaders = new Headers(request.headers)
const origin = getOrigin(request);
//console.log ("origin", origin)
//just cleaner to return everything in this one function and not have to pass back shit back and forth with booleans
return await isLoggedIn(request, origin);
}
/****
END OF Account
*****/
}
export const config = {
matcher: ['/authorize', '/logout', '/account']
};