mirror of
https://github.com/vercel/commerce.git
synced 2025-05-18 23:46:58 +00:00
Additional Files
This commit is contained in:
parent
d4fd03d3d5
commit
3ed3e34758
28
README.md
28
README.md
@ -74,3 +74,31 @@ 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](), 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.
|
||||
|
25
app/(auth)/authorize/page.tsx
Normal file
25
app/(auth)/authorize/page.tsx
Normal 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
16
app/(auth)/login/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
13
app/(auth)/logout/page.tsx
Normal file
13
app/(auth)/logout/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
41
components/account/account-orders-history.tsx
Normal file
41
components/account/account-orders-history.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
53
components/account/account-profile.tsx
Normal file
53
components/account/account-profile.tsx
Normal 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>
|
||||
);
|
||||
}
|
40
components/account/actions.ts
Normal file
40
components/account/actions.ts
Normal 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
|
||||
}
|
69
components/auth/actions.ts
Normal file
69
components/auth/actions.ts
Normal 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
|
||||
}
|
62
components/auth/login-form.tsx
Normal file
62
components/auth/login-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
12
components/auth/login-message.tsx
Normal file
12
components/auth/login-message.tsx
Normal 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
19
components/auth/login.tsx
Normal 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 />;
|
||||
}
|
33
components/auth/user-icon.tsx
Normal file
33
components/auth/user-icon.tsx
Normal 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
50
middleware.ts
Normal 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']
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user