Merge pull request #5 from Car-Part-Planet/CPP-152

Order Confirmation
This commit is contained in:
Teodor Raykov 2024-07-01 23:06:15 +03:00 committed by GitHub
commit eec6518905
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 2429 additions and 406 deletions

View File

@ -1,18 +1,19 @@
import { ArrowLeftIcon, CheckCircleIcon, TruckIcon } from '@heroicons/react/24/outline';
import ActivateWarranty from 'components/orders/activate-warranty';
import OrderConfirmation from 'components/orders/order-confirmation';
import PaymentsDetails from 'components/orders/payment-details';
import OrderSummary from 'components/orders/order-summary';
import OrderSummaryMobile from 'components/orders/order-summary-mobile';
import Price from 'components/price';
import Badge from 'components/ui/badge';
import { Card } from 'components/ui/card';
import { Card } from 'components/ui';
import Heading from 'components/ui/heading';
import Label from 'components/ui/label';
import Text from 'components/ui/text';
import { getCustomerOrder, getOrderMetafields } from 'lib/shopify';
import { getCustomerOrder } from 'lib/shopify';
import { Fulfillment, Order } from 'lib/shopify/types';
import { toPrintDate } from 'lib/utils';
import Image from 'next/image';
import Link from 'next/link';
import ActivateWarranty from 'components/orders/activate-warranty';
function Unfulfilled({ order }: { order: Order }) {
// Build a map of line item IDs to quantities fulfilled
@ -144,30 +145,6 @@ function Fulfillments({ order }: { order: Order }) {
);
}
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">
@ -228,10 +205,7 @@ function OrderDetails({ order }: { order: Order }) {
}
export default async function OrderPage({ params }: { params: { id: string } }) {
const [order, orderMetafields] = await Promise.all([
getCustomerOrder(params.id),
getOrderMetafields(params.id)
]);
const order = await getCustomerOrder(params.id);
return (
<>
@ -247,7 +221,10 @@ export default async function OrderPage({ params }: { params: { id: string } })
<Label>Confirmed {toPrintDate(order.processedAt)}</Label>
</div>
</div>
<ActivateWarranty order={order} orderMetafields={orderMetafields} />
<div className="flex items-start gap-2">
<OrderConfirmation order={order} />
<ActivateWarranty order={order} />
</div>
</div>
<div className="flex items-start gap-6">
<div className="flex flex-1 flex-col gap-6">

View File

@ -1,18 +1,19 @@
import { InformationCircleIcon } from '@heroicons/react/24/outline';
import ActivateWarranty from 'components/orders/activate-warranty';
import MobileOrderActions from 'components/orders/mobile-order-actions';
import OrdersHeader from 'components/orders/orders-header';
import Price from 'components/price';
import { getCustomerOrders, getOrdersMetafields } from 'lib/shopify';
import { toPrintDate } from 'lib/utils';
import { getCustomerOrders } from 'lib/shopify';
import { isBeforeToday, toPrintDate } from 'lib/utils';
import Image from 'next/image';
import Link from 'next/link';
import { Button } from 'components/ui';
import dynamic from 'next/dynamic';
const OrderConfirmation = dynamic(() => import('components/orders/order-confirmation'));
const ActivateWarranty = dynamic(() => import('components/orders/activate-warranty'));
export default async function AccountPage() {
const [orders, ordersMetafields] = await Promise.all([
getCustomerOrders(),
getOrdersMetafields()
]);
const orders = await getCustomerOrders();
return (
<div className="py-5 sm:py-10">
@ -54,17 +55,19 @@ export default async function AccountPage() {
)}
</dl>
<MobileOrderActions order={order} orderMetafields={ordersMetafields[order.id]} />
<MobileOrderActions order={order} />
<div className="hidden lg:col-span-2 lg:flex lg:items-center lg:justify-end lg:space-x-4">
<Link
href={`/account/orders/${order.normalizedId}`}
className="flex items-center justify-center rounded-md border border-gray-300 bg-white px-2.5 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<span>View Order</span>
<span className="sr-only">{order.normalizedId}</span>
<Link href={`/account/orders/${order.normalizedId}`} passHref legacyBehavior>
<Button as="a">
View Order
<span className="sr-only">{order.normalizedId}</span>
</Button>
</Link>
<ActivateWarranty order={order} orderMetafields={ordersMetafields[order.id]} />
{!isBeforeToday(order?.warrantyActivationDeadline?.value) && (
<ActivateWarranty order={order} />
)}
{!order.orderConfirmation && <OrderConfirmation order={order} />}
</div>
</div>

View File

@ -0,0 +1,7 @@
import { getOrderConfirmationContent } from 'lib/shopify';
export async function GET() {
const data = await getOrderConfirmationContent();
return Response.json({ ...data });
}

View File

@ -1,4 +1,4 @@
import { getMenu, getMetaobjects } from 'lib/shopify';
import { getAllMetaobjects, getMenu } from 'lib/shopify';
import FiltersList from './filters-list';
const title: Record<string, string> = {
@ -12,9 +12,9 @@ const title: Record<string, string> = {
const { STORE_PREFIX } = process.env;
const HomePageFilters = async () => {
const yearsData = getMetaobjects('make_model_year_composite');
const modelsData = getMetaobjects('make_model_composite');
const makesData = getMetaobjects('make');
const yearsData = getAllMetaobjects('make_model_year_composite');
const modelsData = getAllMetaobjects('make_model_composite');
const makesData = getAllMetaobjects('make');
const [years, models, makes] = await Promise.all([yearsData, modelsData, makesData]);
const menu = await getMenu('main-menu');

View File

@ -1,9 +1,16 @@
'use server';
import { renderToBuffer } from '@react-pdf/renderer';
import OrderConfirmationPdf from 'components/orders/order-confirmation-pdf';
import { handleUploadFile } from 'components/form/file-input/actions';
import { TAGS } from 'lib/constants';
import { updateOrderMetafields } from 'lib/shopify';
import { ShopifyOrderMetafield, UpdateOrderMetafieldInput } from 'lib/shopify/types';
import {
Order,
OrderConfirmationContent,
ShopifyOrderMetafield,
UpdateOrderMetafieldInput
} from 'lib/shopify/types';
import { revalidateTag } from 'next/cache';
const getMetafieldValue = (
@ -16,11 +23,7 @@ const getMetafieldValue = (
: { ...newValue, namespace: 'custom' };
};
export const activateWarranty = async (
orderId: string,
formData: FormData,
orderMetafields?: ShopifyOrderMetafield
) => {
export const activateWarranty = async (order: Order, formData: FormData) => {
let odometerFileId = null;
let installationFileId = null;
const odometerFile = formData.get('warranty_activation_odometer');
@ -32,7 +35,7 @@ export const activateWarranty = async (
if (installationFile) {
installationFileId = await handleUploadFile({ file: installationFile as File });
}
console.log(formData.get('warranty_activation_self_install'));
// https://shopify.dev/docs/api/admin-graphql/2024-01/mutations/orderUpdate
const rawFormData = [
getMetafieldValue(
@ -42,7 +45,7 @@ export const activateWarranty = async (
value: odometerFileId,
type: 'file_reference'
},
orderMetafields
order
),
getMetafieldValue(
'warrantyActivationInstallation',
@ -51,7 +54,7 @@ export const activateWarranty = async (
value: installationFileId,
type: 'file_reference'
},
orderMetafields
order
),
getMetafieldValue(
'warrantyActivationSelfInstall',
@ -60,7 +63,7 @@ export const activateWarranty = async (
value: formData.get('warranty_activation_self_install') === 'on' ? 'true' : 'false',
type: 'boolean'
},
orderMetafields
order
),
getMetafieldValue(
'warrantyActivationMileage',
@ -69,7 +72,7 @@ export const activateWarranty = async (
value: formData.get('warranty_activation_mileage') as string | null,
type: 'number_integer'
},
orderMetafields
order
),
getMetafieldValue(
'warrantyActivationVIN',
@ -78,13 +81,76 @@ export const activateWarranty = async (
value: formData.get('warranty_activation_vin') as string | null,
type: 'single_line_text_field'
},
orderMetafields
order
)
];
try {
await updateOrderMetafields({
orderId,
orderId: order.id,
metafields: rawFormData
});
revalidateTag(TAGS.orderMetafields);
} catch (error) {
console.log('activateWarranty action', error);
}
};
async function generateOrderConfirmationPDF(
order: Order,
content: OrderConfirmationContent,
signature1: string,
signature2: string,
signDate: string
) {
return renderToBuffer(
<OrderConfirmationPdf
order={order}
content={content}
signature1={signature1}
signature2={signature2}
date={signDate}
/>
);
}
type ConfirmOrderOptions = {
order: Order;
content: OrderConfirmationContent;
formData: FormData;
};
export const confirmOrder = async ({ order, content, formData }: ConfirmOrderOptions) => {
const signature1 = formData.get('signature1') as string;
const signature2 = formData.get('signature2') as string;
const signDate = formData.get('date') as string;
const pdfBuffer = await generateOrderConfirmationPDF(
order,
content,
signature1,
signature2,
signDate
);
const fileName = `${new Date().getTime()}-${order.name}-signaturePdf.pdf`;
const file = new File([pdfBuffer], fileName, { type: 'application/pdf' });
const confirmationPDFId = await handleUploadFile({ file });
const rawFormData = [
{
key: 'customer_confirmation',
value: confirmationPDFId,
type: 'file_reference',
namespace: 'custom'
}
];
try {
await updateOrderMetafields({
orderId: order.id,
metafields: rawFormData
});

View File

@ -6,23 +6,17 @@ import CheckboxField from 'components/form/checkbox-field';
import FileInput from 'components/form/file-input';
import Input from 'components/form/input-field';
import LoadingDots from 'components/loading-dots';
import { ShopifyOrderMetafield } from 'lib/shopify/types';
import { Order } from 'lib/shopify/types';
import { FormEventHandler, useRef, useTransition } from 'react';
import { activateWarranty } from './actions';
type ActivateWarrantyModalProps = {
isOpen: boolean;
onClose: () => void;
orderId: string;
orderMetafields?: ShopifyOrderMetafield;
order: Order;
};
function ActivateWarrantyModal({
onClose,
isOpen,
orderId,
orderMetafields
}: ActivateWarrantyModalProps) {
function ActivateWarrantyModal({ onClose, isOpen, order }: ActivateWarrantyModalProps) {
const [pending, startTransition] = useTransition();
const formRef = useRef<HTMLFormElement>(null);
@ -33,7 +27,7 @@ function ActivateWarrantyModal({
const formData = new FormData(form);
startTransition(async () => {
await activateWarranty(orderId, formData, orderMetafields);
await activateWarranty(order, formData);
form.reset();
onClose();
});
@ -59,28 +53,28 @@ function ActivateWarrantyModal({
<FileInput
label="Odometer"
name="warranty_activation_odometer"
fileId={orderMetafields?.warrantyActivationOdometer?.value}
fileId={order?.warrantyActivationOdometer?.value}
/>
<FileInput
label="Installation Receipt"
name="warranty_activation_installation"
fileId={orderMetafields?.warrantyActivationInstallation?.value}
fileId={order?.warrantyActivationInstallation?.value}
/>
<CheckboxField
label="Self Installed"
name="warranty_activation_self_install"
defaultChecked={orderMetafields?.warrantyActivationSelfInstall?.value === 'true'}
defaultChecked={order?.warrantyActivationSelfInstall?.value === 'true'}
/>
<Input
label="Customer Mileage"
name="warranty_activation_mileage"
type="number"
defaultValue={orderMetafields?.warrantyActivationMileage?.value}
defaultValue={order?.warrantyActivationMileage?.value}
/>
<Input
label="Customer VIN"
name="warranty_activation_vin"
defaultValue={orderMetafields?.warrantyActivationVIN?.value}
defaultValue={order?.warrantyActivationVIN?.value}
/>
</div>
<div className="mt-4 flex w-full justify-end gap-4">

View File

@ -1,20 +1,20 @@
'use client';
import { Order, ShopifyOrderMetafield, WarrantyStatus } from 'lib/shopify/types';
import { Order, WarrantyStatus } from 'lib/shopify/types';
import { isBeforeToday } from 'lib/utils';
import { useState } from 'react';
import ActivateWarrantyModal from './activate-warranty-modal';
import WarrantyActivatedBadge from './warranty-activated-badge';
import { Button } from 'components/ui';
type ActivateWarrantyModalProps = {
order: Order;
orderMetafields?: ShopifyOrderMetafield;
};
const ActivateWarranty = ({ order, orderMetafields }: ActivateWarrantyModalProps) => {
const ActivateWarranty = ({ order }: ActivateWarrantyModalProps) => {
const [isOpen, setIsOpen] = useState(false);
const isWarrantyActivated = orderMetafields?.warrantyStatus?.value === WarrantyStatus.Activated;
const isPassDeadline = isBeforeToday(orderMetafields?.warrantyActivationDeadline?.value);
const isWarrantyActivated = order?.warrantyStatus?.value === WarrantyStatus.Activated;
const isPassDeadline = isBeforeToday(order?.warrantyActivationDeadline?.value);
if (isWarrantyActivated) {
return <WarrantyActivatedBadge />;
@ -26,18 +26,8 @@ const ActivateWarranty = ({ order, orderMetafields }: ActivateWarrantyModalProps
return (
<>
<button
className="flex h-fit items-center justify-center rounded-md border border-gray-300 bg-white px-2.5 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
onClick={() => setIsOpen(true)}
>
Activate Warranty
</button>
<ActivateWarrantyModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
orderId={order.id}
orderMetafields={orderMetafields}
/>
<Button onClick={() => setIsOpen(true)}>Activate Warranty</Button>
<ActivateWarrantyModal isOpen={isOpen} onClose={() => setIsOpen(false)} order={order} />
</>
);
};

View File

@ -1,24 +1,24 @@
'use client';
import dynamic from 'next/dynamic';
import { Button, Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import clsx from 'clsx';
import { Order, ShopifyOrderMetafield, WarrantyStatus } from 'lib/shopify/types';
import { Order, WarrantyStatus } from 'lib/shopify/types';
import { isBeforeToday } from 'lib/utils';
import Link from 'next/link';
import { useState } from 'react';
import ActivateWarrantyModal from './activate-warranty-modal';
const MobileOrderActions = ({
order,
orderMetafields
}: {
order: Order;
orderMetafields?: ShopifyOrderMetafield;
}) => {
const [isOpen, setIsOpen] = useState(false);
const isWarrantyActivated = orderMetafields?.warrantyStatus?.value === WarrantyStatus.Activated;
const isPassDeadline = isBeforeToday(orderMetafields?.warrantyActivationDeadline?.value);
const OrderConfirmationModal = dynamic(() => import('./order-confirmation-modal'));
const MobileOrderActions = ({ order }: { order: Order }) => {
const [isWarrantyOpen, setIsWarrantyOpen] = useState(false);
const [isOrderConfirmaionOpen, setIsOrderConfirmationOpen] = useState(false);
const isWarrantyActivated = order?.warrantyStatus?.value === WarrantyStatus.Activated;
const isPassDeadline = isBeforeToday(order?.warrantyActivationDeadline?.value);
const isOrderConfirmed = order?.orderConfirmation?.value;
return (
<>
@ -56,22 +56,43 @@ const MobileOrderActions = ({
focus ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
'flex w-full px-4 py-2 text-sm'
)}
onClick={() => setIsOpen(true)}
onClick={() => setIsWarrantyOpen(true)}
>
Activate Warranty
</Button>
)}
</MenuItem>
)}
{!isOrderConfirmed && (
<MenuItem>
{({ focus }) => (
<Button
className={clsx(
focus ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
'flex w-full px-4 py-2 text-sm'
)}
onClick={() => setIsOrderConfirmationOpen(true)}
>
Confirm Order
</Button>
)}
</MenuItem>
)}
</div>
</MenuItems>
</Menu>
<ActivateWarrantyModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
orderId={order.id}
orderMetafields={orderMetafields}
isOpen={isWarrantyOpen}
onClose={() => setIsWarrantyOpen(false)}
order={order}
/>
{!isOrderConfirmed && (
<OrderConfirmationModal
isOpen={isOrderConfirmaionOpen}
onClose={() => setIsOrderConfirmationOpen(false)}
order={order}
/>
)}
</>
);
};

View File

@ -0,0 +1,292 @@
import Image from 'next/image';
import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react';
import { toPrintDate } from 'lib/utils';
import PaymentsDetails from './payment-details';
import Price from 'components/price';
import Divider from 'components/divider';
import Markdown from 'markdown-to-jsx';
import { Order, OrderConfirmationContent } from 'lib/shopify/types';
import { FormEventHandler, useEffect, useRef, useState, useTransition } from 'react';
import { confirmOrder } from 'components/orders/actions';
import { Button, Heading, Text, Label, Skeleton, InputLabel, Input } from 'components/ui';
function OrderConfirmationDetails({
content,
order
}: {
content: OrderConfirmationContent;
order: Order;
}) {
return (
<div className="space-y-4">
<figure>
<Image
src={content?.logo?.url}
alt={content?.logo?.altText || 'Logo'}
width={content?.logo?.width || 400}
height={content?.logo?.height || 400}
/>
</figure>
<Heading className="text-primary" size="sm">
ORDER INFORMATION:
</Heading>
<div>
<Text>Order number: {order.name}</Text>
<Text>Email: {order.customer?.emailAddress}</Text>
<Text>Date: {toPrintDate(order.processedAt)}</Text>
</div>
<div className="flex">
<div className="flex-1 space-y-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-1 space-y-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 className="flex flex-col gap-2">
<Label>Payment</Label>
<PaymentsDetails order={order} hideIcon />
</div>
<div className="mb-4">
<Heading size="sm">Products</Heading>
<Divider />
<table className="w-full table-auto">
<thead>
<tr>
<th className="text-start">
<Label>Product</Label>
</th>
<th className="text-start">
<Label>Quantity</Label>
</th>
<th className="text-start">
<Label>Price</Label>
</th>
</tr>
</thead>
<tbody>
{order.lineItems.map((lineItem, index) => (
<tr key={index}>
<td className="py-4 text-start">
<Text className="max-w-sm">{lineItem.title}</Text>
</td>
<td className="text-start">
<Text>{lineItem.quantity}</Text>
</td>
<td className="text-start">
<Price
className="text-sm"
amount={lineItem.totalPrice!.amount}
currencyCode={lineItem.totalPrice!.currencyCode}
/>
</td>
</tr>
))}
</tbody>
</table>
<Divider />
<div className="ml-auto flex w-60 flex-col gap-4">
<div className="flex justify-between">
<Text>Subtotal</Text>
<Price
className="text-sm font-semibold"
amount={order.subtotal!.amount}
currencyCode={order.subtotal!.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 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>
<Markdown
options={{
overrides: {
h1: {
props: {
className: 'text-primary font-semibold mt-4 mb-2 text-xl'
}
},
h2: {
props: {
className: 'text-primary font-semibold mt-4 mb-2'
}
},
h3: {
props: {
className: 'text-primary text-sm font-semibold mt-4 mb-2'
}
},
p: {
props: {
className: 'text-sm'
}
},
a: {
props: {
className: 'text-sm, text-primary underline'
}
}
}
}}
>
{content?.body || ''}
</Markdown>
</div>
);
}
export default function OrderConfirmationModal({
order,
isOpen,
onClose
}: {
order: Order;
isOpen: boolean;
onClose: () => void;
}) {
const [loading, setLoading] = useState(true);
const [orderConfirmationContent, setOrderConfirmationContent] =
useState<OrderConfirmationContent>();
const [, startTransition] = useTransition();
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
const fetchOrderConfirmationContent = async () => {
const res = await fetch('/api/orders/confirmation');
const data = await res.json();
setOrderConfirmationContent(data);
setLoading(false);
};
// If the order has already been confirmed, don't fetch the content
if (order.orderConfirmation) return;
fetchOrderConfirmationContent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
setLoading(true);
const form = formRef.current;
if (!form) return;
const formData = new FormData(form);
startTransition(async () => {
await confirmOrder({
order,
content: orderConfirmationContent!,
formData
});
form.reset();
});
};
if (!loading && !orderConfirmationContent) return null;
return (
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
<DialogBackdrop
transition
className="fixed inset-0 bg-black/30 duration-300 ease-out data-[closed]:opacity-0"
/>
<div className="fixed inset-0 w-screen overflow-y-auto p-4">
<div className="flex min-h-full items-center justify-center">
<DialogPanel
transition
className="max-w-3xl space-y-4 rounded bg-white p-12 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
>
{loading ? (
<Skeleton />
) : (
<OrderConfirmationDetails content={orderConfirmationContent!} order={order} />
)}
<form onSubmit={handleSubmit} ref={formRef} className="space-y-4">
<div className="space-y-2">
<InputLabel htmlFor="date">Today&apos;s date</InputLabel>
<Input
type="date"
id="date"
name="date"
readOnly
value={new Date().toLocaleDateString('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})}
/>
</div>
<div className="space-y-2">
<InputLabel htmlFor="signature1">Print your name to sign</InputLabel>
<Input id="signature1" name="signature1" required />
</div>
<div className="space-y-2">
<InputLabel htmlFor="signature2">
Credit card holder&apos;s electronic signature
</InputLabel>
<Input id="signature2" name="signature2" required />
</div>
<div className="flex justify-end gap-2">
<Button variant="text">Cancel</Button>
<Button
type="submit"
variant="solid"
color="primary"
disabled={loading}
isLoading={loading}
>
Submit
</Button>
</div>
</form>
</DialogPanel>
</div>
</div>
</Dialog>
);
}

View File

@ -0,0 +1,298 @@
import Markdown from 'markdown-to-jsx';
import { Document, Image, Page, Text, StyleSheet, View, Link } from '@react-pdf/renderer';
import { Order, OrderConfirmationContent } from 'lib/shopify/types';
import { toPrintDate } from 'lib/utils';
const PDFPrice = ({
style,
amount,
currencyCode = 'USD'
}: {
style?: any;
amount: string;
currencyCode: string;
}) => {
const price = parseFloat(amount);
// Return 'Included' if price is 0
if (price === 0) {
return <Text style={style}>Free</Text>;
}
return (
<Text style={style}>
{new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode,
currencyDisplay: 'narrowSymbol'
}).format(price)}
</Text>
);
};
export default function OrderConfirmationPdf({
content,
order,
signature1,
signature2,
date
}: {
content: OrderConfirmationContent;
order: Order;
signature1: string;
signature2: string;
date: string;
}) {
const styles = StyleSheet.create({
logo: {
width: 300,
marginHorizontal: 'auto',
marginBottom: 24
},
page: {
padding: 48,
paddingVertical: 64
},
h1: {
fontSize: 18,
fontWeight: 700,
marginBottom: 12,
color: content.color
},
h2: {
fontSize: 14,
fontWeight: 700,
marginBottom: 12,
color: content.color
},
h3: {
fontSize: 12,
fontWeight: 700,
marginBottom: 12,
color: content.color
},
p: {
fontSize: 10,
marginBottom: 12
},
span: {
fontSize: 10
},
strong: {
fontWeight: 700,
fontSize: 10
},
a: {
color: content.color,
fontSize: 10,
textDecoration: 'underline'
},
label: {
fontSize: 10,
fontWeight: 'bold',
color: '#555'
},
tableRow: {
display: 'flex',
flexDirection: 'row',
gap: 8
},
tableCell: {
textAlign: 'left',
fontSize: 10,
paddingVertical: 12
}
});
return (
<Document>
<Page size="A4" style={styles.page}>
<View>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<Image src={content?.logo?.url} style={styles.logo} />
<Text style={styles.h3}>ORDER INFORMATION:</Text>
<View>
<Text style={styles.span}>Order number: {order.name}</Text>
<Text style={styles.span}>Email: {order.customer?.emailAddress}</Text>
<Text style={styles.p}>Date: {toPrintDate(order.processedAt)}</Text>
</View>
<View style={{ display: 'flex', flexDirection: 'row' }}>
<View style={{ flex: 1 }}>
<Text style={[styles.label, { marginBottom: 6 }]}>Shipping Address</Text>
<View>
<Text style={styles.span}>
{order.shippingAddress!.firstName} {order.shippingAddress!.lastName}
</Text>
<Text style={styles.span}>{order.shippingAddress!.address1}</Text>
{order.shippingAddress!.address2 && (
<Text style={styles.p}>{order.shippingAddress!.address2}</Text>
)}
<Text style={styles.span}>
{order.shippingAddress!.city} {order.shippingAddress!.provinceCode}{' '}
{order.shippingAddress!.zip}
</Text>
<Text style={styles.p}>{order.shippingAddress!.country}</Text>
</View>
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.label, { marginBottom: 6 }]}>Billing Address</Text>
<View>
<Text style={styles.span}>
{order.billingAddress!.firstName} {order.billingAddress!.lastName}
</Text>
<Text style={styles.span}>{order.billingAddress!.address1}</Text>
{order.billingAddress!.address2 && (
<Text style={styles.span}>{order.billingAddress!.address2}</Text>
)}
<Text style={styles.span}>
{order.billingAddress!.city} {order.billingAddress!.provinceCode}{' '}
{order.billingAddress!.zip}
</Text>
<Text style={styles.p}>{order.billingAddress!.country}</Text>
</View>
</View>
</View>
<View>
<Text style={styles.label}>Payment</Text>
<View>
<Text style={styles.p}>
Ending with {order.transactions[0]!.paymentDetails.last4} -
<PDFPrice
amount={order.transactions[0]!.transactionAmount.amount}
currencyCode={order.transactions[0]!.transactionAmount.currencyCode}
/>
</Text>
</View>
</View>
<View>
<View style={styles.tableRow}>
<Text style={[styles.label, { width: '70%' }]}>Products</Text>
<Text style={[styles.label, { width: '15%' }]}>Quantity</Text>
<Text style={[styles.label, { width: '15%' }]}>Price</Text>
</View>
<View
style={{
width: '100%',
borderBottom: '1px solid #333',
marginTop: 6,
marginBottom: 8
}}
/>
{order.lineItems.map((lineItem, index) => (
<View key={index} style={styles.tableRow}>
<Text style={[styles.tableCell, { width: '70%' }]}>{lineItem.title}</Text>
<Text style={[styles.tableCell, { width: '15%' }]}>{lineItem.quantity}</Text>
<PDFPrice
style={[styles.tableCell, { width: '15%' }]}
amount={lineItem.totalPrice!.amount}
currencyCode={lineItem.totalPrice!.currencyCode}
/>
</View>
))}
<View
style={{
width: '100%',
borderBottom: '1px solid black',
marginTop: 6,
marginBottom: 8
}}
/>
<View style={{ width: '150px', marginLeft: 'auto', marginRight: '20' }}>
<View
style={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between' }}
>
<Text style={styles.span}>Subtotal</Text>
<PDFPrice
style={styles.span}
amount={order.subtotal!.amount}
currencyCode={order.subtotal!.currencyCode}
/>
</View>
<View
style={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between' }}
>
<Text style={styles.span}>Shipping</Text>
<PDFPrice
style={styles.span}
amount={order.shippingMethod!.price.amount}
currencyCode={order.shippingMethod!.price.currencyCode}
/>
</View>
<View
style={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between' }}
>
<Text style={styles.span}>Total</Text>
<PDFPrice
style={styles.span}
amount={order.totalPrice!.amount}
currencyCode={order.totalPrice!.currencyCode}
/>
</View>
</View>
</View>
</View>
</Page>
<Page size="A4" style={styles.page}>
<Markdown
options={{
wrapper: View,
overrides: {
h1: {
component: Text,
props: {
style: styles.h1
}
},
h2: {
component: Text,
props: {
style: styles.h2
}
},
h3: {
component: Text,
props: {
style: styles.h3
}
},
p: {
component: Text,
props: {
style: styles.p
}
},
strong: {
component: Text,
props: {
style: styles.strong
}
},
a: {
component: Link,
props: {
style: styles.a
}
}
}
}}
>
{content.body}
</Markdown>
<View style={{ display: 'flex', flexDirection: 'row' }}>
<Text style={[styles.p, { flex: 1 }]}>Date:</Text>
<Text style={[styles.p, { flex: 1 }]}>{toPrintDate(date)}</Text>
</View>
<View style={{ display: 'flex', flexDirection: 'row' }}>
<Text style={[styles.p, { flex: 1 }]}>Print your name to sign:</Text>
<Text style={[styles.p, { flex: 1 }]}>{signature1}</Text>
</View>
<View style={{ display: 'flex', flexDirection: 'row' }}>
<Text style={[styles.p, { flex: 1 }]}>
Credit card holder&apos;s electronic signature
</Text>
<Text style={[styles.p, { flex: 1 }]}>{signature2}</Text>
</View>
</Page>
</Document>
);
}

View File

@ -0,0 +1,24 @@
'use client';
import { Button } from 'components/ui';
import { Order } from 'lib/shopify/types';
import dynamic from 'next/dynamic';
import { useState } from 'react';
const OrderConfirmationModal = dynamic(() => import('./order-confirmation-modal'));
export default function OrderConfirmation({ order }: { order: Order }) {
const [isOpen, setIsOpen] = useState(false);
if (order.orderConfirmation) return null;
return (
<>
<Button variant="outlined" onClick={() => setIsOpen(true)}>
Confirm Order
</Button>
{isOpen && (
<OrderConfirmationModal isOpen={isOpen} onClose={() => setIsOpen(false)} order={order} />
)}
</>
);
}

View File

@ -54,8 +54,8 @@ export default function OrderSummary({ order }: { order: Order }) {
<Text>Subtotal</Text>
<Price
className="text-sm font-semibold"
amount={order.totalPrice!.amount}
currencyCode={order.totalPrice!.currencyCode}
amount={order.subtotal!.amount}
currencyCode={order.subtotal!.currencyCode}
/>
</div>
<div className="flex items-center justify-between">

View File

@ -0,0 +1,36 @@
import Text from 'components/ui/text';
import Label from 'components/ui/label';
import { Order } from 'lib/shopify/types';
import Price from 'components/price';
import { toPrintDate } from 'lib/utils';
export default function PaymentsDetails({ order, hideIcon }: { order: Order; hideIcon?: boolean }) {
return (
<>
{order.transactions.map((transaction, index) => (
<div key={index} className="flex items-start gap-2">
{!hideIcon && (
// 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>
))}
</>
);
}

View File

@ -1,7 +1,7 @@
'use client';
import { CloseButton, Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
import { ArrowRightIcon } from '@heroicons/react/16/solid';
import { Button } from 'components/button';
import { Button } from 'components/ui';
import useAuth from 'hooks/use-auth';
import { Menu } from 'lib/shopify/types';
import Link from 'next/link';

View File

@ -1,19 +0,0 @@
import clsx from 'clsx';
import React from 'react';
export default function Spinner({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={clsx('flex-1 animate-spin stroke-current stroke-[3]', className)}
fill="none"
viewBox="0 0 24 24"
>
<path
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
className="stroke-current opacity-25"
/>
<path d="M12 2C6.47715 2 2 6.47715 2 12C2 14.7255 3.09032 17.1962 4.85857 19" />
</svg>
);
}

View File

@ -19,7 +19,7 @@ const badgeStyles = tv({
}
});
interface BadgeProps extends VariantProps<typeof badgeStyles> {
export interface BadgeProps extends VariantProps<typeof badgeStyles> {
content: string | number;
className?: string;
children: React.ReactNode;

View File

@ -3,7 +3,8 @@ 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';
import LoadingDots from './loading-dots';
import { focusInput } from 'lib/utils';
const buttonVariants = tv({
slots: {
@ -15,7 +16,8 @@ const buttonVariants = tv({
// transition
'transition-all duration-100 ease-in-out',
// disabled
'disabled:pointer-events-none disabled:shadow-none'
'disabled:pointer-events-none disabled:shadow-none',
focusInput
],
loading: 'pointer-events-none flex shrink-0 items-center justify-center gap-1.5'
},
@ -36,11 +38,15 @@ const buttonVariants = tv({
content: {}
},
variant: {
solid: {},
outlined: {
root: 'border bg-white'
solid: {
root: 'border border-transparent shadow-sm'
},
text: {}
outlined: {
root: 'border bg-white shadow-sm'
},
text: {
root: 'border border-transparent'
}
}
},
compoundVariants: [
@ -49,20 +55,35 @@ const buttonVariants = tv({
variant: 'solid',
class: {
root: [
// border
'border-transparent',
// text color
'text-white',
// background color
'bg-primary',
// hover color
'hover:bg-primary-empahsis',
'hover:bg-primary-emphasis',
// disabled
'disabled:bg-primary-muted',
'pressed:bg-primary-emphasis/80'
]
}
},
{
color: 'content',
variant: 'solid',
class: {
root: [
// text color
'text-white',
// background color
'bg-content',
// hover color
'hover:bg-content-emphasis',
// disabled
'disabled:bg-content-muted',
'pressed:bg-content-emphasis/80'
]
}
},
{
color: 'primary',
variant: 'outlined',
@ -75,25 +96,62 @@ const buttonVariants = tv({
// background color
'bg-white',
// hover color
'hover:bg-primary/10',
'hover:bg-primary/5',
// disabled
'disabled:border-primary-muted disabled:text-primary-muted'
]
}
},
{
color: 'content',
variant: 'outlined',
class: {
root: [
// border
'border-content-subtle',
// text color
'text-content-emphasis',
// background color
'bg-white',
// hover color
'hover:bg-content/5',
// disabled
'disabled:border-content-muted disabled:text-content-muted'
]
}
},
{
color: 'content',
variant: 'text',
class: {
root: [
// text color
'text-content-emphasis',
// background color
'bg-transparent',
// hover color
'hover:bg-content/5',
// disabled
'disabled:text-content-muted'
]
}
}
],
defaultVariants: {
variant: 'solid',
color: 'primary',
variant: 'outlined',
color: 'content',
size: 'md'
}
});
interface ButtonProps extends Omit<ButtonBaseProps, 'color'>, VariantProps<typeof buttonVariants> {
export interface ButtonProps
extends Omit<ButtonBaseProps, 'color' | 'as'>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean;
loadingText?: string;
className?: string;
disabled?: boolean;
as?: React.ElementType;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
@ -105,14 +163,19 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
isLoading,
loadingText = 'Loading',
size,
color,
variant,
as,
...props
}: ButtonProps,
forwardedRef
) => {
const { loading, root } = buttonVariants({ variant, size });
const { loading, root } = buttonVariants({ variant, size, color });
const Component = as || 'button';
return (
<ButtonBase
as={Component}
ref={forwardedRef}
className={clsx(root(), className)}
disabled={disabled || isLoading}
@ -120,7 +183,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
>
{isLoading ? (
<span className={loading()}>
<Spinner />
<LoadingDots />
<span className="sr-only">{loadingText}</span>
<span>{loadingText}</span>
</span>
@ -134,4 +197,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
Button.displayName = 'Button';
export { Button, buttonVariants, type ButtonProps };
export default Button;

View File

@ -21,7 +21,9 @@ const cardStyles = tv({
}
});
interface CardProps extends React.ComponentPropsWithoutRef<'div'>, VariantProps<typeof cardStyles> {
export interface CardProps
extends React.ComponentPropsWithoutRef<'div'>,
VariantProps<typeof cardStyles> {
asChild?: boolean;
}
@ -41,4 +43,4 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
Card.displayName = 'Card';
export { Card, type CardProps };
export default Card;

View File

@ -0,0 +1,28 @@
'use client';
import { CheckIcon } from '@heroicons/react/24/outline';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { cn } from 'lib/utils';
import { forwardRef } from 'react';
const Checkbox = forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'ring-offset-background focus-visible:ring-ring peer h-4 w-4 shrink-0 rounded-sm border border-dark focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-dark data-[state=checked]:text-white',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export default Checkbox;

View File

@ -1,20 +1,25 @@
import { VariantProps, tv } from 'tailwind-variants';
const heading = tv({
base: [''],
variants: {
size: {
sm: 'text-heading-sm',
md: 'text-heading-md',
lg: 'text-heading-lg'
const heading = tv(
{
base: [''],
variants: {
size: {
sm: 'text-heading-sm',
md: 'text-heading-md',
lg: 'text-heading-lg'
}
},
defaultVariants: {
size: 'md'
}
},
defaultVariants: {
size: 'md'
{
twMerge: false
}
});
);
interface HeadingProps extends VariantProps<typeof heading> {
export interface HeadingProps extends VariantProps<typeof heading> {
className?: string;
children: React.ReactNode;
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'p';

18
components/ui/index.ts Normal file
View File

@ -0,0 +1,18 @@
export { default as Badge } from './badge';
export * from './badge';
export { default as Button } from './button';
export * from './button';
export { default as Card } from './card';
export * from './card';
export { default as Checkbox } from './checkbox';
export { default as Heading } from './heading';
export { default as InputLabel } from './input-label';
export * from './input-label';
export { default as Input } from './input';
export * from './input';
export { default as Label } from './label';
export * from './label';
export { default as Skeleton } from './skeleton';
export * from './skeleton';
export { default as Text } from './text';
export * from './text';

View File

@ -0,0 +1,33 @@
import React from 'react';
import * as LabelPrimitives from '@radix-ui/react-label';
import { cx } from 'lib/utils';
export interface InputLabelProps
extends React.ComponentPropsWithoutRef<typeof LabelPrimitives.Root> {
disabled?: boolean;
}
const InputLabel = React.forwardRef<React.ElementRef<typeof LabelPrimitives.Root>, InputLabelProps>(
({ className, disabled, ...props }, forwardedRef) => (
<LabelPrimitives.Root
ref={forwardedRef}
className={cx(
// base
'text-sm leading-none',
// text color
'text-gray-900 dark:text-gray-50',
// disabled
{
'text-gray-400 dark:text-gray-600': disabled
},
className
)}
aria-disabled={disabled}
{...props}
/>
)
);
InputLabel.displayName = 'InputLabel';
export default InputLabel;

70
components/ui/input.tsx Normal file
View File

@ -0,0 +1,70 @@
import React from 'react';
import { tv, type VariantProps } from 'tailwind-variants';
import { cx, focusInput, hasErrorInput } from 'lib/utils';
const inputStyles = tv({
base: [
// base
'relative block w-full appearance-none rounded-md border px-2.5 py-1.5 shadow-sm outline-none transition sm:text-sm',
// border color
'border-gray-300 dark:border-gray-800',
// text color
'text-gray-900 dark:text-gray-50',
// placeholder color
'placeholder-gray-400 dark:placeholder-gray-500',
// background color
'bg-white dark:bg-gray-950',
// disabled
'disabled:border-gray-300 disabled:bg-gray-100 disabled:text-gray-400',
'disabled:dark:border-gray-700 disabled:dark:bg-gray-800 disabled:dark:text-gray-500',
// file
[
'file:-my-1.5 file:-ml-2.5 file:h-[36px] file:cursor-pointer file:rounded-l-md file:rounded-r-none file:border-0 file:px-3 file:py-1.5 file:outline-none focus:outline-none disabled:pointer-events-none file:disabled:pointer-events-none',
'file:border-solid file:border-gray-300 file:bg-gray-50 file:text-gray-500 file:hover:bg-gray-100 file:dark:border-gray-800 file:dark:bg-gray-950 file:hover:dark:bg-gray-900/20 file:disabled:dark:border-gray-700',
'file:[border-inline-end-width:1px] file:[margin-inline-end:0.75rem]',
'file:disabled:bg-gray-100 file:disabled:text-gray-500 file:disabled:dark:bg-gray-800'
],
// focus
focusInput,
// invalid
'aria-[invalid=true]:dark:ring-red-400/20 aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-red-200 aria-[invalid=true]:border-red-500 invalid:ring-2 invalid:ring-red-200 invalid:border-red-500'
],
variants: {
hasError: {
true: hasErrorInput
},
// number input
enableStepper: {
true: '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
}
}
});
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputStyles> {
inputClassName?: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
{ className, inputClassName, hasError, enableStepper, type, ...props }: InputProps,
forwardedRef
) => {
return (
<div className={cx('relative w-full', className)}>
<input
ref={forwardedRef}
type={type}
className={cx(inputStyles({ hasError, enableStepper }), inputClassName)}
{...props}
/>
</div>
);
}
);
Input.displayName = 'Input';
export default Input;

View File

@ -19,7 +19,7 @@ const label = tv(
}
);
interface LabelProps extends VariantProps<typeof label> {
export interface LabelProps extends VariantProps<typeof label> {
className?: string;
children: React.ReactNode;
as?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'p';

View File

@ -0,0 +1,15 @@
import clsx from 'clsx';
const dots = 'mx-[1px] inline-block h-1 w-1 animate-blink rounded-md';
const LoadingDots = ({ className }: { className?: string }) => {
return (
<span className="mx-2 inline-flex items-center">
<span className={clsx(dots, className)} />
<span className={clsx(dots, 'animation-delay-[200ms]', className)} />
<span className={clsx(dots, 'animation-delay-[400ms]', className)} />
</span>
);
};
export default LoadingDots;

View File

@ -19,7 +19,7 @@ const text = tv(
}
);
interface TextProps extends VariantProps<typeof text> {
export interface TextProps extends VariantProps<typeof text> {
className?: string;
children: React.ReactNode;
as?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'p';

View File

@ -390,6 +390,7 @@ export async function isLoggedIn(request: NextRequest, origin: string) {
}
newHeaders.set('x-shop-customer-token', `${customerTokenValue}`);
console.log('Customer Token', customerTokenValue);
return NextResponse.next({
request: {
// New request headers

View File

@ -0,0 +1,17 @@
const addressFragment = /* GraphQL */ `
fragment Address on CustomerAddress {
id
address1
address2
firstName
lastName
provinceCode: zoneCode
city
zip
countryCodeV2: territoryCode
company
phone: phoneNumber
}
`;
export default addressFragment;

View File

View File

@ -0,0 +1,56 @@
const orderMetafieldsFragment = /* GraphQL */ `
fragment OrderMetafields on Order {
warrantyStatus: metafield(namespace: "custom", key: "warranty_status") {
value
id
key
}
warrantyActivationDeadline: metafield(
namespace: "custom"
key: "warranty_activation_deadline"
) {
value
id
key
}
warrantyActivationOdometer: metafield(
namespace: "custom"
key: "warranty_activation_odometer"
) {
value
id
key
}
warrantyActivationInstallation: metafield(
namespace: "custom"
key: "warranty_activation_installation"
) {
value
id
key
}
warrantyActivationSelfInstall: metafield(
namespace: "custom"
key: "warranty_activation_self_install"
) {
value
id
key
}
warrantyActivationVIN: metafield(namespace: "custom", key: "warranty_activation_vin") {
value
id
key
}
warrantyActivationMileage: metafield(namespace: "custom", key: "warranty_activation_mileage") {
value
id
key
}
orderConfirmation: metafield(namespace: "custom", key: "customer_confirmation") {
value
}
}
`;
export default orderMetafieldsFragment;

View File

@ -0,0 +1,38 @@
const orderTransactionFragment = /* GraphQL */ `
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
}
}
`;
export default orderTransactionFragment;

View File

@ -1,4 +1,8 @@
import addressFragment from './address';
import lineItemFragment from './line-item';
import orderMetafieldsFragment from './order-metafields';
import orderTrasactionFragment from './order-transaction';
import priceFragment from './price';
const orderCard = /* GraphQL */ `
fragment OrderCard on Order {
@ -16,69 +20,44 @@ const orderCard = /* GraphQL */ `
}
}
totalPrice {
amount
currencyCode
...Price
}
subtotal {
...Price
}
totalShipping {
...Price
}
totalTax {
...Price
}
shippingLine {
title
originalPrice {
...Price
}
}
lineItems(first: 20) {
nodes {
...LineItem
}
}
shippingAddress {
...Address
}
billingAddress {
...Address
}
transactions {
...OrderTransaction
}
...OrderMetafields
}
${lineItemFragment}
`;
export const orderMetafields = /* GraphQL */ `
fragment OrderMetafield on Order {
id
warrantyStatus: metafield(namespace: "custom", key: "warranty_status") {
value
id
key
}
warrantyActivationDeadline: metafield(
namespace: "custom"
key: "warranty_activation_deadline"
) {
value
id
key
}
warrantyActivationOdometer: metafield(
namespace: "custom"
key: "warranty_activation_odometer"
) {
value
id
key
}
warrantyActivationInstallation: metafield(
namespace: "custom"
key: "warranty_activation_installation"
) {
value
id
key
}
warrantyActivationSelfInstall: metafield(
namespace: "custom"
key: "warranty_activation_self_install"
) {
value
id
key
}
warrantyActivationVIN: metafield(namespace: "custom", key: "warranty_activation_vin") {
value
id
key
}
warrantyActivationMileage: metafield(namespace: "custom", key: "warranty_activation_mileage") {
value
id
key
}
}
${addressFragment}
${priceFragment}
${orderTrasactionFragment}
${orderMetafieldsFragment}
`;
export default orderCard;

View File

@ -0,0 +1,8 @@
const priceFragment = /* GraphQL */ `
fragment Price on MoneyV2 {
amount
currencyCode
}
`;
export default priceFragment;

View File

@ -38,8 +38,7 @@ import { getCustomerQuery } from './queries/customer';
import { getMenuQuery } from './queries/menu';
import { getMetaobjectQuery, getMetaobjectsQuery } from './queries/metaobject';
import { getFileQuery, getImageQuery, getMetaobjectsByIdsQuery } from './queries/node';
import { getCustomerOrderQuery, getOrderMetafieldsQuery } from './queries/order';
import { getCustomerOrderMetafieldsQuery, getCustomerOrdersQuery } from './queries/orders';
import { getCustomerOrdersQuery } from './queries/orders';
import { getPageQuery, getPagesQuery } from './queries/page';
import {
getProductQuery,
@ -64,6 +63,7 @@ import {
Metaobject,
Money,
Order,
OrderConfirmationContent,
Page,
PageInfo,
Product,
@ -86,10 +86,10 @@ import {
ShopifyImageOperation,
ShopifyMenuOperation,
ShopifyMetaobject,
ShopifyMetaobjectOperation,
ShopifyMetaobjectsOperation,
ShopifyMoneyV2,
ShopifyOrder,
ShopifyOrderMetafield,
ShopifyPage,
ShopifyPageOperation,
ShopifyPagesOperation,
@ -109,6 +109,7 @@ import {
UploadInput,
WarrantyStatus
} from './types';
import getCustomerOrderQuery from './queries/order';
const domain = process.env.SHOPIFY_STORE_DOMAIN
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
@ -185,7 +186,7 @@ export async function shopifyFetch<T>({
}
}
async function adminFetch<T>({
async function shopifyAdminFetch<T>({
headers,
query,
variables,
@ -313,7 +314,7 @@ export async function shopifyCustomerFetch<T>({
}
}
const removeEdgesAndNodes = (array: Connection<any>) => {
const removeEdgesAndNodes = <T = any>(array: Connection<T>) => {
return array.edges.map((edge) => edge?.node);
};
@ -439,7 +440,7 @@ const reshapeImages = (images: Connection<Image>, productTitle: string) => {
const flattened = removeEdgesAndNodes(images);
return flattened.map((image) => {
const filename = image.url.match(/.*\/(.*)\..*/)[1];
const filename = (image.url.match(/.*\/(.*)\..*/) || [])[1];
return {
...image,
altText: image.altText || `${productTitle} - ${filename}`
@ -531,8 +532,7 @@ function reshapeOrders(orders: ShopifyOrder[]): any[] | Promise<Order[]> {
}
function reshapeOrder(shopifyOrder: ShopifyOrder): Order {
const reshapeAddress = (address?: ShopifyAddress): Address | undefined => {
if (!address) return undefined;
const reshapeAddress = (address: ShopifyAddress): Address => {
return {
address1: address.address1,
address2: address.address2,
@ -547,8 +547,7 @@ function reshapeOrder(shopifyOrder: ShopifyOrder): Order {
};
};
const reshapeMoney = (money?: ShopifyMoneyV2): Money | undefined => {
if (!money) return undefined;
const reshapeMoney = (money: ShopifyMoneyV2): Money => {
return {
amount: money.amount || '0.00',
currencyCode: money.currencyCode || 'USD'
@ -619,23 +618,38 @@ function reshapeOrder(shopifyOrder: ShopifyOrder): Order {
totalShipping: reshapeMoney(shopifyOrder.totalShipping),
totalTax: reshapeMoney(shopifyOrder.totalTax),
totalPrice: reshapeMoney(shopifyOrder.totalPrice),
createdAt: shopifyOrder.createdAt
createdAt: shopifyOrder.createdAt,
shippingMethod: {
name: shopifyOrder.shippingLine?.title,
price: reshapeMoney(shopifyOrder.shippingLine.originalPrice)!
},
warrantyActivationDeadline: shopifyOrder.warrantyActivationDeadline,
warrantyStatus: shopifyOrder.warrantyStatus,
warrantyActivationInstallation: shopifyOrder.warrantyActivationInstallation,
warrantyActivationMileage: shopifyOrder.warrantyActivationMileage,
warrantyActivationOdometer: shopifyOrder.warrantyActivationOdometer,
warrantyActivationSelfInstall: shopifyOrder.warrantyActivationSelfInstall,
warrantyActivationVIN: shopifyOrder.warrantyActivationVIN,
orderConfirmation: shopifyOrder.orderConfirmation
};
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 function reshapeOrderConfirmationPdf(
metaobject: ShopifyMetaobject
): OrderConfirmationContent {
return {
body: metaobject.fields.find((field) => field.key === 'body')?.value || '',
logo: metaobject.fields.find((field) => field.key === 'logo')?.reference.image!,
color: metaobject.fields.find((field) => field.key === 'color')?.value || '#000000'
};
}
export async function createCart(): Promise<Cart> {
const res = await shopifyFetch<ShopifyCreateCartOperation>({
query: createCartMutation,
@ -874,6 +888,31 @@ export async function getMetaobjects(type: string) {
return reshapeMetaobjects(removeEdgesAndNodes(res.body.data.metaobjects));
}
export async function getAllMetaobjects(type: string) {
const allMetaobjects: Metaobject[] = [];
let hasNextPage = true;
let after: string | undefined;
while (hasNextPage) {
const res = await shopifyFetch<ShopifyMetaobjectsOperation>({
query: getMetaobjectsQuery,
tags: [TAGS.collections, TAGS.products],
variables: { type, after }
});
const metaobjects = reshapeMetaobjects(removeEdgesAndNodes(res.body.data.metaobjects));
for (const metaobject of metaobjects) {
allMetaobjects.push(metaobject);
}
hasNextPage = res.body.data.metaobjects.pageInfo?.hasNextPage || false;
after = res.body.data.metaobjects.pageInfo?.endCursor;
}
return allMetaobjects;
}
export async function getMetaobjectsByIds(ids: string[]) {
if (!ids.length) return [];
@ -895,10 +934,7 @@ export async function getMetaobject({
id?: string;
handle?: { handle: string; type: string };
}) {
const res = await shopifyFetch<{
data: { metaobject: ShopifyMetaobject };
variables: { id?: string; handle?: { handle: string; type: string } };
}>({
const res = await shopifyFetch<ShopifyMetaobjectOperation>({
query: getMetaobjectQuery,
variables: { id, handle }
});
@ -906,6 +942,15 @@ export async function getMetaobject({
return res.body.data.metaobject ? reshapeMetaobjects([res.body.data.metaobject])[0] : null;
}
export async function getOrderConfirmationContent(): Promise<OrderConfirmationContent> {
const res = await shopifyFetch<ShopifyMetaobjectOperation>({
query: getMetaobjectQuery,
variables: { handle: { handle: 'order-confirmation-pdf', type: 'order_confirmation_pdf' } }
});
return reshapeOrderConfirmationPdf(res.body.data.metaobject);
}
export async function getPage(handle: string): Promise<Page> {
const res = await shopifyFetch<ShopifyPageOperation>({
query: getPageQuery,
@ -1064,7 +1109,7 @@ export const getImage = async (id: string): Promise<Image> => {
};
export const stageUploadFile = async (params: UploadInput) => {
const res = await adminFetch<ShopifyStagedUploadOperation>({
const res = await shopifyAdminFetch<ShopifyStagedUploadOperation>({
query: createStageUploads,
variables: { input: [params] }
});
@ -1080,7 +1125,7 @@ export const uploadFile = async ({ url, formData }: { url: string; formData: For
};
export const createFile = async (params: FileCreateInput) => {
const res = await adminFetch<ShopifyCreateFileOperation>({
const res = await shopifyAdminFetch<ShopifyCreateFileOperation>({
query: createFileMutation,
variables: { files: [params] }
});
@ -1103,7 +1148,7 @@ export const updateOrderMetafields = async ({
validMetafields.find(({ key }) => (Array.isArray(field) ? field.includes(key) : key === field))
);
const response = await adminFetch<ShopifyUpdateOrderMetafieldsOperation>({
const response = await shopifyAdminFetch<ShopifyUpdateOrderMetafieldsOperation>({
query: updateOrderMetafieldsMutation,
variables: {
input: {
@ -1125,59 +1170,6 @@ export const updateOrderMetafields = async ({
return response.body.data.orderUpdate.order.id;
};
export const getOrdersMetafields = async (): Promise<{ [key: string]: ShopifyOrderMetafield }> => {
const customer = await getCustomer();
const res = await adminFetch<{
data: {
customer: {
orders: {
nodes: Array<
{
id: string;
} & ShopifyOrderMetafield
>;
};
};
};
variables: {
id: string;
};
}>({
query: getCustomerOrderMetafieldsQuery,
variables: { id: customer.id },
tags: [TAGS.orderMetafields]
});
return res.body.data.customer.orders.nodes.reduce(
(acc, order) => ({
...acc,
[order.id]: order
}),
{} as { [key: string]: ShopifyOrderMetafield }
);
};
export const getOrderMetafields = async (orderId: string): Promise<ShopifyOrderMetafield> => {
const res = await adminFetch<{
data: {
order: {
id: string;
} & ShopifyOrderMetafield;
};
variables: {
id: string;
};
}>({
query: getOrderMetafieldsQuery,
variables: { id: `gid://shopify/Order/${orderId}` },
tags: [TAGS.orderMetafields]
});
const order = res.body.data.order;
return order;
};
export const getFile = async (id: string) => {
const res = await shopifyFetch<{
data: {

View File

@ -1,6 +1,6 @@
export const getMetaobjectsQuery = /* GraphQL */ `
query getMetaobjects($type: String!) {
metaobjects(type: $type, first: 200) {
query getMetaobjects($type: String!, $after: String) {
metaobjects(type: $type, first: 200, after: $after) {
edges {
node {
id
@ -16,6 +16,10 @@ export const getMetaobjectsQuery = /* GraphQL */ `
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
@ -30,6 +34,14 @@ export const getMetaobjectQuery = /* GraphQL */ `
... on Metaobject {
id
}
... on MediaImage {
image {
url
altText
height
width
}
}
}
key
value

View File

@ -1,8 +1,11 @@
import addressFragment from '../fragments/address';
import lineItemFragment from '../fragments/line-item';
import { orderMetafields } from '../fragments/order';
import orderMetafieldsFragment from '../fragments/order-metafields';
import orderTrasactionFragment from '../fragments/order-transaction';
import priceFragment from '../fragments/price';
// NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer
export const getCustomerOrderQuery = /* GraphQL */ `
const getCustomerOrderQuery = /* GraphQL */ `
query getCustomerOrderQuery($orderId: ID!) {
customer {
emailAddress {
@ -95,60 +98,7 @@ export const getCustomerOrderQuery = /* GraphQL */ `
...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
...OrderMetafields
}
fragment Fulfillment on Fulfillment {
@ -220,13 +170,10 @@ export const getCustomerOrderQuery = /* GraphQL */ `
}
}
${lineItemFragment}
${addressFragment}
${priceFragment}
${orderTrasactionFragment}
${orderMetafieldsFragment}
`;
export const getOrderMetafieldsQuery = /* GraphQL */ `
query getOrderMetafields($id: ID!) {
order(id: $id) {
...OrderMetafield
}
}
${orderMetafields}
`;
export default getCustomerOrderQuery;

View File

@ -1,5 +1,4 @@
import customerDetailsFragment from '../fragments/customer-details';
import { orderMetafields } from '../fragments/order';
const customerFragment = `#graphql
`;
@ -14,16 +13,3 @@ export const getCustomerOrdersQuery = `#graphql
${customerFragment}
${customerDetailsFragment}
`;
export const getCustomerOrderMetafieldsQuery = /* GraphQL */ `
query getCustomerOrderMetafields($id: ID!) {
customer(id: $id) {
orders(first: 20, sortKey: PROCESSED_AT, reverse: true) {
nodes {
...OrderMetafield
}
}
}
}
${orderMetafields}
`;

View File

@ -3,6 +3,7 @@ export type Maybe<T> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
pageInfo?: PageInfo;
};
export type Edge<T> = {
@ -141,18 +142,18 @@ export type Order = {
fulfillments: Fulfillment[];
transactions: Transaction[];
lineItems: LineItem[];
shippingAddress?: Address;
billingAddress?: Address;
shippingAddress: Address;
billingAddress: Address;
/** the price of all line items, excluding taxes and surcharges */
subtotal?: Money;
totalShipping?: Money;
totalTax?: Money;
totalPrice?: Money;
shippingMethod?: {
subtotal: Money;
totalShipping: Money;
totalTax: Money;
totalPrice: Money;
shippingMethod: {
name: string;
price: Money;
};
};
} & ShopifyOrderMetafield;
export type ShopifyOrder = {
id: string;
@ -181,7 +182,7 @@ export type ShopifyOrder = {
requiresShipping: boolean;
shippingLine: ShopifyShippingLine;
note: string | null;
};
} & ShopifyOrderMetafield;
type ShopifyShippingLine = {
title: string;
@ -372,16 +373,30 @@ export type ShopifyMetaobject = {
value: string;
reference: {
id: string;
image?: Image;
};
}>;
};
export type ShopifyMetafield = {
id: string;
namespace: string;
key: string;
value: string;
};
export type Metaobject = {
id: string;
type: string;
[key: string]: string;
};
export type OrderConfirmationContent = {
logo: Image;
body: string;
color: string;
};
export type TransmissionType = 'Automatic' | 'Manual';
export type Product = Omit<
@ -665,7 +680,7 @@ export type ShopifyImageOperation = {
export type ShopifyMetaobjectsOperation = {
data: { metaobjects: Connection<ShopifyMetaobject> };
variables: { type: string };
variables: { type: string; after?: string };
};
export type ShopifyPagesOperation = {
@ -675,8 +690,8 @@ export type ShopifyPagesOperation = {
};
export type ShopifyMetaobjectOperation = {
data: { nodes: ShopifyMetaobject[] };
variables: { ids: string[] };
data: { metaobject: ShopifyMetaobject };
variables: { id?: string; handle?: { handle: string; type: string } };
};
export type ShopifyProductOperation = {
@ -858,20 +873,15 @@ export enum WarrantyStatus {
LimitedActivated = 'Limited Activation'
}
export type OrderMetafieldValue<T = string> = {
value: T;
id: string;
key: string;
};
export type ShopifyOrderMetafield = {
warrantyStatus: OrderMetafieldValue | null;
warrantyActivationDeadline: OrderMetafieldValue | null;
warrantyActivationOdometer: OrderMetafieldValue | null;
warrantyActivationInstallation: OrderMetafieldValue | null;
warrantyActivationSelfInstall: OrderMetafieldValue | null;
warrantyActivationVIN: OrderMetafieldValue | null;
warrantyActivationMileage: OrderMetafieldValue | null;
orderConfirmation: ShopifyMetafield | null;
warrantyStatus: ShopifyMetafield | null;
warrantyActivationDeadline: ShopifyMetafield | null;
warrantyActivationOdometer: ShopifyMetafield | null;
warrantyActivationInstallation: ShopifyMetafield | null;
warrantyActivationSelfInstall: ShopifyMetafield | null;
warrantyActivationVIN: ShopifyMetafield | null;
warrantyActivationMileage: ShopifyMetafield | null;
};
export type File = {

View File

@ -5,7 +5,7 @@ export const carPartPlanetColor = {
muted: '#E6CCB7'
},
content: {
subtle: '#9ca3af', // gray-400
subtle: '#d1d5db', // gray-300
DEFAULT: '#6b7280', // gray-500
emphasis: '#374151', // gray-700
strong: '#111827', // gray-900

View File

@ -3,6 +3,34 @@ import { ReadonlyURLSearchParams } from 'next/navigation';
import { twMerge } from 'tailwind-merge';
import { Menu } from './shopify/types';
export function cx(...args: ClassValue[]) {
return twMerge(clsx(...args));
}
export const focusInput = [
// base
'focus:ring-2',
// ring color
'focus:ring-blue-200 focus:dark:ring-blue-700/30',
// border color
'focus:border-blue-500 focus:dark:border-blue-700'
];
export const hasErrorInput = [
// base
'ring-2',
// border color
'border-red-500 dark:border-red-700',
// ring color
'ring-red-200 dark:ring-red-700/30'
];
export const focusRing = [
// base
'outline outline-offset-2 outline-0 focus-visible:outline-2',
// outline color
'outline-blue-500 dark:outline-blue-500'
];
export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
const paramsString = params.toString();
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;

View File

@ -25,12 +25,17 @@
"@headlessui/react": "^2.1.0",
"@heroicons/react": "^2.1.3",
"@hookform/resolvers": "^3.6.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-slot": "^1.0.2",
"@react-pdf/renderer": "^3.4.4",
"clsx": "^2.1.0",
"geist": "^1.3.0",
"lodash.get": "^4.4.2",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"markdown-to-jsx": "^7.4.7",
"next": "14.2.4",
"react": "18.2.0",
"react-dom": "18.2.0",

1021
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff