add order details page

This commit is contained in:
tedraykov 2024-06-20 13:23:02 +03:00
parent 3694fef9a6
commit 8749b8aaec
52 changed files with 2000 additions and 724 deletions

View File

@ -0,0 +1,328 @@
import { CheckCircleIcon, TruckIcon } from '@heroicons/react/24/outline';
import Image from 'next/image';
import { Button } from 'components/button';
import { Card } from 'components/ui/card';
import Heading from 'components/ui/heading';
import Label from 'components/ui/label';
import { getCustomerOrder } from 'lib/shopify';
import { Fulfillment, Order } from 'lib/shopify/types';
import Text from 'components/ui/text';
import Price from 'components/price';
import Badge from 'components/ui/badge';
export const runtime = 'edge';
function toPrintDate(date: string) {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
function Unfulfilled({ order }: { order: Order }) {
// Build a map of line item IDs to quantities fulfilled
const fulfilledLineItems = order.fulfillments.reduce<Map<string, number>>((acc, fulfillment) => {
fulfillment.fulfilledLineItems.forEach((lineItem) => {
acc.set(lineItem.id, (acc.get(lineItem.id) || 0) + lineItem.quantity);
});
return acc;
}, new Map<string, number>());
// Filter out line items that have not been fulfilled
const unfulfilledLineItems = order.lineItems.filter((lineItem) => {
const fulfilledQuantity = fulfilledLineItems.get(lineItem.id) || 0;
return lineItem.quantity! > fulfilledQuantity;
});
if (unfulfilledLineItems.length === 0) return null;
return (
<Card>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<CheckCircleIcon className="h-4 w-4" />
<Heading as="h3" size="sm">
Confirmed
</Heading>
</div>
<div className="flex gap-2">
<div className="flex w-4 justify-center">
<span className="border border-dashed border-content-subtle" />
</div>
<div className="flex flex-col gap-4">
<Label>{toPrintDate(order.processedAt)}</Label>
<Label>We&apos;ve received your order.</Label>
</div>
</div>
</div>
</Card>
);
}
function FulfillmentCard({
fulfillment,
processedAt,
isPartial
}: {
fulfillment: Fulfillment;
processedAt: string;
isPartial: boolean;
}) {
return (
<Card>
{isPartial && (
<div className="mb-6 flex flex-wrap gap-2">
{fulfillment.fulfilledLineItems.map((lineItem, index) => (
<Badge key={index} content={lineItem.quantity}>
<Image
alt={lineItem.image.altText}
src={lineItem.image.url}
width={62}
height={62}
className="flex flex-col gap-2 rounded border"
/>
</Badge>
))}
</div>
)}
<div className="mb-6 flex flex-col gap-2">
{fulfillment.trackingInformation.map((tracking, index) => (
<div key={index} className="flex w-fit flex-col">
<Label>Courier: {tracking.company}</Label>
<Label>
{' '}
Tracking number: <span className="text-primary">{tracking.number}</span>
</Label>
</div>
))}
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<TruckIcon className="h-4 w-4" />
<Heading size="sm">On its way</Heading>
</div>
<div className="flex gap-2">
<div className="flex w-4 justify-center">
<span className="border border-dashed border-content-subtle" />
</div>
<div className="flex flex-col gap-4">
<Label>Updated {toPrintDate(fulfillment.createdAt)}</Label>
<Label>This shipment is on its way.</Label>
</div>
</div>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<CheckCircleIcon className="h-4 w-4" />
<Heading as="h3" size="sm">
Confirmed
</Heading>
</div>
<div className="flex gap-2">
<div className="flex w-4 justify-center">
<span className="border border-dashed border-content-subtle" />
</div>
<div className="flex flex-col gap-4">
<Label>{toPrintDate(processedAt)}</Label>
<Label>We&apos;ve received your order.</Label>
</div>
</div>
</div>
</div>
</Card>
);
}
function Fulfillments({ order }: { order: Order }) {
return (
<div className="flex flex-col gap-6">
{order.fulfillments.map((fulfillment, index) => (
<FulfillmentCard
key={index}
fulfillment={fulfillment}
processedAt={order.processedAt}
isPartial={order.fulfillments.length > 1}
/>
))}
</div>
);
}
function PaymentsDetails({ order }: { order: Order }) {
return (
<>
{order.transactions.map((transaction, index) => (
<div key={index} className="flex items-start gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={transaction.paymentIcon.url} alt={transaction.paymentIcon.altText} width={36} />
<div>
<Text>
Ending with {transaction.paymentDetails.last4} -
<Price
as="span"
amount={transaction.transactionAmount.amount}
currencyCode={transaction.transactionAmount.currencyCode}
/>
</Text>
<Label>{toPrintDate(transaction.processedAt)}</Label>
</div>
</div>
))}
</>
);
}
function OrderDetails({ order }: { order: Order }) {
return (
<Card className="flex flex-col gap-4">
<Heading size="sm">Order Details</Heading>
<div className="flex flex-col justify-between sm:flex-row">
<div className="flex flex-1 flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>Contact Information</Label>
<div>
<Text>{order.customer!.displayName}</Text>
<Text>{order.customer!.emailAddress}</Text>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Shipping Address</Label>
<div>
<Text>
{order.shippingAddress.firstName} {order.shippingAddress.lastName}
</Text>
<Text>{order.shippingAddress.address1}</Text>
{order.shippingAddress.address2 && <Text>{order.shippingAddress.address2}</Text>}
<Text>
{order.shippingAddress.city} {order.shippingAddress.provinceCode}{' '}
{order.shippingAddress.zip}
</Text>
<Text>{order.shippingAddress.country}</Text>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Shipping Method</Label>
<Text>{order.shippingMethod.name}</Text>
</div>
</div>
<div className="flex flex-1 flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>Payment</Label>
<PaymentsDetails order={order} />
</div>
<div className="flex flex-col gap-2">
<Label>Billing Address</Label>
<div>
<Text>
{order.billingAddress.firstName} {order.billingAddress.lastName}
</Text>
<Text>{order.billingAddress.address1}</Text>
{order.billingAddress.address2 && <Text>{order.billingAddress.address2}</Text>}
<Text>
{order.billingAddress.city} {order.billingAddress.provinceCode}{' '}
{order.billingAddress.zip}
</Text>
<Text>{order.billingAddress.country}</Text>
</div>
</div>
</div>
</div>
</Card>
);
}
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>
);
}
export default async function OrderPage({ params }: { params: { id: string } }) {
const order = await getCustomerOrder(params.id);
return (
<main className="mx-auto max-w-6xl p-6">
<div className="mb-6 flex justify-between">
<div>
<Heading as="h1">Order {order.name}</Heading>
<Label>Confirmed {toPrintDate(order.processedAt)}</Label>
</div>
<div>
<Button>Activate Warranty</Button>
</div>
</div>
<div className="flex items-start gap-6">
<div className="flex flex-1 flex-col gap-6">
<Fulfillments order={order} />
<Unfulfilled order={order} />
<OrderDetails order={order} />
</div>
<Card className="hidden md:block md:basis-5/12">
<OrderSummary order={order} />
</Card>
</div>
</main>
);
}

View File

@ -1,85 +1,104 @@
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';
import Image from 'next/image';
import Link from 'next/link';
import { getCustomerOrders } from 'lib/shopify';
import Price from 'components/price';
import Divider from 'components/divider';
import { Button } from 'components/button';
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
// if (!access) {
// redirect('/logout');
// }
// if (access === 'denied') {
// redirect('/logout');
// }
//
// 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,
// query: CUSTOMER_DETAILS_QUERY,
// tags: [TAGS.customer]
// });
// 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
const orders = await getCustomerOrders();
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 className="mx-auto mt-4 max-w-screen-2xl px-4">
<h3 className="pb-4 text-2xl font-bold">Orders</h3>
{orders.map((order, index) => (
<div className="relative" key={index}>
<Link
className="absolute left-0 top-0 h-full w-full"
href={`/account/orders/${order.id}`}
></Link>
<div className="flex w-full flex-col rounded border bg-white p-6 md:w-fit">
<div className="flex flex-col gap-2">
{order.lineItems.slice(0, 2).map((lineItem, index) => (
<div key={index}>
<div className="flex gap-2">
<Image
src={lineItem?.image?.url}
alt={lineItem?.image?.altText}
width={80}
height={80}
/>
<div>
<p>{lineItem.title}</p>
</div>
</div>
<Divider />
</div>
))}
</div>
</div>
<div className="h-full w-full">
<div className="mt-5">{orders && <AccountOrdersHistory orders={orders} />}</div>
<div className="flex flex-col gap-4">
<div>
<p className="font-bold">
{order.lineItems.length} item{order.lineItems.length > 1 && 's'}
</p>
<p className="text-gray-500">Order {order.name}</p>
</div>
<Price
className="text-lg font-medium text-gray-900"
amount={order.totalPrice!.amount}
currencyCode={order.totalPrice!.currencyCode}
/>
</div>
<Button size="lg">Activate Warranty</Button>
</div>
</div>
</div>
</>
))}
</div>
);
}

View File

@ -1,7 +1,7 @@
import Banner from 'components/banner';
import Navbar from 'components/layout/navbar';
import { GeistSans } from 'geist/font/sans';
import { ensureStartsWith } from 'lib/utils';
import { ensureStartsWith } from 'lib/shopify/utils';
import { ReactNode, Suspense } from 'react';
import './globals.css';

View File

@ -1,5 +1,5 @@
import { getCollections, getPages, getProducts } from 'lib/shopify';
import { validateEnvironmentVariables } from 'lib/utils';
import { validateEnvironmentVariables } from 'lib/shopify/utils';
import { MetadataRoute } from 'next';
type Route = {

View File

@ -1,15 +1,12 @@
'use server';
import { TAGS } from 'lib/shopify/customer/constants';
import { removeAllCookiesServerAction } from 'lib/shopify/customer/auth-helpers';
import { CUSTOMER_API_URL, ORIGIN_URL, removeAllCookiesServerAction } from 'lib/shopify/auth';
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;
const origin = ORIGIN_URL;
const customerAccountApiUrl = CUSTOMER_API_URL;
let logoutUrl;
try {
const idToken = cookies().get('shop_id_token');
@ -26,7 +23,6 @@ export async function doLogout() {
);
}
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

View File

@ -2,26 +2,21 @@
//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';
generateRandomString,
CUSTOMER_API_CLIENT_ID,
ORIGIN_URL,
CUSTOMER_API_URL
} from 'lib/shopify/auth';
export async function doLogin(prevState: any) {
const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL;
const clientId = SHOPIFY_CLIENT_ID;
const origin = SHOPIFY_ORIGIN;
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`);
//console.log ("previous", prevState)
@ -64,6 +59,5 @@ export async function doLogin(prevState: any) {
return 'Error logging in. Please try again';
}
revalidateTag(TAGS.customer);
redirect(`${loginUrl}`); // Navigate to the new post page
}

View File

@ -1,5 +1,5 @@
import { ChevronRightIcon, EllipsisHorizontalIcon } from '@heroicons/react/16/solid';
import { cn } from 'lib/utils';
import { cn } from 'lib/shopify/utils';
import Link, { LinkProps } from 'next/link';
import { ComponentPropsWithoutRef, ReactNode, forwardRef } from 'react';

View File

@ -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/shopify/utils';
type BreadcrumbProps = {
type: 'product' | 'collection';

View File

@ -1,69 +1,77 @@
'use client';
import React from 'react';
import { Slot } from '@radix-ui/react-slot';
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({
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'
],
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: 'text-xs px-2.5 py-1.5',
md: 'text-sm px-3 py-2',
lg: 'text-base px-4 py-2.5'
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'
}
},
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'
]
primary: {
root: [
// border
'border-transparent',
// text color
'text-white',
// background color
'bg-primary',
// hover color
'hover:bg-primary-empahsis',
// disabled
'disabled:bg-primary-muted'
]
},
secondary: {
root: [
// 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: {
root: [
// border
'border-transparent',
// text color
'text-tremor-brand',
// background color
'bg-transparent',
// hover color
'disabled:text-gray-400'
]
}
}
},
defaultVariants: {
@ -72,45 +80,44 @@ const buttonVariants = tv({
}
});
interface ButtonProps
extends React.ComponentPropsWithoutRef<'button'>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
interface ButtonProps extends ButtonBaseProps, VariantProps<typeof buttonVariants> {
isLoading?: boolean;
loadingText?: string;
className?: string;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
asChild,
isLoading = false,
loadingText,
children,
className,
disabled,
isLoading,
loadingText = 'Loading',
size,
variant,
children,
...props
}: ButtonProps,
forwardedRef
) => {
const Component = asChild ? Slot : 'button';
const { loading, root } = buttonVariants({ variant, size });
return (
<Component
<ButtonBase
ref={forwardedRef}
className={clsx(buttonVariants({ variant }), className)}
className={clsx(root(), 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 className={loading()}>
<Spinner />
<span className="sr-only">{loadingText}</span>
<span>{loadingText}</span>
</span>
) : (
children
)}
</Component>
</ButtonBase>
);
}
);

View File

@ -2,7 +2,7 @@ import { PlusIcon } from '@heroicons/react/16/solid';
import Price from 'components/price';
import { DEFAULT_OPTION } from 'lib/constants';
import { CartItem } from 'lib/shopify/types';
import { createUrl } from 'lib/utils';
import { createUrl } from 'lib/shopify/utils';
import Image from 'next/image';
import Link from 'next/link';
import { DeleteItemButton } from './delete-item-button';
@ -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>

View File

@ -2,7 +2,7 @@
import { CheckIcon } from '@heroicons/react/24/outline';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { cn } from 'lib/utils';
import { cn } from 'lib/shopify/utils';
import { forwardRef } from 'react';
const Checkbox = forwardRef<

54
components/divider.tsx Normal file
View 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>
);
}

View File

@ -3,7 +3,7 @@
import { Button } from '@headlessui/react';
import { MAKE_FILTER_ID, MODEL_FILTER_ID, PART_TYPES, YEAR_FILTER_ID } from 'lib/constants';
import { Menu, Metaobject } from 'lib/shopify/types';
import { createUrl, findParentCollection } from 'lib/utils';
import { createUrl, findParentCollection } from 'lib/shopify/utils';
import get from 'lodash.get';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';

View File

@ -1,7 +1,7 @@
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { createUrl } from 'lib/utils';
import { createUrl } from 'lib/shopify/utils';
import { useRouter, useSearchParams } from 'next/navigation';
export default function Search() {

View File

@ -3,7 +3,7 @@ import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { Filter, FilterType } from 'lib/shopify/types';
import { createUrl } from 'lib/utils';
import { createUrl } from 'lib/shopify/utils';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import PriceRange from './price-range';
import SelectedList from './selected-list';

View File

@ -3,7 +3,7 @@
import Price from 'components/price';
import { useDebounce } from 'hooks';
import { Filter } from 'lib/shopify/types';
import { createUrl } from 'lib/utils';
import { createUrl } from 'lib/shopify/utils';
import get from 'lodash.get';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';

View File

@ -2,7 +2,7 @@
import { XMarkIcon } from '@heroicons/react/16/solid';
import { Filter } from 'lib/shopify/types';
import { createUrl } from 'lib/utils';
import { createUrl } from 'lib/shopify/utils';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
const SelectedList = ({ filters }: { filters: Filter[] }) => {

View File

@ -1,6 +1,6 @@
import clsx from 'clsx';
import { SortFilterItem } from 'lib/constants';
import { createUrl } from 'lib/utils';
import { createUrl } from 'lib/shopify/utils';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';

View File

@ -3,7 +3,7 @@
import { ArrowRightIcon } from '@heroicons/react/16/solid';
import { MAKE_FILTER_ID } from 'lib/constants';
import { Metaobject } from 'lib/shopify/types';
import { createUrl } from 'lib/utils';
import { createUrl } from 'lib/shopify/utils';
import { useRouter, useSearchParams } from 'next/navigation';
const ButtonGroup = ({ manufacturer }: { manufacturer: Metaobject }) => {

View File

@ -9,7 +9,6 @@ type ManufacturersGridProps = {
};
const ManufacturersGrid = ({ manufacturers, variant = 'home' }: ManufacturersGridProps) => {
console.log('manufacturers', manufacturers);
const popularManufacturers = manufacturers.filter(
(manufacturer) => manufacturer.is_popular === 'true'
);

View File

@ -3,11 +3,13 @@ import clsx from 'clsx';
const Price = ({
amount,
className,
as,
currencyCode = 'USD',
currencyCodeClassName,
showCurrency = false
}: {
amount: string;
as?: 'p' | 'span';
className?: string;
currencyCode: string;
currencyCodeClassName?: string;
@ -21,9 +23,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}>
{new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode,
@ -32,7 +35,7 @@ const Price = ({
{showCurrency && (
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{currencyCode}</span>
)}
</p>
</Component>
);
};

View File

@ -6,7 +6,7 @@ import Price from 'components/price';
import SideDialog from 'components/side-dialog';
import { CORE_VARIANT_ID_KEY, CORE_WAIVER } from 'lib/constants';
import { CoreChargeOption, ProductVariant } from 'lib/shopify/types';
import { cn, createUrl } from 'lib/utils';
import { cn, createUrl } from 'lib/shopify/utils';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';

View File

@ -2,7 +2,7 @@
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import { TileImage } from 'components/grid/tile';
import { createUrl } from 'lib/utils';
import { createUrl } from 'lib/shopify/utils';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';

View File

@ -6,7 +6,7 @@ import clsx from 'clsx';
import Price from 'components/price';
import { CORE_VARIANT_ID_KEY, CORE_WAIVER } from 'lib/constants';
import { CoreChargeOption, Money, ProductOption, ProductVariant } from 'lib/shopify/types';
import { createUrl } from 'lib/utils';
import { createUrl } from 'lib/shopify/utils';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Fragment, useEffect, useState } from 'react';

View File

@ -1,7 +1,7 @@
'use client';
import Price from 'components/price';
import { cn } from 'lib/utils';
import { cn } from 'lib/shopify/utils';
import { ReactNode, useState } from 'react';
const options = ['Included', 'Premium Labor', '+1 Year'] as const;

View File

@ -20,21 +20,15 @@ function SubmitButton(props: any) {
<>
{props?.message && <div className="my-5">{props?.message}</div>}
<Button
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
type="submit"
aria-label="Log in"
aria-disabled={pending}
disabled={pending}
isLoading={pending}
loadingText="Signing In..."
className="w-full"
>
{pending ? (
<>
<span>Logging In...</span>
</>
) : (
<>
<span>Log-In</span>
</>
)}
Sign In
</Button>
</>
);

19
components/spinner.tsx Normal file
View 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>
);
}

View File

@ -1,6 +1,6 @@
'use client';
import { cn } from 'lib/utils';
import { cn } from 'lib/shopify/utils';
import { ITooltip, Tooltip as ReactTooltip } from 'react-tooltip';
const Tooltip = ({ id, children, className }: ITooltip) => {

35
components/ui/badge.tsx Normal file
View 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>
);
}

38
components/ui/card.tsx Normal file
View File

@ -0,0 +1,38 @@
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 shadow-content/10 bg-white',
false: {}
}
},
defaultVariants: {
outlined: true
}
});
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, className })} {...props} />
);
}
);
Card.displayName = 'Card';
export { Card, type CardProps };

26
components/ui/heading.tsx Normal file
View 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
View 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>;
}

32
components/ui/text.tsx Normal file
View 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>;
}

View File

@ -1,12 +1,59 @@
//you need to remain this as type so as not to confuse with the actual function
import type { NextRequest, NextResponse as NextResponseType } from 'next/server';
import { cookies } from 'next/headers';
import { getNonce } from 'lib/shopify/customer/auth-utils';
import {
SHOPIFY_CUSTOMER_ACCOUNT_API_URL,
SHOPIFY_USER_AGENT,
SHOPIFY_CLIENT_ID
} from './constants';
import { NextRequest, NextResponse } from 'next/server';
export const CUSTOMER_API_URL = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_URL!;
export const CUSTOMER_API_CLIENT_ID = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID || '';
export const ORIGIN_URL = process.env.SHOPIFY_ORIGIN_URL || '';
export const USER_AGENT = '*';
export async function generateCodeVerifier() {
const randomCode = generateRandomCode();
return base64UrlEncode(randomCode);
}
export async function generateCodeChallenge(codeVerifier: string) {
const digestOp = await crypto.subtle.digest(
{ name: 'SHA-256' },
new TextEncoder().encode(codeVerifier)
);
const hash = convertBufferToString(digestOp);
return base64UrlEncode(hash);
}
function generateRandomCode() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return String.fromCharCode.apply(null, Array.from(array));
}
function base64UrlEncode(str: string) {
const base64 = btoa(str);
// This is to ensure that the encoding does not have +, /, or = characters in it.
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function convertBufferToString(hash: ArrayBuffer) {
const uintArray = new Uint8Array(hash);
const numberArray = Array.from(uintArray);
return String.fromCharCode(...numberArray);
}
export async function generateRandomString() {
const timestamp = Date.now().toString();
const randomString = Math.random().toString(36).substring(2);
return timestamp + randomString;
}
export async function getNonce(token: string) {
const [header, payload, signature] = token.split('.');
const decodedHeader = JSON.parse(atob(header || ''));
const decodedPayload = JSON.parse(atob(payload || ''));
return {
header: decodedHeader,
payload: decodedPayload,
signature
};
}
export async function initialAccessToken(
request: NextRequest,
@ -58,29 +105,30 @@ export async function initialAccessToken(
headersNew.append('User-Agent', userAgent);
headersNew.append('Origin', newOrigin || '');
const tokenRequestUrl = `${customerAccountApiUrl}/auth/oauth/token`;
console.log('sending request to', tokenRequestUrl);
const response = await fetch(tokenRequestUrl, {
method: 'POST',
headers: headersNew,
body
});
const data = await response.json();
console.log('data initial access token', data);
console.log('ok', response.ok);
if (!response.ok) {
console.log('data response error auth', data.error);
const error = await response.text();
console.log('data response error auth', error);
console.log('response auth', response.status);
return { success: false, message: `Response error auth` };
}
const data = await response.json();
if (data?.errors) {
const errorMessage = data?.errors?.[0]?.message ?? 'Unknown error auth';
return { success: false, message: `${errorMessage}` };
}
const nonce = await getNonce(data?.id_token || '');
const nonceValue = nonce.payload.nonce;
const shopNonce = request.cookies.get('shop_nonce');
const shopNonceValue = shopNonce?.value;
console.log('sent nonce', nonce);
console.log('original nonce', shopNonceValue);
if (nonce !== shopNonceValue) {
//make equal === to force error for testing
if (nonceValue !== shopNonceValue) {
console.log('Error nonce match');
return { success: false, message: `Error: Nonce mismatch` };
}
@ -134,18 +182,16 @@ export async function refreshToken({ request, origin }: { request: NextRequest;
console.log('Error: No Refresh Token');
return { success: false, message: `no_refresh_token` };
}
const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL;
const clientId = SHOPIFY_CLIENT_ID;
const userAgent = SHOPIFY_USER_AGENT;
const clientId = CUSTOMER_API_CLIENT_ID;
newBody.append('grant_type', 'refresh_token');
newBody.append('refresh_token', refreshTokenValue);
newBody.append('client_id', clientId);
const headers = {
'content-type': 'application/x-www-form-urlencoded',
'User-Agent': userAgent,
'User-Agent': USER_AGENT,
Origin: origin
};
const tokenRequestUrl = `${customerAccountApiUrl}/auth/oauth/token`;
const tokenRequestUrl = `${CUSTOMER_API_URL}/auth/oauth/token`;
const response = await fetch(tokenRequestUrl, {
method: 'POST',
headers,
@ -164,7 +210,7 @@ export async function refreshToken({ request, origin }: { request: NextRequest;
const customerAccessToken = await exchangeAccessToken(
access_token,
clientId,
customerAccountApiUrl,
CUSTOMER_API_URL,
origin
);
// console.log("Customer Access Token in refresh request", customerAccessToken)
@ -203,7 +249,7 @@ export async function checkExpires({
return { ranRefresh: isExpired, success: true };
}
export function removeAllCookies(response: NextResponseType) {
export function removeAllCookies(response: NextResponse) {
//response.cookies.delete('shop_auth_token') //never set. We don't use it anywhere.
response.cookies.delete('shop_customer_token');
response.cookies.delete('shop_refresh_token');
@ -234,7 +280,7 @@ export async function createAllCookies({
expiresAt,
id_token
}: {
response: NextResponseType;
response: NextResponse;
customerAccessToken: string;
expires_in: number;
refresh_token: string;
@ -284,3 +330,181 @@ export async function createAllCookies({
return response;
}
export async function isLoggedIn(request: NextRequest, origin: string) {
const customerToken = request.cookies.get('shop_customer_token');
const customerTokenValue = customerToken?.value;
const refreshToken = request.cookies.get('shop_refresh_token');
const refreshTokenValue = refreshToken?.value;
console.log('customer token', customerTokenValue);
const newHeaders = new Headers(request.headers);
if (!customerTokenValue && !refreshTokenValue) {
const redirectUrl = new URL(`${origin}`);
const response = NextResponse.redirect(`${redirectUrl}`);
return removeAllCookies(response);
}
const expiresToken = request.cookies.get('shop_expires_at');
const expiresTokenValue = expiresToken?.value;
if (!expiresTokenValue) {
const redirectUrl = new URL(`${origin}`);
const response = NextResponse.redirect(`${redirectUrl}`);
return removeAllCookies(response);
//return { success: false, message: `no_expires_at` }
}
const isExpired = await checkExpires({
request: request,
expiresAt: expiresTokenValue,
origin: origin
});
console.log('is Expired?', isExpired);
//only execute the code below to reset the cookies if it was expired!
if (isExpired.ranRefresh) {
const isSuccess = isExpired?.refresh?.success;
if (!isSuccess) {
const redirectUrl = new URL(`${origin}`);
const response = NextResponse.redirect(`${redirectUrl}`);
return removeAllCookies(response);
//return { success: false, message: `no_refresh_token` }
} else {
const refreshData = isExpired?.refresh?.data;
//console.log ("refresh data", refreshData)
console.log('We used the refresh token, so now going to reset the token and cookies');
const newCustomerAccessToken = refreshData?.customerAccessToken;
const expires_in = refreshData?.expires_in;
//const test_expires_in = 180 //to test to see if it expires in 60 seconds!
const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + '';
newHeaders.set('x-shop-customer-token', `${newCustomerAccessToken}`);
const resetCookieResponse = NextResponse.next({
request: {
// New request headers
headers: newHeaders
}
});
return await createAllCookies({
response: resetCookieResponse,
customerAccessToken: newCustomerAccessToken,
expires_in,
refresh_token: refreshData?.refresh_token,
expiresAt
});
}
}
newHeaders.set('x-shop-customer-token', `${customerTokenValue}`);
return NextResponse.next({
request: {
// New request headers
headers: newHeaders
}
});
}
//when we are running on the production website we just get the origin from the request.nextUrl
export function getOrigin(request: NextRequest) {
const nextOrigin = request.nextUrl.origin;
console.log('Current Origin', nextOrigin);
//when running localhost, we want to use fake origin otherwise we use the real origin
let newOrigin = nextOrigin;
if (nextOrigin === 'https://localhost:3000' || nextOrigin === 'http://localhost:3000') {
newOrigin = ORIGIN_URL;
} else {
newOrigin = nextOrigin;
}
console.log('New Origin', newOrigin);
return newOrigin;
}
export async function authorize(request: NextRequest, origin: string) {
const clientId = CUSTOMER_API_CLIENT_ID;
const newHeaders = new Headers(request.headers);
/***
STEP 1: Get the initial access token or deny access
****/
const dataInitialToken = await initialAccessToken(request, origin, CUSTOMER_API_URL, clientId);
console.log('data initial token', dataInitialToken);
if (!dataInitialToken.success) {
console.log('Error: Access Denied. Check logs', dataInitialToken.message);
newHeaders.set('x-shop-access', 'denied');
return NextResponse.next({
request: {
// New request headers
headers: newHeaders
}
});
}
const { access_token, expires_in, id_token, refresh_token } = dataInitialToken.data;
/***
STEP 2: Get a Customer Access Token
****/
const customerAccessToken = await exchangeAccessToken(
access_token,
clientId,
CUSTOMER_API_URL,
origin || ''
);
console.log('customer access token', customerAccessToken);
if (!customerAccessToken.success) {
console.log('Error: Customer Access Token');
newHeaders.set('x-shop-access', 'denied');
return NextResponse.next({
request: {
// New request headers
headers: newHeaders
}
});
}
//console.log("customer access Token", customerAccessToken.data.access_token)
/**STEP 3: Set Customer Access Token cookies
We are setting the cookies here b/c if we set it on the request, and then redirect
it doesn't see to set sometimes
**/
newHeaders.set('x-shop-access', 'allowed');
/*
const authResponse = NextResponse.next({
request: {
// New request headers
headers: newHeaders,
},
})
*/
const accountUrl = new URL(`${origin}/account`);
const authResponse = NextResponse.redirect(`${accountUrl}`);
//sets an expires time 2 minutes before expiration which we can use in refresh strategy
//const test_expires_in = 180 //to test to see if it expires in 60 seconds!
const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + '';
console.log('expires at', expiresAt);
return await createAllCookies({
response: authResponse,
customerAccessToken: customerAccessToken?.data?.access_token,
expires_in,
refresh_token,
expiresAt,
id_token
});
}
export async function logout(request: NextRequest, origin: string) {
//console.log("New Origin", newOrigin)
const idToken = request.cookies.get('shop_id_token');
const idTokenValue = idToken?.value;
//revalidateTag(TAGS.customer); //this causes some strange error in Nextjs about invariant, so removing for now
//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)
if (!idTokenValue) {
const logoutUrl = new URL(`${origin}/login`);
const response = NextResponse.redirect(`${logoutUrl}`);
return removeAllCookies(response);
}
//console.log ("id toke value", idTokenValue)
const logoutUrl = new URL(
`${CUSTOMER_API_URL}/auth/logout?id_token_hint=${idTokenValue}&post_logout_redirect_uri=${origin}`
);
//console.log ("logout url", logoutUrl)
const logoutResponse = NextResponse.redirect(logoutUrl);
return removeAllCookies(logoutResponse);
}

View File

@ -1,48 +0,0 @@
// @ts-nocheck
export async function generateCodeVerifier() {
const randomCode = generateRandomCode();
return base64UrlEncode(randomCode);
}
export async function generateCodeChallenge(codeVerifier: string) {
const digestOp = await crypto.subtle.digest(
{ name: 'SHA-256' },
new TextEncoder().encode(codeVerifier)
);
const hash = convertBufferToString(digestOp);
return base64UrlEncode(hash);
}
function generateRandomCode() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return String.fromCharCode.apply(null, Array.from(array));
}
function base64UrlEncode(str: string) {
const base64 = btoa(str);
// This is to ensure that the encoding does not have +, /, or = characters in it.
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function convertBufferToString(hash: ArrayBuffer) {
const uintArray = new Uint8Array(hash);
const numberArray = Array.from(uintArray);
return String.fromCharCode(...numberArray);
}
export async function generateRandomString() {
const timestamp = Date.now().toString();
const randomString = Math.random().toString(36).substring(2);
return timestamp + randomString;
}
export async function getNonce(token: string) {
return decodeJwt(token).payload.nonce;
}
function decodeJwt(token: string) {
const [header, payload, signature] = token.split('.');
const decodedHeader = JSON.parse(atob(header || ''));
const decodedPayload = JSON.parse(atob(payload || ''));
return {
header: decodedHeader,
payload: decodedPayload,
signature
};
}

View File

@ -1,10 +0,0 @@
export const TAGS = {
customer: 'customer'
};
//ENVs
export const SHOPIFY_CUSTOMER_ACCOUNT_API_URL = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_URL || '';
export const SHOPIFY_CLIENT_ID = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID || '';
export const SHOPIFY_CUSTOMER_API_VERSION = process.env.SHOPIFY_CUSTOMER_API_VERSION || '';
export const SHOPIFY_USER_AGENT = '*';
export const SHOPIFY_ORIGIN = process.env.SHOPIFY_ORIGIN_URL || '';

View File

@ -1,285 +0,0 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import {
checkExpires,
removeAllCookies,
initialAccessToken,
exchangeAccessToken,
createAllCookies
} from './auth-helpers';
import { isShopifyError } from 'lib/type-guards';
import { parseJSON } from 'lib/shopify/customer/utils/parse-json';
import {
SHOPIFY_CUSTOMER_ACCOUNT_API_URL,
SHOPIFY_USER_AGENT,
SHOPIFY_CUSTOMER_API_VERSION,
SHOPIFY_CLIENT_ID,
SHOPIFY_ORIGIN
} from './constants';
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL;
const apiVersion = SHOPIFY_CUSTOMER_API_VERSION;
const userAgent = SHOPIFY_USER_AGENT;
const customerEndpoint = `${customerAccountApiUrl}/account/customer/api/${apiVersion}/graphql`;
//NEVER CACHE THIS! Doesn't see to be cached anyway b/c
//https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#opting-out-of-data-caching
//The fetch request comes after the usage of headers or cookies.
//and we always send this anyway after getting a cookie for the customer
export async function shopifyCustomerFetch<T>({
customerToken,
query,
tags,
variables
}: {
cache?: RequestCache;
customerToken: string;
query: string;
tags?: string[];
variables?: ExtractVariables<T>;
}): Promise<{ status: number; body: T } | never> {
try {
const customerOrigin = SHOPIFY_ORIGIN;
const result = await fetch(customerEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': userAgent,
Origin: customerOrigin,
Authorization: customerToken
},
body: JSON.stringify({
...(query && { query }),
...(variables && { variables })
}),
cache: 'no-store',
...(tags && { next: { tags } })
});
const body = await result.json();
if (!result.ok) {
//the statuses here could be different, a 401 means
//https://shopify.dev/docs/api/customer#endpoints
//401 means the token is bad
console.log('Error in Customer Fetch Status', body.errors);
if (result.status === 401) {
// clear session because current access token is invalid
const errorMessage = 'unauthorized';
throw errorMessage; //this should throw in the catch below in the non-shopify catch
}
let errors;
try {
errors = parseJSON(body);
} catch (_e) {
errors = [{ message: body }];
}
throw errors;
}
//this just throws an error and the error boundary is called
if (body.errors) {
//throw 'Error'
console.log('Error in Customer Fetch', body.errors[0]);
throw body.errors[0];
}
return {
status: result.status,
body
};
} catch (e) {
if (isShopifyError(e)) {
throw {
cause: e.cause?.toString() || 'unknown',
status: e.status || 500,
message: e.message,
query
};
}
throw {
error: e,
query
};
}
}
export async function isLoggedIn(request: NextRequest, origin: string) {
const customerToken = request.cookies.get('shop_customer_token');
const customerTokenValue = customerToken?.value;
const refreshToken = request.cookies.get('shop_refresh_token');
const refreshTokenValue = refreshToken?.value;
const newHeaders = new Headers(request.headers);
if (!customerTokenValue && !refreshTokenValue) {
const redirectUrl = new URL(`${origin}`);
const response = NextResponse.redirect(`${redirectUrl}`);
return removeAllCookies(response);
}
const expiresToken = request.cookies.get('shop_expires_at');
const expiresTokenValue = expiresToken?.value;
if (!expiresTokenValue) {
const redirectUrl = new URL(`${origin}`);
const response = NextResponse.redirect(`${redirectUrl}`);
return removeAllCookies(response);
//return { success: false, message: `no_expires_at` }
}
const isExpired = await checkExpires({
request: request,
expiresAt: expiresTokenValue,
origin: origin
});
console.log('is Expired?', isExpired);
//only execute the code below to reset the cookies if it was expired!
if (isExpired.ranRefresh) {
const isSuccess = isExpired?.refresh?.success;
if (!isSuccess) {
const redirectUrl = new URL(`${origin}`);
const response = NextResponse.redirect(`${redirectUrl}`);
return removeAllCookies(response);
//return { success: false, message: `no_refresh_token` }
} else {
const refreshData = isExpired?.refresh?.data;
//console.log ("refresh data", refreshData)
console.log('We used the refresh token, so now going to reset the token and cookies');
const newCustomerAccessToken = refreshData?.customerAccessToken;
const expires_in = refreshData?.expires_in;
//const test_expires_in = 180 //to test to see if it expires in 60 seconds!
const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + '';
newHeaders.set('x-shop-customer-token', `${newCustomerAccessToken}`);
const resetCookieResponse = NextResponse.next({
request: {
// New request headers
headers: newHeaders
}
});
return await createAllCookies({
response: resetCookieResponse,
customerAccessToken: newCustomerAccessToken,
expires_in,
refresh_token: refreshData?.refresh_token,
expiresAt
});
}
}
newHeaders.set('x-shop-customer-token', `${customerTokenValue}`);
return NextResponse.next({
request: {
// New request headers
headers: newHeaders
}
});
}
//when we are running on the production website we just get the origin from the request.nextUrl
export function getOrigin(request: NextRequest) {
const nextOrigin = request.nextUrl.origin;
//console.log("Current Origin", nextOrigin)
//when running localhost, we want to use fake origin otherwise we use the real origin
let newOrigin = nextOrigin;
if (nextOrigin === 'https://localhost:3000' || nextOrigin === 'http://localhost:3000') {
newOrigin = SHOPIFY_ORIGIN;
} else {
newOrigin = nextOrigin;
}
return newOrigin;
}
export async function authorizeFn(request: NextRequest, origin: string) {
const clientId = SHOPIFY_CLIENT_ID;
const newHeaders = new Headers(request.headers);
/***
STEP 1: Get the initial access token or deny access
****/
const dataInitialToken = await initialAccessToken(
request,
origin,
customerAccountApiUrl,
clientId
);
if (!dataInitialToken.success) {
console.log('Error: Access Denied. Check logs', dataInitialToken.message);
newHeaders.set('x-shop-access', 'denied');
return NextResponse.next({
request: {
// New request headers
headers: newHeaders
}
});
}
const { access_token, expires_in, id_token, refresh_token } = dataInitialToken.data;
/***
STEP 2: Get a Customer Access Token
****/
const customerAccessToken = await exchangeAccessToken(
access_token,
clientId,
customerAccountApiUrl,
origin || ''
);
if (!customerAccessToken.success) {
console.log('Error: Customer Access Token');
newHeaders.set('x-shop-access', 'denied');
return NextResponse.next({
request: {
// New request headers
headers: newHeaders
}
});
}
//console.log("customer access Token", customerAccessToken.data.access_token)
/**STEP 3: Set Customer Access Token cookies
We are setting the cookies here b/c if we set it on the request, and then redirect
it doesn't see to set sometimes
**/
newHeaders.set('x-shop-access', 'allowed');
/*
const authResponse = NextResponse.next({
request: {
// New request headers
headers: newHeaders,
},
})
*/
const accountUrl = new URL(`${origin}/account`);
const authResponse = NextResponse.redirect(`${accountUrl}`);
//sets an expires time 2 minutes before expiration which we can use in refresh strategy
//const test_expires_in = 180 //to test to see if it expires in 60 seconds!
const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + '';
return await createAllCookies({
response: authResponse,
customerAccessToken: customerAccessToken?.data?.access_token,
expires_in,
refresh_token,
expiresAt,
id_token
});
}
export async function logoutFn(request: NextRequest, origin: string) {
//console.log("New Origin", newOrigin)
const idToken = request.cookies.get('shop_id_token');
const idTokenValue = idToken?.value;
//revalidateTag(TAGS.customer); //this causes some strange error in Nextjs about invariant, so removing for now
//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)
if (!idTokenValue) {
const logoutUrl = new URL(`${origin}/login`);
const response = NextResponse.redirect(`${logoutUrl}`);
return removeAllCookies(response);
}
//console.log ("id toke value", idTokenValue)
const logoutUrl = new URL(
`${customerAccountApiUrl}/auth/logout?id_token_hint=${idTokenValue}&post_logout_redirect_uri=${origin}`
);
//console.log ("logout url", logoutUrl)
const logoutResponse = NextResponse.redirect(logoutUrl);
return removeAllCookies(logoutResponse);
}

View File

@ -1,97 +0,0 @@
//https://shopify.dev/docs/api/customer/2024-01/queries/customer
export const CUSTOMER_ME_QUERY = /* GraphQL */ `
query customer {
customer {
emailAddress {
emailAddress
}
firstName
lastName
tags
}
}
`;
const CUSTOMER_FRAGMENT = `#graphql
fragment OrderCard on Order {
id
number
processedAt
financialStatus
fulfillments(first: 1) {
nodes {
status
}
}
totalPrice {
amount
currencyCode
}
lineItems(first: 2) {
edges {
node {
title
image {
altText
height
url
width
}
}
}
}
}
fragment AddressPartial on CustomerAddress {
id
formatted
firstName
lastName
company
address1
address2
territoryCode
zoneCode
city
zip
phoneNumber
}
fragment CustomerDetails on Customer {
firstName
lastName
phoneNumber {
phoneNumber
}
emailAddress {
emailAddress
}
defaultAddress {
...AddressPartial
}
addresses(first: 6) {
edges {
node {
...AddressPartial
}
}
}
orders(first: 250, sortKey: PROCESSED_AT, reverse: true) {
edges {
node {
...OrderCard
}
}
}
}
` as const;
// NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer
export const CUSTOMER_DETAILS_QUERY = `#graphql
query CustomerDetails {
customer {
...CustomerDetails
}
}
${CUSTOMER_FRAGMENT}
` as const;

View File

@ -1,36 +0,0 @@
export type Maybe<T> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
node: T;
};
export type CustomerData = {
data: {
customer: {
emailAddress: {
emailAddress: string;
};
firstName: string;
lastName: string;
tags: any[];
};
};
};
export type GenericObject = { [key: string]: any };
export type CustomerDetailsData = {
data: {
customer: {
emailAddress: {
emailAddress: string;
};
// Using GenericObject to type 'orders' since the fields are not known in advance
orders: Connection<GenericObject>;
};
};
};

View File

@ -1,7 +0,0 @@
export function parseJSON(json: any) {
if (String(json).includes('__proto__')) return JSON.parse(json, noproto);
return JSON.parse(json);
}
function noproto(k: string, v: string) {
if (k !== '__proto__') return v;
}

View File

@ -0,0 +1,18 @@
const customerAddress = /* GraphQL */ `
fragment CustomerAddress on CustomerAddress {
id
formatted
firstName
lastName
company
address1
address2
territoryCode
zoneCode
city
zip
phoneNumber
}
`;
export default customerAddress;

View File

@ -0,0 +1,36 @@
import customerAddress from './customer-address';
import orderCard from './order-card';
const customerDetailsFragment = /* GraphQL */ `
${customerAddress}
${orderCard}
fragment CustomerDetails on Customer {
firstName
lastName
phoneNumber {
phoneNumber
}
emailAddress {
emailAddress
}
defaultAddress {
...CustomerAddress
}
addresses(first: 6) {
edges {
node {
...CustomerAddress
}
}
}
orders(first: 20, sortKey: PROCESSED_AT, reverse: true) {
edges {
node {
...OrderCard
}
}
}
}
`;
export default customerDetailsFragment;

View File

@ -0,0 +1,35 @@
const orderCard = /* GraphQL */ `
fragment OrderCard on Order {
id
number
name
processedAt
financialStatus
fulfillments(first: 1) {
edges {
node {
status
}
}
}
totalPrice {
amount
currencyCode
}
lineItems(first: 20) {
edges {
node {
title
image {
altText
height
url
width
}
}
}
}
}
`;
export default orderCard;

View File

@ -11,7 +11,7 @@ import {
YEAR_FILTER_ID
} from 'lib/constants';
import { isShopifyError } from 'lib/type-guards';
import { ensureStartsWith, normalizeUrl, parseMetaFieldValue } from 'lib/utils';
import { ensureStartsWith, normalizeUrl, parseJSON, parseMetaFieldValue } from 'lib/shopify/utils';
import { revalidatePath, revalidateTag } from 'next/cache';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
@ -38,16 +38,21 @@ import {
getProductsQuery
} from './queries/product';
import {
Address,
Cart,
CartAttributeInput,
CartItem,
Collection,
Connection,
Customer,
Filter,
Image,
Menu,
Metaobject,
Money,
Order,
Fulfillment,
Transaction,
Page,
PageInfo,
Product,
@ -60,6 +65,9 @@ import {
ShopifyCollectionProductsOperation,
ShopifyCollectionsOperation,
ShopifyCreateCartOperation,
ShopifyCustomerOperation,
ShopifyCustomerOrderOperation,
ShopifyCustomerOrdersOperation,
ShopifyFilter,
ShopifyImageOperation,
ShopifyMenuOperation,
@ -75,13 +83,31 @@ import {
ShopifyProductsOperation,
ShopifyRemoveFromCartOperation,
ShopifySetCartAttributesOperation,
ShopifyUpdateCartOperation
ShopifyUpdateCartOperation,
ShopifyCustomer,
ShopifyOrder,
ShopifyAddress,
ShopifyMoneyV2,
LineItem
} from './types';
import { getCustomerQuery } from './queries/customer';
import { getCustomerOrdersQuery } from './queries/orders';
import { getCustomerOrderQuery } from './queries/order';
const domain = process.env.SHOPIFY_STORE_DOMAIN
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
: '';
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
const customerApiUrl = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_URL;
const customerApiVersion = process.env.SHOPIFY_CUSTOMER_API_VERSION;
const storefrontEndpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
const customerEndpoint = `${customerApiUrl}/account/customer/api/${customerApiVersion}/graphql`;
const userAgent = '*';
const placeholderProductImage =
'https://cdn.shopify.com/shopifycloud/customer-account-web/production/assets/8bc6556601c510713d76.svg';
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
@ -100,7 +126,7 @@ export async function shopifyFetch<T>({
variables?: ExtractVariables<T>;
}): Promise<{ status: number; body: T } | never> {
try {
const result = await fetch(endpoint, {
const result = await fetch(storefrontEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -142,6 +168,80 @@ export async function shopifyFetch<T>({
}
}
export async function shopifyCustomerFetch<T>({
query,
variables
}: {
query: string;
variables?: ExtractVariables<T>;
}): Promise<{ status: number; body: T } | never> {
const headersList = headers();
const customerToken = headersList.get('x-shop-customer-token') || '';
try {
const result = await fetch(customerEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': userAgent,
Origin: domain,
Authorization: customerToken
},
body: JSON.stringify({
...(query && { query }),
...(variables && { variables })
}),
cache: 'no-store'
});
const body = await result.json();
if (!result.ok) {
//the statuses here could be different, a 401 means
//https://shopify.dev/docs/api/customer#endpoints
//401 means the token is bad
console.log('Error in Customer Fetch Status', body.errors);
if (result.status === 401) {
// clear session because current access token is invalid
const errorMessage = 'unauthorized';
throw errorMessage; //this should throw in the catch below in the non-shopify catch
}
let errors;
try {
errors = parseJSON(body);
} catch (_e) {
errors = [{ message: body }];
}
throw errors;
}
//this just throws an error and the error boundary is called
if (body.errors) {
//throw 'Error'
console.log('Error in Customer Fetch', body.errors[0]);
throw body.errors[0];
}
return {
status: result.status,
body
};
} catch (e) {
if (isShopifyError(e)) {
throw {
cause: e.cause?.toString() || 'unknown',
status: e.status || 500,
message: e.message,
query
};
}
throw {
error: e,
query
};
}
}
const removeEdgesAndNodes = (array: Connection<any>) => {
return array.edges.map((edge) => edge?.node);
};
@ -316,6 +416,143 @@ const reshapeProducts = (products: ShopifyProduct[]) => {
return reshapedProducts;
};
function reshapeCustomer(customer: ShopifyCustomer): Customer {
return {
firstName: customer.firstName,
lastName: customer.lastName,
displayName: customer.displayName,
emailAddress: customer.emailAddress.emailAddress
};
}
function reshapeOrders(orders: ShopifyOrder[]): any[] | Promise<Order[]> {
const reshapedOrders: Order[] = [];
for (const order of orders) {
const reshapedOrder = reshapeOrder(order);
if (!reshapedOrder) continue;
reshapedOrders.push(reshapedOrder);
}
return reshapedOrders;
}
function reshapeOrder(shopifyOrder: ShopifyOrder): Order {
const reshapeAddress = (address?: ShopifyAddress): Address | undefined => {
if (!address) return undefined;
return {
address1: address.address1,
address2: address.address2,
firstName: address.firstName,
lastName: address.lastName,
provinceCode: address.provinceCode,
city: address.city,
zip: address.zip,
country: address.countryCodeV2,
company: address.company,
phone: address.phone
};
};
const reshapeMoney = (money?: ShopifyMoneyV2): Money | undefined => {
if (!money) return undefined;
return {
amount: money.amount || '0.00',
currencyCode: money.currencyCode || 'USD'
};
};
const orderFulfillments: Fulfillment[] =
shopifyOrder.fulfillments?.edges?.map((edge) => ({
status: edge.node.status,
createdAt: edge.node.createdAt,
trackingInformation:
edge.node.trackingInformation?.map((tracking) => ({
number: tracking.number,
company: tracking.company,
url: tracking.url
})) || [],
events:
edge.node.events?.edges.map((event) => ({
status: event.node.status,
happenedAt: event.node.happenedAt
})) || [],
fulfilledLineItems:
edge.node.fulfillmentLineItems?.nodes.map((lineItem) => ({
id: lineItem.lineItem.id,
quantity: lineItem.quantity,
image: {
url: lineItem.lineItem.image?.url || placeholderProductImage,
altText: lineItem.lineItem.image?.altText || lineItem.lineItem.title,
width: 100,
height: 100
}
})) || []
})) || [];
const orderTransactions: Transaction[] = shopifyOrder.transactions?.map((transaction) => ({
processedAt: transaction.processedAt,
paymentIcon: {
url: transaction.paymentIcon.url,
altText: transaction.paymentIcon.altText,
width: 100,
height: 100
},
paymentDetails: {
last4: transaction.paymentDetails.last4,
cardBrand: transaction.paymentDetails.cardBrand
},
transactionAmount: reshapeMoney(transaction.transactionAmount.presentmentMoney)!
}));
const orderLineItems: LineItem[] =
shopifyOrder.lineItems?.edges.map((edge) => ({
id: edge.node.id,
title: edge.node.title,
quantity: edge.node.quantity,
image: {
url: edge.node.image?.url || placeholderProductImage,
altText: edge.node.image?.altText || edge.node.title,
width: edge.node.image?.width || 62,
height: edge.node.image?.height || 62
},
price: reshapeMoney(edge.node.price),
totalPrice: reshapeMoney(edge.node.totalPrice),
variantTitle: edge.node.variantTitle,
sku: edge.node.sku
})) || [];
const order: Order = {
id: shopifyOrder.id.replace('gid://shopify/Order/', ''),
name: shopifyOrder.name,
processedAt: shopifyOrder.processedAt,
fulfillments: orderFulfillments,
transactions: orderTransactions,
lineItems: orderLineItems,
shippingAddress: reshapeAddress(shopifyOrder.shippingAddress),
billingAddress: reshapeAddress(shopifyOrder.billingAddress),
subtotal: reshapeMoney(shopifyOrder.subtotal),
totalShipping: reshapeMoney(shopifyOrder.totalShipping),
totalTax: reshapeMoney(shopifyOrder.totalTax),
totalPrice: reshapeMoney(shopifyOrder.totalPrice)
};
if (shopifyOrder.customer) {
order.customer = reshapeCustomer(shopifyOrder.customer);
}
if (shopifyOrder.shippingLine) {
console.log('Shipping Line', shopifyOrder.shippingLine);
order.shippingMethod = {
name: shopifyOrder.shippingLine?.title,
price: reshapeMoney(shopifyOrder.shippingLine.originalPrice)!
};
}
return order;
}
export async function createCart(): Promise<Cart> {
const res = await shopifyFetch<ShopifyCreateCartOperation>({
query: createCartMutation,
@ -650,6 +887,33 @@ export async function getProducts({
pageInfo
};
}
export async function getCustomer(): Promise<Customer> {
const res = await shopifyCustomerFetch<ShopifyCustomerOperation>({
query: getCustomerQuery
});
const customer = res.body.data.customer;
return reshapeCustomer(customer);
}
export async function getCustomerOrders(): Promise<Order[]> {
const res = await shopifyCustomerFetch<ShopifyCustomerOrdersOperation>({
query: getCustomerOrdersQuery
});
return reshapeOrders(removeEdgesAndNodes(res.body.data.customer.orders));
}
export async function getCustomerOrder(orderId: string): Promise<Order> {
const res = await shopifyCustomerFetch<ShopifyCustomerOrderOperation>({
query: getCustomerOrderQuery,
variables: { orderId: `gid://shopify/Order/${orderId}` }
});
return reshapeOrder(res.body.data.order);
}
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
export async function revalidate(req: NextRequest): Promise<NextResponse> {
console.log(`Receiving revalidation request from Shopify.`);

View File

@ -0,0 +1,13 @@
//https://shopify.dev/docs/api/customer/2024-01/queries/customer
export const getCustomerQuery = /* GraphQL */ `
query customer {
customer {
emailAddress {
emailAddress
}
firstName
lastName
tags
}
}
`;

View File

@ -0,0 +1,240 @@
// NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer
export const getCustomerOrderQuery = /* GraphQL */ `
query getCustomerOrderQuery($orderId: ID!) {
customer {
emailAddress {
emailAddress
}
displayName
}
order(id: $orderId) {
... on Order {
id
...Order
customer {
id
emailAddress {
emailAddress
marketingState
}
firstName
lastName
phoneNumber {
phoneNumber
marketingState
}
imageUrl
displayName
}
}
}
}
fragment Order on Order {
id
name
confirmationNumber
processedAt
cancelledAt
currencyCode
transactions {
...OrderTransaction
}
billingAddress {
...Address
}
shippingAddress {
...Address
}
fulfillments(first: 20, sortKey: CREATED_AT, reverse: true, query: "NOT status:CANCELLED") {
edges {
node {
id
...Fulfillment
}
}
}
lineItems(first: 50) {
edges {
node {
id
...LineItem
}
}
}
totalPrice {
...Price
}
subtotal {
...Price
}
totalShipping {
...Price
}
totalTax {
...Price
}
financialStatus
totalRefunded {
...Price
}
refunds {
id
createdAt
}
paymentInformation {
paymentCollectionUrl
...OrderPaymentInformation
}
requiresShipping
note
shippingLine {
title
originalPrice {
...Price
}
}
}
fragment OrderTransaction on OrderTransaction {
id
processedAt
paymentIcon {
id
url
altText
}
paymentDetails {
... on CardPaymentDetails {
last4
cardBrand
}
}
transactionAmount {
presentmentMoney {
...Price
}
}
giftCardDetails {
last4
balance {
...Price
}
}
status
kind
transactionParentId
type
typeDetails {
name
message
}
}
fragment Price on MoneyV2 {
amount
currencyCode
}
fragment Address on CustomerAddress {
id
address1
address2
firstName
lastName
provinceCode: zoneCode
city
zip
countryCodeV2: territoryCode
company
phone: phoneNumber
}
fragment Fulfillment on Fulfillment {
id
status
createdAt
estimatedDeliveryAt
trackingInformation {
number
company
url
}
requiresShipping
fulfillmentLineItems(first: 20) {
nodes {
id
quantity
lineItem {
id
name
title
presentmentTitle
sku
image {
id
url
altText
}
}
}
}
events(first: 20, sortKey: HAPPENED_AT, reverse: true) {
edges {
node {
id
...FulfillmentEvent
}
}
}
}
fragment FulfillmentEvent on FulfillmentEvent {
status
happenedAt
}
fragment LineItem on LineItem {
title
image {
altText
height
url
width
}
price {
...Price
}
quantity
sku
totalPrice {
...Price
}
variantTitle
}
fragment OrderPaymentInformation on OrderPaymentInformation {
paymentStatus
totalPaidAmount {
...Price
}
totalOutstandingAmount {
...Price
}
paymentTerms {
id
overdue
nextDueAt
paymentSchedules(first: 2) {
nodes {
id
dueAt
completed
amount {
...Price
}
}
}
}
}
`;

View File

@ -0,0 +1,15 @@
import customerDetailsFragment from '../fragments/customer-details';
const customerFragment = `#graphql
`;
// NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer
export const getCustomerOrdersQuery = `#graphql
query getCustomerOrdersQuery {
customer {
...CustomerDetails
}
}
${customerFragment}
${customerDetailsFragment}
`;

View File

@ -35,6 +35,13 @@ export type Collection = ShopifyCollection & {
path: string;
};
export type Customer = {
emailAddress: string;
firstName?: string;
lastName?: string;
displayName?: string;
};
export type Image = {
url: string;
altText: string;
@ -58,6 +65,278 @@ export type PageMetafield = {
value: string;
};
export type Fulfillment = {
status: string;
createdAt: string;
fulfilledLineItems: {
id: string;
quantity: number;
image: Image;
}[];
trackingInformation: {
number: string;
company: string;
url: string;
}[];
events: {
status: string;
happenedAt: string;
}[];
};
export type Transaction = {
processedAt: string;
paymentIcon: Image;
paymentDetails: {
last4: string;
cardBrand: string;
};
transactionAmount: Money;
};
export type Address = {
address1: string;
address2: string | null;
firstName: string;
lastName: string;
provinceCode: string;
city: string;
zip: string;
country: string;
company: string | null;
phone: string;
};
export type LineItem = {
id: string;
title: string;
image: Image;
price?: Money;
quantity?: number;
sku?: string;
totalPrice?: Money;
variantTitle?: string;
};
export type Order = {
id: string;
name: string;
customer?: Customer;
processedAt: string;
fulfillments: Fulfillment[];
transactions: Transaction[];
lineItems: LineItem[];
shippingAddress: Address;
billingAddress: Address;
/** the price of all line items, excluding taxes and surcharges */
subtotal: Money;
totalShipping: Money;
totalTax: Money;
totalPrice: Money;
shippingMethod?: {
name: string;
price: Money;
};
};
export type ShopifyOrder = {
id: string;
name: string;
confirmationNumber: string;
customer: ShopifyCustomer;
processedAt: string;
cancelledAt: string | null;
currencyCode: string;
transactions: ShopifyOrderTransaction[];
billingAddress: ShopifyAddress;
shippingAddress: ShopifyAddress;
fulfillments: Connection<ShopifyFulfillment>;
lineItems: Connection<ShopifyLineItem>;
totalPrice: ShopifyMoneyV2;
subtotal: ShopifyMoneyV2;
totalShipping: ShopifyMoneyV2;
totalTax: ShopifyMoneyV2;
financialStatus: string;
totalRefunded: ShopifyMoneyV2;
refunds: ShopifyRefund[];
paymentInformation: ShopifyOrderPaymentInformation;
requiresShipping: boolean;
shippingLine: ShopifyShippingLine;
note: string | null;
};
type ShopifyShippingLine = {
title: string;
originalPrice: ShopifyMoneyV2;
};
type ShopifyOrderTransaction = {
id: string;
processedAt: string;
paymentIcon: ShopifyPaymentIconImage;
paymentDetails: ShopifyCardPaymentDetails;
transactionAmount: ShopifyMoneyBag;
giftCardDetails: ShopifyGiftCardDetails | null;
status: string;
kind: string;
transactionParentId: string | null;
type: string;
typeDetails: ShopifyTransactionTypeDetails;
};
type ShopifyPaymentIconImage = {
id: string;
url: string;
altText: string;
};
type ShopifyCardPaymentDetails = {
last4: string;
cardBrand: string;
};
type ShopifyGiftCardDetails = {
last4: string;
balance: ShopifyMoneyV2;
};
type ShopifyMoneyBag = {
presentmentMoney: ShopifyMoneyV2;
};
export type ShopifyMoneyV2 = {
amount: string;
currencyCode: string;
};
type ShopifyTransactionTypeDetails = {
name: string;
message: string | null;
};
export type ShopifyAddress = {
id: string;
address1: string;
address2: string | null;
firstName: string;
lastName: string;
provinceCode: string;
city: string;
zip: string;
countryCodeV2: string;
company: string | null;
phone: string;
};
type ShopifyFulfillment = {
id: string;
status: string;
createdAt: string;
estimatedDeliveryAt: string | null;
trackingInformation: ShopifyTrackingInformation[];
requiresShipping: boolean;
fulfillmentLineItems: ShopifyFulfillmentLineItemConnection;
events: Connection<ShopifyFulfillmentEvent>;
};
type ShopifyTrackingInformation = {
number: string;
company: string;
url: string;
};
type ShopifyFulfillmentLineItemConnection = {
nodes: ShopifyFulfillmentLineItem[];
};
type ShopifyFulfillmentLineItem = {
id: string;
quantity: number;
lineItem: ShopifyLineItem;
};
type ShopifyLineItem = {
id: string;
title: string;
image: ShopifyImage;
price: ShopifyMoneyV2;
quantity: number;
sku: string;
totalPrice: ShopifyMoneyV2;
variantTitle: string;
};
type ShopifyImage = {
altText: string;
height: number;
url: string;
width: number;
};
type ShopifyFulfillmentEventConnection = {
edges: ShopifyFulfillmentEventEdge[];
};
type ShopifyFulfillmentEventEdge = {
node: ShopifyFulfillmentEvent;
};
type ShopifyFulfillmentEvent = {
status: string;
happenedAt: string;
};
type ShopifyRefund = {
id: string;
createdAt: string;
};
type ShopifyOrderPaymentInformation = {
paymentCollectionUrl: string;
paymentStatus: string;
totalPaidAmount: ShopifyMoneyV2;
totalOutstandingAmount: ShopifyMoneyV2;
paymentTerms: ShopifyPaymentTerms | null;
};
type ShopifyPaymentTerms = {
id: string;
overdue: boolean;
nextDueAt: string;
paymentSchedules: ShopifyPaymentScheduleConnection;
};
type ShopifyPaymentScheduleConnection = {
nodes: ShopifyPaymentSchedule[];
};
type ShopifyPaymentSchedule = {
id: string;
dueAt: string;
completed: boolean;
amount: ShopifyMoneyV2;
};
export type ShopifyCustomer = {
id: string;
emailAddress: ShopifyCustomerEmailAddress;
firstName: string;
lastName: string;
phoneNumber: ShopifyCustomerPhoneNumber | null;
imageUrl: string;
displayName: string;
};
type ShopifyCustomerEmailAddress = {
emailAddress: string;
marketingState: string;
};
type ShopifyCustomerPhoneNumber = {
phoneNumber: string;
marketingState: string;
};
export const PAGE_TYPES = [
'image',
'icon_content_section',
@ -399,6 +678,29 @@ export type ShopifyProductsOperation = {
};
};
export type ShopifyCustomerOperation = {
data: {
customer: ShopifyCustomer;
};
};
export type ShopifyCustomerOrdersOperation = {
data: {
customer: {
orders: Connection<ShopifyOrder>;
};
};
};
export type ShopifyCustomerOrderOperation = {
data: {
order: ShopifyOrder;
};
variables: {
orderId: string;
};
};
export type CoreChargeOption = {
label: string;
value: string;

View File

@ -1,7 +1,7 @@
import clsx, { ClassValue } from 'clsx';
import { ReadonlyURLSearchParams } from 'next/navigation';
import { twMerge } from 'tailwind-merge';
import { Menu } from './shopify/types';
import { Menu } from './types';
export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
const paramsString = params.toString();
@ -14,7 +14,14 @@ export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
export const validateEnvironmentVariables = () => {
const requiredEnvironmentVariables = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_STOREFRONT_ACCESS_TOKEN'];
const requiredEnvironmentVariables = [
'SHOPIFY_STORE_DOMAIN',
'SHOPIFY_STOREFRONT_ACCESS_TOKEN',
'SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID',
'SHOPIFY_CUSTOMER_ACCOUNT_API_URL',
'SHOPIFY_CUSTOMER_API_VERSION',
'SHOPIFY_ORIGIN_URL'
];
const missingEnvironmentVariables = [] as string[];
requiredEnvironmentVariables.forEach((envVar) => {
@ -71,3 +78,12 @@ export const findParentCollection = (menu: Menu[], collection: string): Menu | n
}
return parentCollection;
};
export function parseJSON(json: any) {
if (String(json).includes('__proto__')) return JSON.parse(json, noproto);
return JSON.parse(json);
}
function noproto(k: string, v: string) {
if (k !== '__proto__') return v;
}

View File

@ -1,5 +1,16 @@
export const colors = {
primary: '#EF6C02',
primary: {
DEFAULT: '#EF6C02',
emphasis: '#C85900',
muted: '#E6CCB7'
},
content: {
subtle: '#9ca3af', // gray-400
DEFAULT: '#6b7280', // gray-500
emphasis: '#374151', // gray-700
strong: '#111827', // gray-900
inverted: '#ffffff' // white
},
dark: '#091242',
secondary: '#EF6C02',
blue: {

View File

@ -1,5 +1,5 @@
import type { NextRequest } from 'next/server';
import { isLoggedIn, getOrigin, authorizeFn, logoutFn } from 'lib/shopify/customer';
import { isLoggedIn, getOrigin, authorize, logout } from 'lib/shopify/auth';
// This function can be marked `async` if using `await` inside
export async function middleware(request: NextRequest) {
@ -9,8 +9,8 @@ export async function middleware(request: NextRequest) {
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);
console.log('origin', origin);
return await authorize(request, origin);
}
/****
END OF Authorize Middleware to get access tokens
@ -22,7 +22,7 @@ export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/logout')) {
console.log('Running Logout middleware');
const origin = getOrigin(request);
return await logoutFn(request, origin);
return await logout(request, origin);
}
/****
END OF LOGOUT
@ -45,5 +45,5 @@ export async function middleware(request: NextRequest) {
}
export const config = {
matcher: ['/authorize', '/logout', '/account']
matcher: ['/authorize', '/logout', '/account/:path*']
};

View File

@ -11,6 +11,14 @@ module.exports = {
fontFamily: {
sans: ['var(--font-geist-sans)']
},
fontSize: {
'label-sm': ['0.75rem', { lineHeight: '1rem' }],
'label-md': ['0.875rem', { lineHeight: '1.25rem' }],
'label-lg': ['1rem', { lineHeight: '1.5rem' }],
'heading-sm': ['1.125rem', { lineHeight: '1.75rem', fontWeight: '600' }],
'heading-md': ['1.5rem', { lineHeight: '2rem', fontWeight: '600' }],
'heading-lg': ['1.875rem', { lineHeight: '2.25rem', fontWeight: '600' }]
},
keyframes: {
fadeIn: {
from: { opacity: 0 },
@ -37,7 +45,6 @@ module.exports = {
hoverOnlyWhenSupported: true
},
plugins: [
require('@tailwindcss/container-queries'),
require('@tailwindcss/typography'),
plugin(({ matchUtilities, theme }) => {
matchUtilities(