mirror of
https://github.com/vercel/commerce.git
synced 2025-05-14 05:37:51 +00:00
Merge pull request #4 from Car-Part-Planet/CPP-153
Add Customer Authentication and Order Details
This commit is contained in:
commit
93f46a3f90
27
app/(auth)/authorize/page.tsx
Normal file
27
app/(auth)/authorize/page.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export default async function AuthorizationPage() {
|
||||||
|
const headersList = headers();
|
||||||
|
const access = headersList.get('x-shop-access');
|
||||||
|
if (!access) {
|
||||||
|
console.log('ERROR: No access header');
|
||||||
|
throw new Error('No access header');
|
||||||
|
}
|
||||||
|
console.log('Authorize Access code header:', access);
|
||||||
|
if (access === 'denied') {
|
||||||
|
console.log('Access Denied for Auth');
|
||||||
|
throw new Error('No access allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto max-w-screen-2xl px-4">
|
||||||
|
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row lg:gap-8">
|
||||||
|
<div className="h-full w-full">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
17
app/(auth)/login/page.tsx
Normal file
17
app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { LoginMessage } from 'components/auth/login-message';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export default async function LoginPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto max-w-screen-2xl px-4">
|
||||||
|
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row lg:gap-8">
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<LoginMessage />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
13
app/(auth)/logout/page.tsx
Normal file
13
app/(auth)/logout/page.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export default async function LogoutPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto max-w-screen-2xl px-4">
|
||||||
|
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-black md:p-12 lg:flex-row lg:gap-8">
|
||||||
|
<div className="h-full w-full">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -38,7 +38,7 @@ export default async function Page({ params }: { params: { page: string } }) {
|
|||||||
{page.title}
|
{page.title}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<main>
|
<div>
|
||||||
<div className="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
|
||||||
<div className="flex flex-col space-y-16">
|
<div className="flex flex-col space-y-16">
|
||||||
{page.metaobjects?.map((content) => (
|
{page.metaobjects?.map((content) => (
|
||||||
@ -48,7 +48,7 @@ export default async function Page({ params }: { params: { page: string } }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
3
app/account/layout.tsx
Normal file
3
app/account/layout.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="mx-auto max-w-screen-2xl">{children}</div>;
|
||||||
|
}
|
34
app/account/loading.tsx
Normal file
34
app/account/loading.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import Divider from 'components/divider';
|
||||||
|
import Heading from 'components/ui/heading';
|
||||||
|
import Skeleton from 'components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<Heading className="pb-4" as="h1">
|
||||||
|
Orders
|
||||||
|
</Heading>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="flex w-full flex-col rounded border bg-white p-6">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-20 w-20 flex-none" />
|
||||||
|
<Skeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<Skeleton className="mb-2 h-5 w-14" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="w-20" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="mt-4 h-11" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
29
app/account/orders/[id]/loading.tsx
Normal file
29
app/account/orders/[id]/loading.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import Skeleton from 'components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl p-6">
|
||||||
|
<div className="mb-6 flex justify-between">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Skeleton className="mt-1 h-6 w-6" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Skeleton className="h-8 w-32" />
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-9 w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-6">
|
||||||
|
<div className="flex flex-1 flex-col gap-6">
|
||||||
|
<Skeleton className="h-72" />
|
||||||
|
<Skeleton className="h-72" />
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block md:basis-5/12">
|
||||||
|
<Skeleton className="h-80" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
276
app/account/orders/[id]/page.tsx
Normal file
276
app/account/orders/[id]/page.tsx
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
import { CheckCircleIcon, TruckIcon, ArrowLeftIcon } 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';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import OrderSummaryMobile from 'components/account/orders/order-summary-mobile';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import OrderSummary from 'components/account/orders/order-summary';
|
||||||
|
|
||||||
|
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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OrderPage({ params }: { params: { id: string } }) {
|
||||||
|
const order = await getCustomerOrder(params.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Suspense>
|
||||||
|
<OrderSummaryMobile order={order} />
|
||||||
|
</Suspense>
|
||||||
|
<div className="mx-auto max-w-6xl p-6">
|
||||||
|
<div className="mb-6 flex justify-between">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Link href="/account">
|
||||||
|
<ArrowLeftIcon className="mt-1 h-6 w-6" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<Heading as="h1">Order {order.name}</Heading>
|
||||||
|
<Label>Confirmed {toPrintDate(order.processedAt)}</Label>
|
||||||
|
</div>
|
||||||
|
</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 lg:block lg:basis-5/12">
|
||||||
|
<OrderSummary order={order} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
71
app/account/page.tsx
Normal file
71
app/account/page.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { getCustomerOrders } from 'lib/shopify';
|
||||||
|
import Text from 'components/ui/text';
|
||||||
|
import Price from 'components/price';
|
||||||
|
import Divider from 'components/divider';
|
||||||
|
import { Button } from 'components/button';
|
||||||
|
import Heading from 'components/ui/heading';
|
||||||
|
import Label from 'components/ui/label';
|
||||||
|
import Badge from 'components/ui/badge';
|
||||||
|
import { Card } from 'components/ui/card';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export default async function AccountPage() {
|
||||||
|
const orders = await getCustomerOrders();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<Heading className="pb-4" as="h1">
|
||||||
|
Orders
|
||||||
|
</Heading>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{orders.map((order, index) => (
|
||||||
|
<div className="relative" key={index}>
|
||||||
|
<Link
|
||||||
|
className="peer absolute left-0 top-0 h-full w-full"
|
||||||
|
href={`/account/orders/${order.id}`}
|
||||||
|
/>
|
||||||
|
<Card className="flex h-full flex-col transition-shadow peer-hover:shadow-lg peer-active:shadow-lg">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{order.lineItems.map((lineItem, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge content={lineItem.quantity!}>
|
||||||
|
<Image
|
||||||
|
src={lineItem?.image?.url}
|
||||||
|
alt={lineItem?.image?.altText}
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
className="rounded border"
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
<Text>{lineItem.title}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<div className="flex flex-1 flex-col justify-end gap-4">
|
||||||
|
<div>
|
||||||
|
<Text>
|
||||||
|
{order.lineItems.length} item{order.lineItems.length > 1 && 's'}
|
||||||
|
</Text>
|
||||||
|
<Label>Order {order.name}</Label>
|
||||||
|
</div>
|
||||||
|
<Price
|
||||||
|
amount={order.totalPrice!.amount}
|
||||||
|
currencyCode={order.totalPrice!.currencyCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button size="lg" className="mt-4">
|
||||||
|
Activate Warranty
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -4,6 +4,7 @@ import { GeistSans } from 'geist/font/sans';
|
|||||||
import { ensureStartsWith } from 'lib/utils';
|
import { ensureStartsWith } from 'lib/utils';
|
||||||
import { ReactNode, Suspense } from 'react';
|
import { ReactNode, Suspense } from 'react';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
import { AuthProvider } from 'contexts/auth-context';
|
||||||
|
|
||||||
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
|
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
||||||
@ -35,14 +36,20 @@ export const metadata = {
|
|||||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={GeistSans.variable}>
|
<html lang="en" className={GeistSans.variable}>
|
||||||
<body className="bg-white text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
|
<body className="min-h-screen bg-white text-black selection:bg-primary-muted dark:bg-neutral-900 dark:text-white dark:selection:bg-primary-emphasis dark:selection:text-white">
|
||||||
<header>
|
<AuthProvider>
|
||||||
<Banner />
|
{/* We need to have this wrapper div because the headless ui popover clickaway event is not working properly */}
|
||||||
<Navbar />
|
{/* https://github.com/tailwindlabs/headlessui/issues/2752#issuecomment-1724096430 */}
|
||||||
</header>
|
<div className="flex h-screen flex-col">
|
||||||
<Suspense>
|
<header>
|
||||||
<main>{children}</main>
|
<Banner />
|
||||||
</Suspense>
|
<Navbar />
|
||||||
|
</header>
|
||||||
|
<Suspense>
|
||||||
|
<main className="main group flex-1">{children}</main>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
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 { getCollection, getMenu, getProduct } from 'lib/shopify';
|
||||||
import { findParentCollection } from 'lib/utils';
|
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@ -9,6 +8,7 @@ import {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator
|
BreadcrumbSeparator
|
||||||
} from './breadcrumb-list';
|
} from './breadcrumb-list';
|
||||||
|
import { findParentCollection } from 'lib/utils';
|
||||||
|
|
||||||
type BreadcrumbProps = {
|
type BreadcrumbProps = {
|
||||||
type: 'product' | 'collection';
|
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"
|
className="h-full w-full object-cover"
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
alt={item.merchandise.product.featuredImage.altText || item.merchandise.product.title}
|
alt={
|
||||||
src={item.merchandise.product.featuredImage.url}
|
item.merchandise.product?.featuredImage?.altText || item.merchandise.product.title
|
||||||
|
}
|
||||||
|
src={item.merchandise.product?.featuredImage?.url}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -14,12 +14,15 @@ import CloseCart from './close-cart';
|
|||||||
import LineItem from './line-item';
|
import LineItem from './line-item';
|
||||||
import OpenCart from './open-cart';
|
import OpenCart from './open-cart';
|
||||||
import VehicleDetails, { VehicleFormSchema, vehicleFormSchema } from './vehicle-details';
|
import VehicleDetails, { VehicleFormSchema, vehicleFormSchema } from './vehicle-details';
|
||||||
|
import useAuth from 'hooks/use-auth';
|
||||||
|
|
||||||
export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const quantityRef = useRef(cart?.totalQuantity);
|
const quantityRef = useRef(cart?.totalQuantity);
|
||||||
const openCart = () => setIsOpen(true);
|
const openCart = () => setIsOpen(true);
|
||||||
const closeCart = () => setIsOpen(false);
|
const closeCart = () => setIsOpen(false);
|
||||||
|
const [checkoutUrl, setCheckoutUrl] = useState<string | undefined>(cart?.checkoutUrl);
|
||||||
const { control, handleSubmit } = useForm<VehicleFormSchema>({
|
const { control, handleSubmit } = useForm<VehicleFormSchema>({
|
||||||
resolver: zodResolver(vehicleFormSchema),
|
resolver: zodResolver(vehicleFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -45,6 +48,20 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
|||||||
}
|
}
|
||||||
}, [isOpen, cart?.totalQuantity, quantityRef]);
|
}, [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) => {
|
const onSubmit = async (data: VehicleFormSchema) => {
|
||||||
if (!cart) return;
|
if (!cart) return;
|
||||||
|
|
||||||
@ -136,7 +153,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href={cart.checkoutUrl} ref={linkRef} className="hidden">
|
<a href={checkoutUrl} ref={linkRef} className="hidden">
|
||||||
Proceed to Checkout
|
Proceed to Checkout
|
||||||
</a>
|
</a>
|
||||||
<button
|
<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 = ({
|
const Price = ({
|
||||||
amount,
|
amount,
|
||||||
className,
|
className,
|
||||||
|
as,
|
||||||
currencyCode = 'USD',
|
currencyCode = 'USD',
|
||||||
currencyCodeClassName,
|
currencyCodeClassName,
|
||||||
showCurrency = false,
|
showCurrency = false,
|
||||||
prefix
|
prefix
|
||||||
}: {
|
}: {
|
||||||
amount: string;
|
amount: string;
|
||||||
|
as?: 'p' | 'span';
|
||||||
className?: string;
|
className?: string;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
currencyCodeClassName?: string;
|
currencyCodeClassName?: string;
|
||||||
@ -23,9 +25,10 @@ const Price = ({
|
|||||||
return <p className={className}>Included</p>;
|
return <p className={className}>Included</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Component = as || 'p';
|
||||||
// Otherwise, format and display the price
|
// Otherwise, format and display the price
|
||||||
return (
|
return (
|
||||||
<p suppressHydrationWarning={true} className={className}>
|
<Component suppressHydrationWarning={true} className={className}>
|
||||||
{prefix}
|
{prefix}
|
||||||
{new Intl.NumberFormat(undefined, {
|
{new Intl.NumberFormat(undefined, {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@ -35,7 +38,7 @@ const Price = ({
|
|||||||
{showCurrency && (
|
{showCurrency && (
|
||||||
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{currencyCode}</span>
|
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{currencyCode}</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</Component>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,16 +1,46 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { CloseButton, Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
|
||||||
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
|
|
||||||
import { ArrowRightIcon } from '@heroicons/react/16/solid';
|
import { ArrowRightIcon } from '@heroicons/react/16/solid';
|
||||||
import { Menu } from 'lib/shopify/types';
|
import { Menu } from 'lib/shopify/types';
|
||||||
import { Fragment } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
import OpenProfile from './open-profile';
|
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 = {
|
type ProfilePopoverProps = {
|
||||||
menu: Menu[];
|
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 ProfilePopover = ({ menu }: ProfilePopoverProps) => {
|
||||||
|
const [message, action] = useFormState(doLogin, null);
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="relative">
|
<Popover className="relative">
|
||||||
<PopoverButton aria-label="Open Profile Menu" className="flex">
|
<PopoverButton aria-label="Open Profile Menu" className="flex">
|
||||||
@ -25,29 +55,52 @@ const ProfilePopover = ({ menu }: ProfilePopoverProps) => {
|
|||||||
leaveFrom="opacity-100 translate-y-0"
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
leaveTo="opacity-0 translate-y-1"
|
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">
|
<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>
|
<span className="text-sm font-medium">My Account</span>
|
||||||
<a
|
{!isAuthenticated && !loading && (
|
||||||
href="#"
|
<form action={action}>
|
||||||
className="mt-1 rounded-sm bg-primary p-2 text-center text-xs font-medium uppercase text-white hover:bg-secondary "
|
<SubmitButton message={message} />
|
||||||
>
|
</form>
|
||||||
Sign in
|
)}
|
||||||
</a>
|
|
||||||
{menu.length ? (
|
{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) => (
|
{menu.map((menuItem) => (
|
||||||
<li className="cursor-pointer py-2 hover:underline" key={menuItem.title}>
|
<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"
|
className="flex w-full flex-row items-center justify-between"
|
||||||
href={menuItem.path}
|
href={menuItem.path}
|
||||||
>
|
>
|
||||||
{menuItem.title} <ArrowRightIcon className="h-3" />
|
{menuItem.title} <ArrowRightIcon className="h-3" />
|
||||||
</a>
|
</CloseButton>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isAuthenticated && !loading && (
|
||||||
|
<Button
|
||||||
|
disabled={loggingOut}
|
||||||
|
onClick={() => {
|
||||||
|
setLoggingOut(true);
|
||||||
|
router.push('/logout');
|
||||||
|
}}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
{loggingOut ? 'Logging Out...' : 'Log Out'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PopoverPanel>
|
</PopoverPanel>
|
||||||
</Transition>
|
</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>;
|
||||||
|
}
|
38
contexts/auth-context.tsx
Normal file
38
contexts/auth-context.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
import { isLoggedIn } from 'components/auth/actions';
|
||||||
|
import { createContext, useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
type AuthContextType = {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType>({
|
||||||
|
isAuthenticated: false,
|
||||||
|
loading: true
|
||||||
|
});
|
||||||
|
|
||||||
|
type AuthProviderProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function checkAuth() {
|
||||||
|
const isLogged = await isLoggedIn();
|
||||||
|
setIsAuthenticated(isLogged);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ isAuthenticated, loading }}>{children}</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthContext;
|
11
hooks/use-auth.ts
Normal file
11
hooks/use-auth.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import AuthContext from 'contexts/auth-context';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
export default function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
508
lib/shopify/auth.ts
Normal file
508
lib/shopify/auth.ts
Normal file
@ -0,0 +1,508 @@
|
|||||||
|
import { cookies } from 'next/headers';
|
||||||
|
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,
|
||||||
|
newOrigin: string,
|
||||||
|
customerAccountApiUrl: string,
|
||||||
|
clientId: string
|
||||||
|
) {
|
||||||
|
const code = request.nextUrl.searchParams.get('code');
|
||||||
|
const state = request.nextUrl.searchParams.get('state');
|
||||||
|
/*
|
||||||
|
STEP 1: Check for all necessary cookies and other information
|
||||||
|
*/
|
||||||
|
if (!code) {
|
||||||
|
console.log('Error: No Code Auth');
|
||||||
|
return { success: false, message: `No Code` };
|
||||||
|
}
|
||||||
|
if (!state) {
|
||||||
|
console.log('Error: No State Auth');
|
||||||
|
return { success: false, message: `No State` };
|
||||||
|
}
|
||||||
|
const shopState = request.cookies.get('shop_state');
|
||||||
|
const shopStateValue = shopState?.value;
|
||||||
|
if (!shopStateValue) {
|
||||||
|
console.log('Error: No Shop State Value');
|
||||||
|
return { success: false, message: `No Shop State` };
|
||||||
|
}
|
||||||
|
if (state !== shopStateValue) {
|
||||||
|
console.log('Error: Shop state mismatch');
|
||||||
|
return { success: false, message: `No Shop State Mismatch` };
|
||||||
|
}
|
||||||
|
const codeVerifier = request.cookies.get('shop_verifier');
|
||||||
|
const codeVerifierValue = codeVerifier?.value;
|
||||||
|
if (!codeVerifierValue) {
|
||||||
|
console.log('No Code Verifier');
|
||||||
|
return { success: false, message: `No Code Verifier` };
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
STEP 2: GET ACCESS TOKEN
|
||||||
|
*/
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.append('grant_type', 'authorization_code');
|
||||||
|
body.append('client_id', clientId);
|
||||||
|
body.append('redirect_uri', `${newOrigin}/authorize`);
|
||||||
|
body.append('code', code);
|
||||||
|
body.append('code_verifier', codeVerifier?.value);
|
||||||
|
const userAgent = '*';
|
||||||
|
const headersNew = new Headers();
|
||||||
|
headersNew.append('Content-Type', 'application/x-www-form-urlencoded');
|
||||||
|
headersNew.append('User-Agent', userAgent);
|
||||||
|
headersNew.append('Origin', newOrigin || '');
|
||||||
|
const tokenRequestUrl = `${customerAccountApiUrl}/auth/oauth/token`;
|
||||||
|
|
||||||
|
const response = await fetch(tokenRequestUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headersNew,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
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;
|
||||||
|
if (nonceValue !== shopNonceValue) {
|
||||||
|
console.log('Error nonce match');
|
||||||
|
return { success: false, message: `Error: Nonce mismatch` };
|
||||||
|
}
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeAccessToken(
|
||||||
|
token: string,
|
||||||
|
customerAccountId: string,
|
||||||
|
customerAccountApiUrl: string,
|
||||||
|
origin: string
|
||||||
|
) {
|
||||||
|
const clientId = customerAccountId;
|
||||||
|
//this is a constant - see the docs. https://shopify.dev/docs/api/customer#useaccesstoken-propertydetail-audience
|
||||||
|
const customerApiClientId = '30243aa5-17c1-465a-8493-944bcc4e88aa';
|
||||||
|
const accessToken = token;
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.append('grant_type', 'urn:ietf:params:oauth:grant-type:token-exchange');
|
||||||
|
body.append('client_id', clientId);
|
||||||
|
body.append('audience', customerApiClientId);
|
||||||
|
body.append('subject_token', accessToken);
|
||||||
|
body.append('subject_token_type', 'urn:ietf:params:oauth:token-type:access_token');
|
||||||
|
body.append('scopes', 'https://api.customers.com/auth/customer.graphql');
|
||||||
|
|
||||||
|
const userAgent = '*';
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.append('Content-Type', 'application/x-www-form-urlencoded');
|
||||||
|
headers.append('User-Agent', userAgent);
|
||||||
|
headers.append('Origin', origin);
|
||||||
|
|
||||||
|
// Token Endpoint goes here
|
||||||
|
const response = await fetch(`${customerAccountApiUrl}/auth/oauth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.error) {
|
||||||
|
return { success: false, data: data?.error_description };
|
||||||
|
}
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshToken({ request, origin }: { request: NextRequest; origin: string }) {
|
||||||
|
const newBody = new URLSearchParams();
|
||||||
|
const refreshToken = request.cookies.get('shop_refresh_token');
|
||||||
|
const refreshTokenValue = refreshToken?.value;
|
||||||
|
if (!refreshTokenValue) {
|
||||||
|
console.log('Error: No Refresh Token');
|
||||||
|
return { success: false, message: `no_refresh_token` };
|
||||||
|
}
|
||||||
|
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': USER_AGENT,
|
||||||
|
Origin: origin
|
||||||
|
};
|
||||||
|
const tokenRequestUrl = `${CUSTOMER_API_URL}/auth/oauth/token`;
|
||||||
|
const response = await fetch(tokenRequestUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: newBody
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
console.log('response error in refresh token', text);
|
||||||
|
return { success: false, message: `no_refresh_token` };
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('data response from initial fetch to refresh', data);
|
||||||
|
const { access_token, expires_in, refresh_token } = data;
|
||||||
|
|
||||||
|
const customerAccessToken = await exchangeAccessToken(
|
||||||
|
access_token,
|
||||||
|
clientId,
|
||||||
|
CUSTOMER_API_URL,
|
||||||
|
origin
|
||||||
|
);
|
||||||
|
// console.log("Customer Access Token in refresh request", customerAccessToken)
|
||||||
|
if (!customerAccessToken.success) {
|
||||||
|
return { success: false, message: `no_refresh_token` };
|
||||||
|
}
|
||||||
|
|
||||||
|
//const expiresAt = new Date(new Date().getTime() + (expires_in - 120) * 1000).getTime() + ''
|
||||||
|
//const idToken = id_token
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { customerAccessToken: customerAccessToken.data.access_token, expires_in, refresh_token }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkExpires({
|
||||||
|
request,
|
||||||
|
expiresAt,
|
||||||
|
origin
|
||||||
|
}: {
|
||||||
|
request: NextRequest;
|
||||||
|
expiresAt: string;
|
||||||
|
origin: string;
|
||||||
|
}) {
|
||||||
|
let isExpired = false;
|
||||||
|
if (parseInt(expiresAt, 10) - 1000 < new Date().getTime()) {
|
||||||
|
isExpired = true;
|
||||||
|
console.log('Isexpired is true, we are running refresh token!');
|
||||||
|
const refresh = await refreshToken({ request, origin });
|
||||||
|
console.log('refresh', refresh);
|
||||||
|
//this will return success: true or success: false - depending on result of refresh
|
||||||
|
return { ranRefresh: isExpired, refresh };
|
||||||
|
}
|
||||||
|
console.log('is expired is false - just sending back success', isExpired);
|
||||||
|
return { ranRefresh: isExpired, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
response.cookies.delete('shop_id_token');
|
||||||
|
response.cookies.delete('shop_state');
|
||||||
|
response.cookies.delete('shop_nonce');
|
||||||
|
response.cookies.delete('shop_verifier');
|
||||||
|
response.cookies.delete('shop_expires_at');
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeAllCookiesServerAction() {
|
||||||
|
cookies().delete('shop_customer_token');
|
||||||
|
cookies().delete('shop_refresh_token');
|
||||||
|
cookies().delete('shop_id_token');
|
||||||
|
cookies().delete('shop_state');
|
||||||
|
cookies().delete('shop_nonce');
|
||||||
|
cookies().delete('shop_verifier');
|
||||||
|
cookies().delete('shop_expires_at');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAllCookies({
|
||||||
|
response,
|
||||||
|
customerAccessToken,
|
||||||
|
expires_in,
|
||||||
|
refresh_token,
|
||||||
|
expiresAt,
|
||||||
|
id_token
|
||||||
|
}: {
|
||||||
|
response: NextResponse;
|
||||||
|
customerAccessToken: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token: string;
|
||||||
|
expiresAt: string;
|
||||||
|
id_token?: string;
|
||||||
|
}) {
|
||||||
|
response.cookies.set('shop_customer_token', customerAccessToken, {
|
||||||
|
httpOnly: true, //if true can only read the cookie in server
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: true,
|
||||||
|
path: '/',
|
||||||
|
maxAge: expires_in //value from shopify, seems like this is 2 hours
|
||||||
|
});
|
||||||
|
|
||||||
|
//you need to set an expiration here, because otherwise its a sessions cookie
|
||||||
|
//and will disappear after the user closes the browser and then we can never refresh - same with expires at below
|
||||||
|
response.cookies.set('shop_refresh_token', refresh_token, {
|
||||||
|
httpOnly: true, //if true can only read the cookie in server
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: true,
|
||||||
|
path: '/',
|
||||||
|
maxAge: 604800 //one week
|
||||||
|
});
|
||||||
|
|
||||||
|
//you need to set an expiration here, because otherwise its a sessions cookie
|
||||||
|
//and will disappear after the user closes the browser and then we can never refresh
|
||||||
|
response.cookies.set('shop_expires_at', expiresAt, {
|
||||||
|
httpOnly: true, //if true can only read the cookie in server
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: true,
|
||||||
|
path: '/',
|
||||||
|
maxAge: 604800 //one week
|
||||||
|
});
|
||||||
|
|
||||||
|
//required for logout - this must be the same as the original expires - it;s long lived so they can logout, otherwise it will expire
|
||||||
|
//because that's how we got the token, if this is different, it won't work
|
||||||
|
//we don't always send in id_token here. For example, on refresh it's not available, it's only sent in on the initial authorization
|
||||||
|
if (id_token) {
|
||||||
|
response.cookies.set('shop_id_token', id_token, {
|
||||||
|
httpOnly: true, //if true can only read the cookie in server
|
||||||
|
sameSite: 'lax', //should be lax???
|
||||||
|
secure: true,
|
||||||
|
path: '/',
|
||||||
|
maxAge: 604800 //one week
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
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);
|
||||||
|
}
|
18
lib/shopify/fragments/customer-address.ts
Normal file
18
lib/shopify/fragments/customer-address.ts
Normal 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;
|
36
lib/shopify/fragments/customer-details.ts
Normal file
36
lib/shopify/fragments/customer-details.ts
Normal 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;
|
36
lib/shopify/fragments/order-card.ts
Normal file
36
lib/shopify/fragments/order-card.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
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
|
||||||
|
quantity
|
||||||
|
image {
|
||||||
|
altText
|
||||||
|
height
|
||||||
|
url
|
||||||
|
width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default orderCard;
|
@ -12,7 +12,7 @@ import {
|
|||||||
YEAR_FILTER_ID
|
YEAR_FILTER_ID
|
||||||
} from 'lib/constants';
|
} from 'lib/constants';
|
||||||
import { isShopifyError } from 'lib/type-guards';
|
import { isShopifyError } from 'lib/type-guards';
|
||||||
import { ensureStartsWith, normalizeUrl, parseMetaFieldValue } from 'lib/utils';
|
import { ensureStartsWith, normalizeUrl, parseJSON, parseMetaFieldValue } from 'lib/utils';
|
||||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
@ -39,15 +39,20 @@ import {
|
|||||||
getProductsQuery
|
getProductsQuery
|
||||||
} from './queries/product';
|
} from './queries/product';
|
||||||
import {
|
import {
|
||||||
|
Address,
|
||||||
Cart,
|
Cart,
|
||||||
CartAttributeInput,
|
CartAttributeInput,
|
||||||
Collection,
|
Collection,
|
||||||
Connection,
|
Connection,
|
||||||
|
Customer,
|
||||||
Filter,
|
Filter,
|
||||||
Image,
|
Image,
|
||||||
Menu,
|
Menu,
|
||||||
Metaobject,
|
Metaobject,
|
||||||
Money,
|
Money,
|
||||||
|
Order,
|
||||||
|
Fulfillment,
|
||||||
|
Transaction,
|
||||||
Page,
|
Page,
|
||||||
PageInfo,
|
PageInfo,
|
||||||
Product,
|
Product,
|
||||||
@ -60,6 +65,9 @@ import {
|
|||||||
ShopifyCollectionProductsOperation,
|
ShopifyCollectionProductsOperation,
|
||||||
ShopifyCollectionsOperation,
|
ShopifyCollectionsOperation,
|
||||||
ShopifyCreateCartOperation,
|
ShopifyCreateCartOperation,
|
||||||
|
ShopifyCustomerOperation,
|
||||||
|
ShopifyCustomerOrderOperation,
|
||||||
|
ShopifyCustomerOrdersOperation,
|
||||||
ShopifyFilter,
|
ShopifyFilter,
|
||||||
ShopifyImageOperation,
|
ShopifyImageOperation,
|
||||||
ShopifyMenuOperation,
|
ShopifyMenuOperation,
|
||||||
@ -76,13 +84,31 @@ import {
|
|||||||
ShopifyRemoveFromCartOperation,
|
ShopifyRemoveFromCartOperation,
|
||||||
ShopifySetCartAttributesOperation,
|
ShopifySetCartAttributesOperation,
|
||||||
ShopifyUpdateCartOperation,
|
ShopifyUpdateCartOperation,
|
||||||
TransmissionType
|
TransmissionType,
|
||||||
|
ShopifyCustomer,
|
||||||
|
ShopifyOrder,
|
||||||
|
ShopifyAddress,
|
||||||
|
ShopifyMoneyV2,
|
||||||
|
LineItem
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { getCustomerQuery } from './queries/customer';
|
||||||
|
import { getCustomerOrdersQuery } from './queries/orders';
|
||||||
|
import { getCustomerOrderQuery } from './queries/order';
|
||||||
|
|
||||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
|
? 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!;
|
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
|
||||||
|
|
||||||
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
|
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
|
||||||
@ -101,7 +127,7 @@ export async function shopifyFetch<T>({
|
|||||||
variables?: ExtractVariables<T>;
|
variables?: ExtractVariables<T>;
|
||||||
}): Promise<{ status: number; body: T } | never> {
|
}): Promise<{ status: number; body: T } | never> {
|
||||||
try {
|
try {
|
||||||
const result = await fetch(endpoint, {
|
const result = await fetch(storefrontEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@ -143,6 +169,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>) => {
|
const removeEdgesAndNodes = (array: Connection<any>) => {
|
||||||
return array.edges.map((edge) => edge?.node);
|
return array.edges.map((edge) => edge?.node);
|
||||||
};
|
};
|
||||||
@ -337,6 +437,142 @@ const reshapeProducts = (products: ShopifyProduct[]) => {
|
|||||||
return reshapedProducts;
|
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: 62,
|
||||||
|
height: 62
|
||||||
|
}
|
||||||
|
})) || []
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
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: 62,
|
||||||
|
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) {
|
||||||
|
order.shippingMethod = {
|
||||||
|
name: shopifyOrder.shippingLine?.title,
|
||||||
|
price: reshapeMoney(shopifyOrder.shippingLine.originalPrice)!
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createCart(): Promise<Cart> {
|
export async function createCart(): Promise<Cart> {
|
||||||
const res = await shopifyFetch<ShopifyCreateCartOperation>({
|
const res = await shopifyFetch<ShopifyCreateCartOperation>({
|
||||||
query: createCartMutation,
|
query: createCartMutation,
|
||||||
@ -687,6 +923,33 @@ export async function getProducts({
|
|||||||
pageInfo
|
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.
|
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
|
||||||
export async function revalidate(req: NextRequest): Promise<NextResponse> {
|
export async function revalidate(req: NextRequest): Promise<NextResponse> {
|
||||||
console.log(`Receiving revalidation request from Shopify.`);
|
console.log(`Receiving revalidation request from Shopify.`);
|
||||||
|
13
lib/shopify/queries/customer.ts
Normal file
13
lib/shopify/queries/customer.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
240
lib/shopify/queries/order.ts
Normal file
240
lib/shopify/queries/order.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
15
lib/shopify/queries/orders.ts
Normal file
15
lib/shopify/queries/orders.ts
Normal 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}
|
||||||
|
`;
|
@ -46,6 +46,13 @@ export type Collection = Omit<ShopifyCollection, 'helpfulLinks' | 'helpfulLinksT
|
|||||||
helpfulLinksTop: string[] | null;
|
helpfulLinksTop: string[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Customer = {
|
||||||
|
emailAddress: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Image = {
|
export type Image = {
|
||||||
url: string;
|
url: string;
|
||||||
altText: string;
|
altText: string;
|
||||||
@ -69,6 +76,270 @@ export type PageMetafield = {
|
|||||||
value: string;
|
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 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 = [
|
export const PAGE_TYPES = [
|
||||||
'image',
|
'image',
|
||||||
'icon_content_section',
|
'icon_content_section',
|
||||||
@ -445,6 +716,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 = {
|
export type CoreChargeOption = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
@ -1,5 +1,16 @@
|
|||||||
export const colors = {
|
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',
|
dark: '#091242',
|
||||||
secondary: '#EF6C02',
|
secondary: '#EF6C02',
|
||||||
blue: {
|
blue: {
|
||||||
|
18
lib/utils.ts
18
lib/utils.ts
@ -14,7 +14,14 @@ export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
|
|||||||
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
|
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
|
||||||
|
|
||||||
export const validateEnvironmentVariables = () => {
|
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[];
|
const missingEnvironmentVariables = [] as string[];
|
||||||
|
|
||||||
requiredEnvironmentVariables.forEach((envVar) => {
|
requiredEnvironmentVariables.forEach((envVar) => {
|
||||||
@ -71,3 +78,12 @@ export const findParentCollection = (menu: Menu[], collection: string): Menu | n
|
|||||||
}
|
}
|
||||||
return parentCollection;
|
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;
|
||||||
|
}
|
||||||
|
49
middleware.ts
Normal file
49
middleware.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
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) {
|
||||||
|
/****
|
||||||
|
Authorize Middleware to get access tokens
|
||||||
|
*****/
|
||||||
|
if (request.nextUrl.pathname.startsWith('/authorize')) {
|
||||||
|
console.log('Running Initial Authorization Middleware');
|
||||||
|
const origin = getOrigin(request);
|
||||||
|
console.log('origin', origin);
|
||||||
|
return await authorize(request, origin);
|
||||||
|
}
|
||||||
|
/****
|
||||||
|
END OF Authorize Middleware to get access tokens
|
||||||
|
*****/
|
||||||
|
|
||||||
|
/****
|
||||||
|
LOGOUT -
|
||||||
|
*****/
|
||||||
|
if (request.nextUrl.pathname.startsWith('/logout')) {
|
||||||
|
console.log('Running Logout middleware');
|
||||||
|
const origin = getOrigin(request);
|
||||||
|
return await logout(request, origin);
|
||||||
|
}
|
||||||
|
/****
|
||||||
|
END OF LOGOUT
|
||||||
|
*****/
|
||||||
|
/****
|
||||||
|
Account
|
||||||
|
*****/
|
||||||
|
|
||||||
|
if (request.nextUrl.pathname.startsWith('/account')) {
|
||||||
|
console.log('Running Account middleware');
|
||||||
|
//const newHeaders = new Headers(request.headers)
|
||||||
|
const origin = getOrigin(request);
|
||||||
|
//console.log ("origin", origin)
|
||||||
|
return await isLoggedIn(request, origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/****
|
||||||
|
END OF Account
|
||||||
|
*****/
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/authorize', '/logout', '/account/:path*']
|
||||||
|
};
|
@ -2,7 +2,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@9.1.2",
|
"packageManager": "pnpm@9.1.2",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18",
|
"node": ">=20",
|
||||||
"pnpm": ">=7"
|
"pnpm": ">=7"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -26,17 +26,19 @@
|
|||||||
"@heroicons/react": "^2.1.3",
|
"@heroicons/react": "^2.1.3",
|
||||||
"@hookform/resolvers": "^3.6.0",
|
"@hookform/resolvers": "^3.6.0",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"geist": "^1.3.0",
|
"geist": "^1.3.0",
|
||||||
"lodash.get": "^4.4.2",
|
"lodash.get": "^4.4.2",
|
||||||
"lodash.kebabcase": "^4.1.1",
|
"lodash.kebabcase": "^4.1.1",
|
||||||
"lodash.startcase": "^4.4.0",
|
"lodash.startcase": "^4.4.0",
|
||||||
"next": "14.1.4",
|
"next": "14.2.4",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.51.5",
|
"react-hook-form": "^7.51.5",
|
||||||
"react-tooltip": "^5.26.3",
|
"react-tooltip": "^5.26.3",
|
||||||
"tailwind-merge": "^2.2.2",
|
"tailwind-merge": "^2.2.2",
|
||||||
|
"tailwind-variants": "^0.2.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -53,7 +55,7 @@
|
|||||||
"@vercel/git-hooks": "^1.0.0",
|
"@vercel/git-hooks": "^1.0.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-next": "^14.1.4",
|
"eslint-config-next": "^14.2.4",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-unicorn": "^51.0.1",
|
"eslint-plugin-unicorn": "^51.0.1",
|
||||||
"lint-staged": "^15.2.2",
|
"lint-staged": "^15.2.2",
|
||||||
|
5244
pnpm-lock.yaml
generated
5244
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,14 @@ module.exports = {
|
|||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['var(--font-geist-sans)']
|
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: {
|
keyframes: {
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
from: { opacity: 0 },
|
from: { opacity: 0 },
|
||||||
@ -37,7 +45,6 @@ module.exports = {
|
|||||||
hoverOnlyWhenSupported: true
|
hoverOnlyWhenSupported: true
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
require('@tailwindcss/container-queries'),
|
|
||||||
require('@tailwindcss/typography'),
|
require('@tailwindcss/typography'),
|
||||||
plugin(({ matchUtilities, theme }) => {
|
plugin(({ matchUtilities, theme }) => {
|
||||||
matchUtilities(
|
matchUtilities(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user