mirror of
https://github.com/vercel/commerce.git
synced 2025-07-25 11:11:24 +00:00
Merge pull request #4 from Car-Part-Planet/CPP-153
Add Customer Authentication and Order Details
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>
|
||||
);
|
||||
}
|
34
components/account/actions.ts
Normal file
34
components/account/actions.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
'use server';
|
||||
|
||||
import { CUSTOMER_API_URL, ORIGIN_URL, removeAllCookiesServerAction } from 'lib/shopify/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function doLogout() {
|
||||
const origin = ORIGIN_URL;
|
||||
const customerAccountApiUrl = CUSTOMER_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();
|
||||
} 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
|
||||
}
|
46
components/account/orders/order-summary-mobile.tsx
Normal file
46
components/account/orders/order-summary-mobile.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
import { ShoppingCartIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
|
||||
import Text from 'components/ui/text';
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel, Transition } from '@headlessui/react';
|
||||
import Divider from 'components/divider';
|
||||
import Price from 'components/price';
|
||||
import { Order } from 'lib/shopify/types';
|
||||
import OrderSummary from './order-summary';
|
||||
|
||||
export default function OrderSummaryMobile({ order }: { order: Order }) {
|
||||
return (
|
||||
<div className="block lg:hidden">
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<DisclosureButton className="flex w-full justify-between p-6">
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<ShoppingCartIcon className="w-6" />
|
||||
<Text>{open ? 'Hide order summary' : 'Show order summary'}</Text>
|
||||
{open ? <ChevronUpIcon className="w-4" /> : <ChevronDownIcon className="w-4" />}
|
||||
</div>
|
||||
<Price
|
||||
amount={order.totalPrice!.amount}
|
||||
currencyCode={order.totalPrice!.currencyCode}
|
||||
/>
|
||||
</DisclosureButton>
|
||||
|
||||
<Transition
|
||||
enter="duration-200 ease-out"
|
||||
enterFrom="opacity-0 -translate-y-6"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="duration-300 ease-out"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 -translate-y-6"
|
||||
>
|
||||
<DisclosurePanel className="origin-top p-6 text-gray-500 transition">
|
||||
<OrderSummary order={order} />
|
||||
</DisclosurePanel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
<Divider hasSpacing={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
73
components/account/orders/order-summary.tsx
Normal file
73
components/account/orders/order-summary.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import Image from 'next/image';
|
||||
import Price from 'components/price';
|
||||
import Badge from 'components/ui/badge';
|
||||
import Heading from 'components/ui/heading';
|
||||
import Label from 'components/ui/label';
|
||||
import Text from 'components/ui/text';
|
||||
import { Order } from 'lib/shopify/types';
|
||||
|
||||
export default function OrderSummary({ order }: { order: Order }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Heading size="sm">Order Summary</Heading>
|
||||
<div className="flex flex-col gap-6">
|
||||
{order.lineItems.map((lineItem, index) => (
|
||||
<div key={index} className="flex items-center gap-4">
|
||||
<Badge content={lineItem.quantity!}>
|
||||
<Image
|
||||
src={lineItem.image.url}
|
||||
alt={lineItem.image.altText}
|
||||
width={lineItem.image.width}
|
||||
height={lineItem.image.height}
|
||||
className="rounded border"
|
||||
/>
|
||||
</Badge>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text>{lineItem.title}</Text>
|
||||
<Label>{lineItem.sku}</Label>
|
||||
</div>
|
||||
<Price
|
||||
className="text-sm"
|
||||
amount={lineItem.price!.amount}
|
||||
currencyCode={lineItem.price!.currencyCode}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text>Subtotal</Text>
|
||||
<Price
|
||||
className="text-sm font-semibold"
|
||||
amount={order.totalPrice!.amount}
|
||||
currencyCode={order.totalPrice!.currencyCode}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Text>Shipping</Text>
|
||||
{order.shippingMethod?.price.amount !== '0.0' ? (
|
||||
<Price
|
||||
className="text-sm font-semibold"
|
||||
amount={order.shippingMethod!.price.amount}
|
||||
currencyCode={order.shippingMethod!.price.currencyCode}
|
||||
/>
|
||||
) : (
|
||||
<Text className="font-semibold">Free</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Heading as="span" size="sm">
|
||||
Total
|
||||
</Heading>
|
||||
<Price
|
||||
className="font-semibold"
|
||||
amount={order.totalPrice!.amount}
|
||||
currencyCode={order.totalPrice!.currencyCode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
58
components/auth/actions.ts
Normal file
58
components/auth/actions.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { cookies } from 'next/headers';
|
||||
import {
|
||||
generateCodeVerifier,
|
||||
generateCodeChallenge,
|
||||
generateRandomString,
|
||||
CUSTOMER_API_CLIENT_ID,
|
||||
ORIGIN_URL,
|
||||
CUSTOMER_API_URL
|
||||
} from 'lib/shopify/auth';
|
||||
|
||||
export async function doLogin(_: any) {
|
||||
const customerAccountApiUrl = CUSTOMER_API_URL;
|
||||
const clientId = CUSTOMER_API_CLIENT_ID;
|
||||
const origin = ORIGIN_URL;
|
||||
const loginUrl = new URL(`${customerAccountApiUrl}/auth/oauth/authorize`);
|
||||
|
||||
try {
|
||||
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 challenge = await generateCodeChallenge(verifier);
|
||||
cookies().set('shop_verifier', verifier as string, {});
|
||||
const state = await generateRandomString();
|
||||
const nonce = await generateRandomString();
|
||||
cookies().set('shop_state', state as string, {});
|
||||
cookies().set('shop_nonce', nonce as string, {});
|
||||
|
||||
loginUrl.searchParams.append('state', state);
|
||||
loginUrl.searchParams.append('nonce', nonce);
|
||||
loginUrl.searchParams.append('code_challenge', challenge);
|
||||
loginUrl.searchParams.append('code_challenge_method', 'S256');
|
||||
} catch (e) {
|
||||
console.log('Error', e);
|
||||
return 'Error logging in. Please try again';
|
||||
}
|
||||
|
||||
redirect(`${loginUrl}`); // Navigate to the new post page
|
||||
}
|
||||
|
||||
export async function isLoggedIn() {
|
||||
const customerToken = cookies().get('shop_customer_token')?.value;
|
||||
const refreshToken = cookies().get('shop_refresh_token')?.value;
|
||||
|
||||
if (!customerToken && !refreshToken) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
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>
|
||||
);
|
||||
}
|
16
components/auth/login.tsx
Normal file
16
components/auth/login.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
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 />;
|
||||
}
|
@@ -1,5 +1,4 @@
|
||||
import { getCollection, getMenu, getProduct } from 'lib/shopify';
|
||||
import { findParentCollection } from 'lib/utils';
|
||||
import { Fragment } from 'react';
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from './breadcrumb-list';
|
||||
import { findParentCollection } from 'lib/utils';
|
||||
|
||||
type BreadcrumbProps = {
|
||||
type: 'product' | 'collection';
|
||||
|
137
components/button.tsx
Normal file
137
components/button.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { Button as ButtonBase, ButtonProps as ButtonBaseProps } from '@headlessui/react';
|
||||
import { tv, type VariantProps } from 'tailwind-variants';
|
||||
import clsx from 'clsx';
|
||||
import Spinner from './spinner';
|
||||
|
||||
const buttonVariants = tv({
|
||||
slots: {
|
||||
root: [
|
||||
// base
|
||||
'relative inline-flex items-center justify-center rounded-md',
|
||||
// text
|
||||
'text-center font-medium',
|
||||
// transition
|
||||
'transition-all duration-100 ease-in-out',
|
||||
// disabled
|
||||
'disabled:pointer-events-none disabled:shadow-none'
|
||||
],
|
||||
loading: 'pointer-events-none flex shrink-0 items-center justify-center gap-1.5'
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
sm: {
|
||||
root: 'text-xs px-2.5 py-1.5'
|
||||
},
|
||||
md: {
|
||||
root: 'text-sm px-3 py-2'
|
||||
},
|
||||
lg: {
|
||||
root: 'text-base px-4 py-2.5'
|
||||
}
|
||||
},
|
||||
color: {
|
||||
primary: {},
|
||||
content: {}
|
||||
},
|
||||
variant: {
|
||||
solid: {},
|
||||
outlined: {
|
||||
root: 'border bg-white'
|
||||
},
|
||||
text: {}
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
color: 'primary',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
root: [
|
||||
// border
|
||||
'border-transparent',
|
||||
// text color
|
||||
'text-white',
|
||||
// background color
|
||||
'bg-primary',
|
||||
// hover color
|
||||
'hover:bg-primary-empahsis',
|
||||
// disabled
|
||||
'disabled:bg-primary-muted',
|
||||
'pressed:bg-primary-emphasis/80'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
color: 'primary',
|
||||
variant: 'outlined',
|
||||
class: {
|
||||
root: [
|
||||
// border
|
||||
'border-primary',
|
||||
// text color
|
||||
'text-primary',
|
||||
// background color
|
||||
'bg-white',
|
||||
// hover color
|
||||
'hover:bg-primary/10',
|
||||
// disabled
|
||||
'disabled:border-primary-muted disabled:text-primary-muted'
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'solid',
|
||||
color: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
});
|
||||
|
||||
interface ButtonProps extends Omit<ButtonBaseProps, 'color'>, VariantProps<typeof buttonVariants> {
|
||||
isLoading?: boolean;
|
||||
loadingText?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
isLoading,
|
||||
loadingText = 'Loading',
|
||||
size,
|
||||
variant,
|
||||
...props
|
||||
}: ButtonProps,
|
||||
forwardedRef
|
||||
) => {
|
||||
const { loading, root } = buttonVariants({ variant, size });
|
||||
return (
|
||||
<ButtonBase
|
||||
ref={forwardedRef}
|
||||
className={clsx(root(), className)}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className={loading()}>
|
||||
<Spinner />
|
||||
<span className="sr-only">{loadingText}</span>
|
||||
<span>{loadingText}</span>
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</ButtonBase>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants, type ButtonProps };
|
@@ -65,8 +65,10 @@ const LineItem = ({ item, closeCart }: LineItemProps) => {
|
||||
className="h-full w-full object-cover"
|
||||
width={64}
|
||||
height={64}
|
||||
alt={item.merchandise.product.featuredImage.altText || item.merchandise.product.title}
|
||||
src={item.merchandise.product.featuredImage.url}
|
||||
alt={
|
||||
item.merchandise.product?.featuredImage?.altText || item.merchandise.product.title
|
||||
}
|
||||
src={item.merchandise.product?.featuredImage?.url}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@@ -14,12 +14,15 @@ import CloseCart from './close-cart';
|
||||
import LineItem from './line-item';
|
||||
import OpenCart from './open-cart';
|
||||
import VehicleDetails, { VehicleFormSchema, vehicleFormSchema } from './vehicle-details';
|
||||
import useAuth from 'hooks/use-auth';
|
||||
|
||||
export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const quantityRef = useRef(cart?.totalQuantity);
|
||||
const openCart = () => setIsOpen(true);
|
||||
const closeCart = () => setIsOpen(false);
|
||||
const [checkoutUrl, setCheckoutUrl] = useState<string | undefined>(cart?.checkoutUrl);
|
||||
const { control, handleSubmit } = useForm<VehicleFormSchema>({
|
||||
resolver: zodResolver(vehicleFormSchema),
|
||||
defaultValues: {
|
||||
@@ -45,6 +48,20 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||
}
|
||||
}, [isOpen, cart?.totalQuantity, quantityRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cart) return;
|
||||
if (isAuthenticated) {
|
||||
const newCheckoutUrl = new URL(cart.checkoutUrl);
|
||||
newCheckoutUrl.searchParams.append('logged_in', 'true');
|
||||
|
||||
return setCheckoutUrl(newCheckoutUrl.toString());
|
||||
}
|
||||
|
||||
if (checkoutUrl !== cart.checkoutUrl) {
|
||||
setCheckoutUrl(cart.checkoutUrl);
|
||||
}
|
||||
}, [cart, isAuthenticated, checkoutUrl]);
|
||||
|
||||
const onSubmit = async (data: VehicleFormSchema) => {
|
||||
if (!cart) return;
|
||||
|
||||
@@ -136,7 +153,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<a href={cart.checkoutUrl} ref={linkRef} className="hidden">
|
||||
<a href={checkoutUrl} ref={linkRef} className="hidden">
|
||||
Proceed to Checkout
|
||||
</a>
|
||||
<button
|
||||
|
54
components/divider.tsx
Normal file
54
components/divider.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
const divider = tv({
|
||||
slots: {
|
||||
root: '',
|
||||
element: 'bg-gray-200'
|
||||
},
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal: {
|
||||
root: 'w-full mx-auto flex justify-between items-center text-tremor-default text-tremor-content',
|
||||
element: 'w-full h-[1px] '
|
||||
},
|
||||
vertical: {
|
||||
root: 'flex justify-between items-stretch text-tremor-default text-tremor-content',
|
||||
element: 'h-full w-[1px]'
|
||||
}
|
||||
},
|
||||
hasSpacing: {
|
||||
true: {},
|
||||
false: {}
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
orientation: 'horizontal',
|
||||
hasSpacing: true,
|
||||
class: {
|
||||
root: 'my-6'
|
||||
}
|
||||
},
|
||||
{
|
||||
orientation: 'vertical',
|
||||
hasSpacing: true,
|
||||
class: {
|
||||
root: 'mx-6'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
type DividerProps = {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
hasSpacing?: boolean;
|
||||
};
|
||||
export default function Divider({ orientation = 'horizontal', hasSpacing = true }: DividerProps) {
|
||||
const { root, element } = divider({ orientation, hasSpacing });
|
||||
|
||||
return (
|
||||
<div className={root()}>
|
||||
<span className={element()} />
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -3,12 +3,14 @@ import clsx from 'clsx';
|
||||
const Price = ({
|
||||
amount,
|
||||
className,
|
||||
as,
|
||||
currencyCode = 'USD',
|
||||
currencyCodeClassName,
|
||||
showCurrency = false,
|
||||
prefix
|
||||
}: {
|
||||
amount: string;
|
||||
as?: 'p' | 'span';
|
||||
className?: string;
|
||||
currencyCode: string;
|
||||
currencyCodeClassName?: string;
|
||||
@@ -23,9 +25,10 @@ const Price = ({
|
||||
return <p className={className}>Included</p>;
|
||||
}
|
||||
|
||||
const Component = as || 'p';
|
||||
// Otherwise, format and display the price
|
||||
return (
|
||||
<p suppressHydrationWarning={true} className={className}>
|
||||
<Component suppressHydrationWarning={true} className={className}>
|
||||
{prefix}
|
||||
{new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
@@ -35,7 +38,7 @@ const Price = ({
|
||||
{showCurrency && (
|
||||
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{currencyCode}</span>
|
||||
)}
|
||||
</p>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -1,16 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
|
||||
import { CloseButton, Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
|
||||
import { ArrowRightIcon } from '@heroicons/react/16/solid';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import { Fragment } from 'react';
|
||||
import { Fragment, useState } from 'react';
|
||||
import OpenProfile from './open-profile';
|
||||
import { useFormState, useFormStatus } from 'react-dom';
|
||||
import { doLogin } from 'components/auth/actions';
|
||||
import { Button } from 'components/button';
|
||||
import useAuth from 'hooks/use-auth';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
type ProfilePopoverProps = {
|
||||
menu: Menu[];
|
||||
};
|
||||
|
||||
function SubmitButton(props: any) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<>
|
||||
{props?.message && <div className="my-5">{props?.message}</div>}
|
||||
<Button
|
||||
type="submit"
|
||||
aria-label="Log in"
|
||||
aria-disabled={pending}
|
||||
disabled={pending}
|
||||
isLoading={pending}
|
||||
loadingText="Signing In..."
|
||||
className="w-full"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const ProfilePopover = ({ menu }: ProfilePopoverProps) => {
|
||||
const [message, action] = useFormState(doLogin, null);
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<PopoverButton aria-label="Open Profile Menu" className="flex">
|
||||
@@ -25,29 +55,52 @@ 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>
|
||||
{!isAuthenticated && !loading && (
|
||||
<form action={action}>
|
||||
<SubmitButton message={message} />
|
||||
</form>
|
||||
)}
|
||||
{menu.length ? (
|
||||
<ul className="mt-2 flex w-full flex-col divide-y text-sm">
|
||||
<ul className="flex w-full flex-col divide-y text-sm">
|
||||
{isAuthenticated && (
|
||||
<li className="cursor-pointer py-2 hover:underline">
|
||||
<CloseButton
|
||||
as={Link}
|
||||
className="flex w-full flex-row items-center justify-between"
|
||||
href="/account"
|
||||
>
|
||||
My Orders <ArrowRightIcon className="h-3" />
|
||||
</CloseButton>
|
||||
</li>
|
||||
)}
|
||||
{menu.map((menuItem) => (
|
||||
<li className="cursor-pointer py-2 hover:underline" key={menuItem.title}>
|
||||
<a
|
||||
<CloseButton
|
||||
as={Link}
|
||||
className="flex w-full flex-row items-center justify-between"
|
||||
href={menuItem.path}
|
||||
>
|
||||
{menuItem.title} <ArrowRightIcon className="h-3" />
|
||||
</a>
|
||||
</CloseButton>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{isAuthenticated && !loading && (
|
||||
<Button
|
||||
disabled={loggingOut}
|
||||
onClick={() => {
|
||||
setLoggingOut(true);
|
||||
router.push('/logout');
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
{loggingOut ? 'Logging Out...' : 'Log Out'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
|
19
components/spinner.tsx
Normal file
19
components/spinner.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
export default function Spinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={clsx('flex-1 animate-spin stroke-current stroke-[3]', className)}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
|
||||
className="stroke-current opacity-25"
|
||||
/>
|
||||
<path d="M12 2C6.47715 2 2 6.47715 2 12C2 14.7255 3.09032 17.1962 4.85857 19" />
|
||||
</svg>
|
||||
);
|
||||
}
|
35
components/ui/badge.tsx
Normal file
35
components/ui/badge.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
import { VariantProps, tv } from 'tailwind-variants';
|
||||
|
||||
const badgeStyles = tv({
|
||||
base: [
|
||||
'absolute -right-2 -top-2 h-5 w-5',
|
||||
'flex items-center justify-center rounded-full text-xs font-semibold'
|
||||
],
|
||||
variants: {
|
||||
color: {
|
||||
primary: 'bg-primary text-white',
|
||||
secondary: 'bg-secondary text-white',
|
||||
content: 'bg-content text-white'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
color: 'content'
|
||||
}
|
||||
});
|
||||
|
||||
interface BadgeProps extends VariantProps<typeof badgeStyles> {
|
||||
content: string | number;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Badge({ className, color, children, content }: BadgeProps) {
|
||||
return (
|
||||
<span className="relative flex-none">
|
||||
{children}
|
||||
<span className={badgeStyles({ color, className })}>{content}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
44
components/ui/card.tsx
Normal file
44
components/ui/card.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
|
||||
import { VariantProps, tv } from 'tailwind-variants';
|
||||
|
||||
const cardStyles = tv({
|
||||
base: 'rounded p-6 text-left w-full',
|
||||
variants: {
|
||||
outlined: {
|
||||
true: 'border bg-white',
|
||||
false: ''
|
||||
},
|
||||
elevated: {
|
||||
true: 'shadow-lg bg-white',
|
||||
false: ''
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
outlined: true,
|
||||
elevated: false
|
||||
}
|
||||
});
|
||||
|
||||
interface CardProps extends React.ComponentPropsWithoutRef<'div'>, VariantProps<typeof cardStyles> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, asChild, outlined, elevated, ...props }, forwardedRef) => {
|
||||
const Component = asChild ? Slot : 'div';
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={forwardedRef}
|
||||
className={cardStyles({ outlined, elevated, className })}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
export { Card, type CardProps };
|
26
components/ui/heading.tsx
Normal file
26
components/ui/heading.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { VariantProps, tv } from 'tailwind-variants';
|
||||
|
||||
const heading = tv({
|
||||
base: [''],
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'text-heading-sm',
|
||||
md: 'text-heading-md',
|
||||
lg: 'text-heading-lg'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md'
|
||||
}
|
||||
});
|
||||
|
||||
interface HeadingProps extends VariantProps<typeof heading> {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'p';
|
||||
}
|
||||
|
||||
export default function Heading({ children, className, size, as }: HeadingProps) {
|
||||
const Component = as || 'h2';
|
||||
return <Component className={heading({ size, className })}>{children}</Component>;
|
||||
}
|
32
components/ui/label.tsx
Normal file
32
components/ui/label.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { VariantProps, tv } from 'tailwind-variants';
|
||||
|
||||
const label = tv(
|
||||
{
|
||||
base: 'text-content',
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'text-label-sm',
|
||||
md: 'text-label-md',
|
||||
lg: 'text-label-lg'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md'
|
||||
}
|
||||
},
|
||||
{
|
||||
twMerge: false
|
||||
}
|
||||
);
|
||||
|
||||
interface LabelProps extends VariantProps<typeof label> {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
as?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'p';
|
||||
}
|
||||
|
||||
export default function Label({ children, className, size, as }: LabelProps) {
|
||||
const Component = as || 'span';
|
||||
|
||||
return <Component className={label({ size, className })}>{children}</Component>;
|
||||
}
|
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { VariantProps, tv } from 'tailwind-variants';
|
||||
|
||||
const skeleton = tv({
|
||||
base: 'animate-pulse rounded bg-gray-100 w-full h-6'
|
||||
});
|
||||
|
||||
interface SkeletonProps extends VariantProps<typeof skeleton> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Skeleton({ className }: SkeletonProps) {
|
||||
return <div className={skeleton({ className })} />;
|
||||
}
|
32
components/ui/text.tsx
Normal file
32
components/ui/text.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { VariantProps, tv } from 'tailwind-variants';
|
||||
|
||||
const text = tv(
|
||||
{
|
||||
base: '',
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-md'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md'
|
||||
}
|
||||
},
|
||||
{
|
||||
twMerge: false
|
||||
}
|
||||
);
|
||||
|
||||
interface TextProps extends VariantProps<typeof text> {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
as?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'p';
|
||||
}
|
||||
|
||||
export default function Text({ children, className, size, as }: TextProps) {
|
||||
const Component = as || 'p';
|
||||
|
||||
return <Component className={text({ size, className })}>{children}</Component>;
|
||||
}
|
Reference in New Issue
Block a user