mirror of
https://github.com/vercel/commerce.git
synced 2025-07-25 11:11:24 +00:00
add customer account api
This commit is contained in:
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>
|
||||
);
|
||||
}
|
46
components/account/account-profile.tsx
Normal file
46
components/account/account-profile.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
38
components/account/actions.ts
Normal file
38
components/account/actions.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
'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';
|
||||
|
||||
export async function doLogout() {
|
||||
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
|
||||
}
|
51
components/auth/login-form.tsx
Normal file
51
components/auth/login-form.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
import clsx from 'clsx';
|
||||
import { doLogin } from './actions';
|
||||
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';
|
||||
//const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
|
||||
|
||||
return (
|
||||
<>
|
||||
{props?.message && <div className="my-5">{props?.message}</div>}
|
||||
<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 ? (
|
||||
<>
|
||||
<span>Logging In...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
8
components/auth/login-message.tsx
Normal file
8
components/auth/login-message.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export function LoginMessage() {
|
||||
return (
|
||||
<div>
|
||||
<h2>Error</h2>
|
||||
<span>Your session has expired. Please log in again.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
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 />;
|
||||
}
|
30
components/auth/user-icon.tsx
Normal file
30
components/auth/user-icon.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
import { UserIcon as User2Icon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
|
||||
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
|
||||
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 />;
|
||||
}
|
120
components/button.tsx
Normal file
120
components/button.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { tv, type VariantProps } from 'tailwind-variants';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const buttonVariants = tv({
|
||||
base: [
|
||||
// base
|
||||
'relative inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-center text-sm font-medium transition-all duration-100 ease-in-out',
|
||||
// disabled
|
||||
'disabled:pointer-events-none disabled:shadow-none'
|
||||
],
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'text-xs px-2.5 py-1.5',
|
||||
md: 'text-sm px-3 py-2',
|
||||
lg: 'text-base px-4 py-2.5'
|
||||
},
|
||||
variant: {
|
||||
primary: [
|
||||
// border
|
||||
'border-transparent',
|
||||
// text color
|
||||
'text-white',
|
||||
// background color
|
||||
'bg-tremor-brand',
|
||||
// hover color
|
||||
'hover:bg-tremor-brand-emphasis',
|
||||
// disabled
|
||||
'disabled:bg-gray-100',
|
||||
'disabled:bg-tremor-brand-muted'
|
||||
],
|
||||
secondary: [
|
||||
// border
|
||||
'border-gray-300',
|
||||
// text color
|
||||
'text-gray-900',
|
||||
// background color
|
||||
' bg-white',
|
||||
//hover color
|
||||
'hover:bg-gray-50',
|
||||
// disabled
|
||||
'disabled:text-gray-400'
|
||||
],
|
||||
text: [
|
||||
// border
|
||||
'border-transparent',
|
||||
// text color
|
||||
'text-tremor-brand',
|
||||
// background color
|
||||
'bg-transparent',
|
||||
// hover color
|
||||
'disabled:text-gray-400'
|
||||
],
|
||||
destructive: [
|
||||
// text color
|
||||
'text-white',
|
||||
// border
|
||||
'border-transparent',
|
||||
// background color
|
||||
'bg-red-600',
|
||||
// hover color
|
||||
'hover:bg-red-700',
|
||||
// disabled
|
||||
'disabled:bg-red-300 disabled:text-white'
|
||||
]
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
});
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ComponentPropsWithoutRef<'button'>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
isLoading?: boolean;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
asChild,
|
||||
isLoading = false,
|
||||
loadingText,
|
||||
className,
|
||||
disabled,
|
||||
variant,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps,
|
||||
forwardedRef
|
||||
) => {
|
||||
const Component = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Component
|
||||
ref={forwardedRef}
|
||||
className={clsx(buttonVariants({ variant }), className)}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="pointer-events-none flex shrink-0 items-center justify-center gap-1.5">
|
||||
<span className="sr-only">{loadingText ? loadingText : 'Loading'}</span>
|
||||
{loadingText ? loadingText : children}
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants, type ButtonProps };
|
@@ -9,6 +9,7 @@ type ManufacturersGridProps = {
|
||||
};
|
||||
|
||||
const ManufacturersGrid = ({ manufacturers, variant = 'home' }: ManufacturersGridProps) => {
|
||||
console.log('manufacturers', manufacturers);
|
||||
const popularManufacturers = manufacturers.filter(
|
||||
(manufacturer) => manufacturer.is_popular === 'true'
|
||||
);
|
||||
|
@@ -5,12 +5,43 @@ import { ArrowRightIcon } from '@heroicons/react/16/solid';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import { Fragment } from 'react';
|
||||
import OpenProfile from './open-profile';
|
||||
import { useFormState, useFormStatus } from 'react-dom';
|
||||
import { doLogin } from 'components/auth/actions';
|
||||
import { Button } from 'components/button';
|
||||
|
||||
type ProfilePopoverProps = {
|
||||
menu: Menu[];
|
||||
};
|
||||
|
||||
function SubmitButton(props: any) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<>
|
||||
{props?.message && <div className="my-5">{props?.message}</div>}
|
||||
<Button
|
||||
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
|
||||
if (pending) e.preventDefault();
|
||||
}}
|
||||
aria-label="Log in"
|
||||
aria-disabled={pending}
|
||||
>
|
||||
{pending ? (
|
||||
<>
|
||||
<span>Logging In...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Log-In</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const ProfilePopover = ({ menu }: ProfilePopoverProps) => {
|
||||
const [message, formAction] = useFormState(doLogin, null);
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<PopoverButton aria-label="Open Profile Menu" className="flex">
|
||||
@@ -25,15 +56,15 @@ const ProfilePopover = ({ menu }: ProfilePopoverProps) => {
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute -right-10 z-10 mt-2 w-72 max-w-lg px-4 sm:px-0 lg:right-0">
|
||||
<PopoverPanel className="absolute -right-10 z-50 mt-2 w-72 max-w-lg px-4 sm:px-0 lg:right-0">
|
||||
<div className="flex flex-col gap-2 overflow-hidden rounded-md bg-white px-4 py-3 text-black shadow-xl ring-1 ring-black/5">
|
||||
<span className="text-sm font-medium">My Account</span>
|
||||
<a
|
||||
href="#"
|
||||
className="mt-1 rounded-sm bg-primary p-2 text-center text-xs font-medium uppercase text-white hover:bg-secondary "
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
<form action={formAction}>
|
||||
<SubmitButton message={message} />
|
||||
<p aria-live="polite" className="sr-only" role="status">
|
||||
{message}
|
||||
</p>
|
||||
</form>
|
||||
{menu.length ? (
|
||||
<ul className="mt-2 flex w-full flex-col divide-y text-sm">
|
||||
{menu.map((menuItem) => (
|
||||
|
Reference in New Issue
Block a user