diff --git a/app/account/orders/[id]/page.tsx b/app/account/orders/[id]/page.tsx new file mode 100644 index 000000000..d767ab5d7 --- /dev/null +++ b/app/account/orders/[id]/page.tsx @@ -0,0 +1,328 @@ +import { CheckCircleIcon, TruckIcon } from '@heroicons/react/24/outline'; +import Image from 'next/image'; +import { Button } from 'components/button'; +import { Card } from 'components/ui/card'; +import Heading from 'components/ui/heading'; +import Label from 'components/ui/label'; +import { getCustomerOrder } from 'lib/shopify'; +import { Fulfillment, Order } from 'lib/shopify/types'; +import Text from 'components/ui/text'; +import Price from 'components/price'; +import Badge from 'components/ui/badge'; + +export const runtime = 'edge'; + +function toPrintDate(date: string) { + return new Date(date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); +} + +function Unfulfilled({ order }: { order: Order }) { + // Build a map of line item IDs to quantities fulfilled + const fulfilledLineItems = order.fulfillments.reduce>((acc, fulfillment) => { + fulfillment.fulfilledLineItems.forEach((lineItem) => { + acc.set(lineItem.id, (acc.get(lineItem.id) || 0) + lineItem.quantity); + }); + return acc; + }, new Map()); + + // 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 ( + +
+
+ + + Confirmed + +
+
+
+ +
+
+ + +
+
+
+
+ ); +} + +function FulfillmentCard({ + fulfillment, + processedAt, + isPartial +}: { + fulfillment: Fulfillment; + processedAt: string; + isPartial: boolean; +}) { + return ( + + {isPartial && ( +
+ {fulfillment.fulfilledLineItems.map((lineItem, index) => ( + + {lineItem.image.altText} + + ))} +
+ )} +
+ {fulfillment.trackingInformation.map((tracking, index) => ( +
+ + +
+ ))} +
+
+
+
+ + On its way +
+
+
+ +
+
+ + +
+
+
+
+
+ + + Confirmed + +
+
+
+ +
+
+ + +
+
+
+
+
+ ); +} + +function Fulfillments({ order }: { order: Order }) { + return ( +
+ {order.fulfillments.map((fulfillment, index) => ( + 1} + /> + ))} +
+ ); +} + +function PaymentsDetails({ order }: { order: Order }) { + return ( + <> + {order.transactions.map((transaction, index) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {transaction.paymentIcon.altText} +
+ + Ending with {transaction.paymentDetails.last4} - + + + +
+
+ ))} + + ); +} + +function OrderDetails({ order }: { order: Order }) { + return ( + + Order Details +
+
+
+ +
+ {order.customer!.displayName} + {order.customer!.emailAddress} +
+
+
+ +
+ + {order.shippingAddress.firstName} {order.shippingAddress.lastName} + + {order.shippingAddress.address1} + {order.shippingAddress.address2 && {order.shippingAddress.address2}} + + {order.shippingAddress.city} {order.shippingAddress.provinceCode}{' '} + {order.shippingAddress.zip} + + {order.shippingAddress.country} +
+
+
+ + {order.shippingMethod.name} +
+
+
+
+ + +
+
+ +
+ + {order.billingAddress.firstName} {order.billingAddress.lastName} + + {order.billingAddress.address1} + {order.billingAddress.address2 && {order.billingAddress.address2}} + + {order.billingAddress.city} {order.billingAddress.provinceCode}{' '} + {order.billingAddress.zip} + + {order.billingAddress.country} +
+
+
+
+
+ ); +} + +function OrderSummary({ order }: { order: Order }) { + return ( +
+ Order Summary +
+ {order.lineItems.map((lineItem, index) => ( +
+ + {lineItem.image.altText} + +
+ {lineItem.title} + +
+ +
+ ))} +
+
+
+
+ Subtotal + +
+
+ Shipping + {order.shippingMethod?.price.amount !== '0.0' ? ( + + ) : ( + Free + )} +
+
+
+ + Total + + +
+
+
+ ); +} + +export default async function OrderPage({ params }: { params: { id: string } }) { + const order = await getCustomerOrder(params.id); + + return ( +
+
+
+ Order {order.name} + +
+
+ +
+
+
+
+ + + +
+ + + +
+
+ ); +} diff --git a/app/account/page.tsx b/app/account/page.tsx index 62eb7de5f..1484c432d 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -1,85 +1,104 @@ -import { headers } from 'next/headers'; -import { AccountProfile } from 'components/account/account-profile'; -import { AccountOrdersHistory } from 'components/account/account-orders-history'; -import { redirect } from 'next/navigation'; -import { shopifyCustomerFetch } from 'lib/shopify/customer/index'; -import { CUSTOMER_DETAILS_QUERY } from 'lib/shopify/customer/queries/customer'; -import { CustomerDetailsData } from 'lib/shopify/customer/types'; -import { TAGS } from 'lib/shopify/customer/constants'; +import Image from 'next/image'; +import Link from 'next/link'; +import { getCustomerOrders } from 'lib/shopify'; +import Price from 'components/price'; +import Divider from 'components/divider'; +import { Button } from 'components/button'; + export const runtime = 'edge'; + export default async function AccountPage() { - const headersList = headers(); - const access = headersList.get('x-shop-customer-token'); - if (!access) { - console.log('ERROR: No access header account'); - //I'm not sure what's better here. Throw error or just log out?? - //redirect gets rid of call cookies - redirect('/logout'); - //throw new Error("No access header") - } - //console.log("Authorize Access code header:", access) - if (access === 'denied') { - console.log('Access Denied for Auth account'); - redirect('/logout'); - //throw new Error("No access allowed") - } - const customerAccessToken = access; - - //this is needed b/c of strange way server components handle redirects etc. - //see https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#redirecting - //can only redirect outside of try/catch! - let success = true; - let errorMessage; - let customerData; - let orders; - - try { - const responseCustomerDetails = await shopifyCustomerFetch({ - customerToken: customerAccessToken, - cache: 'no-store', - query: CUSTOMER_DETAILS_QUERY, - tags: [TAGS.customer] - }); - //console.log("userDetails", responseCustomerDetails) - const userDetails = responseCustomerDetails.body; - if (!userDetails) { - throw new Error('Error getting actual user data Account page.'); - } - customerData = userDetails?.data?.customer; - orders = customerData?.orders?.edges; - //console.log ("Details",orders) - } catch (e) { - //they don't recognize this error in TS! - //@ts-ignore - errorMessage = e?.error?.toString() ?? 'Unknown Error'; - console.log('error customer fetch account', e); - if (errorMessage !== 'unauthorized') { - throw new Error('Error getting actual user data Account page.'); - } else { - console.log('Unauthorized access. Set to false and redirect'); - success = false; - } - } - if (!success && errorMessage === 'unauthorized') redirect('/logout'); - //revalidateTag('posts') // Update cached posts //FIX + // if (!access) { + // redirect('/logout'); + // } + // if (access === 'denied') { + // redirect('/logout'); + // } + // + // const customerAccessToken = access; + // + // //this is needed b/c of strange way server components handle redirects etc. + // //see https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#redirecting + // //can only redirect outside of try/catch! + // let success = true; + // let errorMessage; + // let customerData; + // let orders; + // + // try { + // const responseCustomerDetails = await shopifyCustomerFetch({ + // customerToken: customerAccessToken, + // query: CUSTOMER_DETAILS_QUERY, + // tags: [TAGS.customer] + // }); + // const userDetails = responseCustomerDetails.body; + // if (!userDetails) { + // throw new Error('Error getting actual user data Account page.'); + // } + // customerData = userDetails?.data?.customer; + // orders = customerData?.orders?.edges; + // //console.log ("Details",orders) + // } catch (e) { + // //they don't recognize this error in TS! + // //@ts-ignore + // errorMessage = e?.error?.toString() ?? 'Unknown Error'; + // console.log('error customer fetch account', e); + // if (errorMessage !== 'unauthorized') { + // throw new Error('Error getting actual user data Account page.'); + // } else { + // console.log('Unauthorized access. Set to false and redirect'); + // success = false; + // } + // } + // if (!success && errorMessage === 'unauthorized') redirect('/logout'); + // // revalidateTag('posts') // Update cached posts //FIX + const orders = await getCustomerOrders(); return ( - <> -
-
-
-
Welcome: {customerData?.emailAddress.emailAddress}
-
-
-
- +
+

Orders

+ {orders.map((order, index) => ( +
+ +
+
+ {order.lineItems.slice(0, 2).map((lineItem, index) => ( +
+
+ {lineItem?.image?.altText} +
+

{lineItem.title}

+
+
+ +
+ ))}
-
-
-
{orders && }
+
+
+

+ {order.lineItems.length} item{order.lineItems.length > 1 && 's'} +

+

Order {order.name}

+
+ +
+
-
- + ))} +
); } diff --git a/app/layout.tsx b/app/layout.tsx index a91f6da81..0ffff8bd6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,7 @@ import Banner from 'components/banner'; import Navbar from 'components/layout/navbar'; import { GeistSans } from 'geist/font/sans'; -import { ensureStartsWith } from 'lib/utils'; +import { ensureStartsWith } from 'lib/shopify/utils'; import { ReactNode, Suspense } from 'react'; import './globals.css'; diff --git a/app/sitemap.ts b/app/sitemap.ts index 9a1095b63..88cd70745 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,5 +1,5 @@ import { getCollections, getPages, getProducts } from 'lib/shopify'; -import { validateEnvironmentVariables } from 'lib/utils'; +import { validateEnvironmentVariables } from 'lib/shopify/utils'; import { MetadataRoute } from 'next'; type Route = { diff --git a/components/account/actions.ts b/components/account/actions.ts index aaafc40b4..3006d8f5a 100644 --- a/components/account/actions.ts +++ b/components/account/actions.ts @@ -1,15 +1,12 @@ 'use server'; -import { TAGS } from 'lib/shopify/customer/constants'; -import { removeAllCookiesServerAction } from 'lib/shopify/customer/auth-helpers'; +import { CUSTOMER_API_URL, ORIGIN_URL, removeAllCookiesServerAction } from 'lib/shopify/auth'; import { redirect } from 'next/navigation'; -import { revalidateTag } from 'next/cache'; import { cookies } from 'next/headers'; -import { SHOPIFY_ORIGIN, SHOPIFY_CUSTOMER_ACCOUNT_API_URL } from 'lib/shopify/customer/constants'; export async function doLogout() { - const origin = SHOPIFY_ORIGIN; - const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL; + const origin = ORIGIN_URL; + const customerAccountApiUrl = CUSTOMER_API_URL; let logoutUrl; try { const idToken = cookies().get('shop_id_token'); @@ -26,7 +23,6 @@ export async function doLogout() { ); } await removeAllCookiesServerAction(); - revalidateTag(TAGS.customer); } catch (e) { console.log('Error', e); //you can throw error here or return - return goes back to form b/c of state, throw will throw the error boundary diff --git a/components/auth/actions.ts b/components/auth/actions.ts index 36517cfd9..0692e039e 100644 --- a/components/auth/actions.ts +++ b/components/auth/actions.ts @@ -2,26 +2,21 @@ //https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#forms 'use server'; -import { TAGS } from 'lib/shopify/customer/constants'; import { redirect } from 'next/navigation'; -import { revalidateTag } from 'next/cache'; import { cookies } from 'next/headers'; -//import { getOrigin } from 'lib/shopify/customer' import { generateCodeVerifier, generateCodeChallenge, - generateRandomString -} from 'lib/shopify/customer/auth-utils'; -import { - SHOPIFY_CUSTOMER_ACCOUNT_API_URL, - SHOPIFY_CLIENT_ID, - SHOPIFY_ORIGIN -} from 'lib/shopify/customer/constants'; + generateRandomString, + CUSTOMER_API_CLIENT_ID, + ORIGIN_URL, + CUSTOMER_API_URL +} from 'lib/shopify/auth'; -export async function doLogin(prevState: any) { - const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL; - const clientId = SHOPIFY_CLIENT_ID; - const origin = SHOPIFY_ORIGIN; +export async function doLogin(_: any) { + const customerAccountApiUrl = CUSTOMER_API_URL; + const clientId = CUSTOMER_API_CLIENT_ID; + const origin = ORIGIN_URL; const loginUrl = new URL(`${customerAccountApiUrl}/auth/oauth/authorize`); //console.log ("previous", prevState) @@ -64,6 +59,5 @@ export async function doLogin(prevState: any) { return 'Error logging in. Please try again'; } - revalidateTag(TAGS.customer); redirect(`${loginUrl}`); // Navigate to the new post page } diff --git a/components/breadcrumb/breadcrumb-list.tsx b/components/breadcrumb/breadcrumb-list.tsx index ad8e2cc3d..89b1bf058 100644 --- a/components/breadcrumb/breadcrumb-list.tsx +++ b/components/breadcrumb/breadcrumb-list.tsx @@ -1,5 +1,5 @@ import { ChevronRightIcon, EllipsisHorizontalIcon } from '@heroicons/react/16/solid'; -import { cn } from 'lib/utils'; +import { cn } from 'lib/shopify/utils'; import Link, { LinkProps } from 'next/link'; import { ComponentPropsWithoutRef, ReactNode, forwardRef } from 'react'; diff --git a/components/breadcrumb/index.tsx b/components/breadcrumb/index.tsx index 5a1c4f456..d6089f606 100644 --- a/components/breadcrumb/index.tsx +++ b/components/breadcrumb/index.tsx @@ -1,5 +1,4 @@ import { getCollection, getMenu, getProduct } from 'lib/shopify'; -import { findParentCollection } from 'lib/utils'; import { Fragment } from 'react'; import { Breadcrumb, @@ -9,6 +8,7 @@ import { BreadcrumbPage, BreadcrumbSeparator } from './breadcrumb-list'; +import { findParentCollection } from 'lib/shopify/utils'; type BreadcrumbProps = { type: 'product' | 'collection'; diff --git a/components/button.tsx b/components/button.tsx index fd0cf7b2f..ed7c860ae 100644 --- a/components/button.tsx +++ b/components/button.tsx @@ -1,69 +1,77 @@ +'use client'; import React from 'react'; -import { Slot } from '@radix-ui/react-slot'; +import { Button as ButtonBase, ButtonProps as ButtonBaseProps } from '@headlessui/react'; import { tv, type VariantProps } from 'tailwind-variants'; import clsx from 'clsx'; +import Spinner from './spinner'; const buttonVariants = tv({ - base: [ - // base - 'relative inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-center text-sm font-medium transition-all duration-100 ease-in-out', - // disabled - 'disabled:pointer-events-none disabled:shadow-none' - ], + slots: { + root: [ + // base + 'relative inline-flex items-center justify-center rounded-md', + // text + 'text-center font-medium', + // transition + 'transition-all duration-100 ease-in-out', + // disabled + 'disabled:pointer-events-none disabled:shadow-none' + ], + loading: 'pointer-events-none flex shrink-0 items-center justify-center gap-1.5' + }, variants: { size: { - sm: 'text-xs px-2.5 py-1.5', - md: 'text-sm px-3 py-2', - lg: 'text-base px-4 py-2.5' + sm: { + root: 'text-xs px-2.5 py-1.5' + }, + md: { + root: 'text-sm px-3 py-2' + }, + lg: { + root: 'text-base px-4 py-2.5' + } }, variant: { - primary: [ - // border - 'border-transparent', - // text color - 'text-white', - // background color - 'bg-tremor-brand', - // hover color - 'hover:bg-tremor-brand-emphasis', - // disabled - 'disabled:bg-gray-100', - 'disabled:bg-tremor-brand-muted' - ], - secondary: [ - // border - 'border-gray-300', - // text color - 'text-gray-900', - // background color - ' bg-white', - //hover color - 'hover:bg-gray-50', - // disabled - 'disabled:text-gray-400' - ], - text: [ - // border - 'border-transparent', - // text color - 'text-tremor-brand', - // background color - 'bg-transparent', - // hover color - 'disabled:text-gray-400' - ], - destructive: [ - // text color - 'text-white', - // border - 'border-transparent', - // background color - 'bg-red-600', - // hover color - 'hover:bg-red-700', - // disabled - 'disabled:bg-red-300 disabled:text-white' - ] + primary: { + root: [ + // border + 'border-transparent', + // text color + 'text-white', + // background color + 'bg-primary', + // hover color + 'hover:bg-primary-empahsis', + // disabled + 'disabled:bg-primary-muted' + ] + }, + secondary: { + root: [ + // border + 'border-gray-300', + // text color + 'text-gray-900', + // background color + ' bg-white', + //hover color + 'hover:bg-gray-50', + // disabled + 'disabled:text-gray-400' + ] + }, + text: { + root: [ + // border + 'border-transparent', + // text color + 'text-tremor-brand', + // background color + 'bg-transparent', + // hover color + 'disabled:text-gray-400' + ] + } } }, defaultVariants: { @@ -72,45 +80,44 @@ const buttonVariants = tv({ } }); -interface ButtonProps - extends React.ComponentPropsWithoutRef<'button'>, - VariantProps { - asChild?: boolean; +interface ButtonProps extends ButtonBaseProps, VariantProps { isLoading?: boolean; loadingText?: string; + className?: string; } const Button = React.forwardRef( ( { - asChild, - isLoading = false, - loadingText, + children, className, disabled, + isLoading, + loadingText = 'Loading', + size, variant, - children, ...props }: ButtonProps, forwardedRef ) => { - const Component = asChild ? Slot : 'button'; + const { loading, root } = buttonVariants({ variant, size }); return ( - {isLoading ? ( - - {loadingText ? loadingText : 'Loading'} - {loadingText ? loadingText : children} + + + {loadingText} + {loadingText} ) : ( children )} - + ); } ); diff --git a/components/cart/line-item.tsx b/components/cart/line-item.tsx index 70de7449d..bfc3e1dc4 100644 --- a/components/cart/line-item.tsx +++ b/components/cart/line-item.tsx @@ -2,7 +2,7 @@ import { PlusIcon } from '@heroicons/react/16/solid'; import Price from 'components/price'; import { DEFAULT_OPTION } from 'lib/constants'; import { CartItem } from 'lib/shopify/types'; -import { createUrl } from 'lib/utils'; +import { createUrl } from 'lib/shopify/utils'; import Image from 'next/image'; import Link from 'next/link'; import { DeleteItemButton } from './delete-item-button'; @@ -65,8 +65,10 @@ const LineItem = ({ item, closeCart }: LineItemProps) => { className="h-full w-full object-cover" width={64} height={64} - alt={item.merchandise.product.featuredImage.altText || item.merchandise.product.title} - src={item.merchandise.product.featuredImage.url} + alt={ + item.merchandise.product?.featuredImage?.altText || item.merchandise.product.title + } + src={item.merchandise.product?.featuredImage?.url} />
diff --git a/components/checkbox.tsx b/components/checkbox.tsx index 197cb3b1d..f68455c7d 100644 --- a/components/checkbox.tsx +++ b/components/checkbox.tsx @@ -2,7 +2,7 @@ import { CheckIcon } from '@heroicons/react/24/outline'; import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; -import { cn } from 'lib/utils'; +import { cn } from 'lib/shopify/utils'; import { forwardRef } from 'react'; const Checkbox = forwardRef< diff --git a/components/divider.tsx b/components/divider.tsx new file mode 100644 index 000000000..944f7378d --- /dev/null +++ b/components/divider.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/components/filters/filters-list.tsx b/components/filters/filters-list.tsx index 2bf599822..aeec83244 100644 --- a/components/filters/filters-list.tsx +++ b/components/filters/filters-list.tsx @@ -3,7 +3,7 @@ import { Button } from '@headlessui/react'; import { MAKE_FILTER_ID, MODEL_FILTER_ID, PART_TYPES, YEAR_FILTER_ID } from 'lib/constants'; import { Menu, Metaobject } from 'lib/shopify/types'; -import { createUrl, findParentCollection } from 'lib/utils'; +import { createUrl, findParentCollection } from 'lib/shopify/utils'; import get from 'lodash.get'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useState } from 'react'; diff --git a/components/layout/navbar/search.tsx b/components/layout/navbar/search.tsx index 551d781c2..c2982b25e 100644 --- a/components/layout/navbar/search.tsx +++ b/components/layout/navbar/search.tsx @@ -1,7 +1,7 @@ 'use client'; import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; -import { createUrl } from 'lib/utils'; +import { createUrl } from 'lib/shopify/utils'; import { useRouter, useSearchParams } from 'next/navigation'; export default function Search() { diff --git a/components/layout/search/filters/filters-list.tsx b/components/layout/search/filters/filters-list.tsx index 96e89aa07..a1c137b71 100644 --- a/components/layout/search/filters/filters-list.tsx +++ b/components/layout/search/filters/filters-list.tsx @@ -3,7 +3,7 @@ import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react import { ChevronDownIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; import { Filter, FilterType } from 'lib/shopify/types'; -import { createUrl } from 'lib/utils'; +import { createUrl } from 'lib/shopify/utils'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import PriceRange from './price-range'; import SelectedList from './selected-list'; diff --git a/components/layout/search/filters/price-range.tsx b/components/layout/search/filters/price-range.tsx index 22048d195..a4a2ea9cb 100644 --- a/components/layout/search/filters/price-range.tsx +++ b/components/layout/search/filters/price-range.tsx @@ -3,7 +3,7 @@ import Price from 'components/price'; import { useDebounce } from 'hooks'; import { Filter } from 'lib/shopify/types'; -import { createUrl } from 'lib/utils'; +import { createUrl } from 'lib/shopify/utils'; import get from 'lodash.get'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; diff --git a/components/layout/search/filters/selected-list.tsx b/components/layout/search/filters/selected-list.tsx index 16f8a8cfa..d6429b300 100644 --- a/components/layout/search/filters/selected-list.tsx +++ b/components/layout/search/filters/selected-list.tsx @@ -2,7 +2,7 @@ import { XMarkIcon } from '@heroicons/react/16/solid'; import { Filter } from 'lib/shopify/types'; -import { createUrl } from 'lib/utils'; +import { createUrl } from 'lib/shopify/utils'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; const SelectedList = ({ filters }: { filters: Filter[] }) => { diff --git a/components/layout/search/sorting-menu/item.tsx b/components/layout/search/sorting-menu/item.tsx index c828cc03b..046572229 100644 --- a/components/layout/search/sorting-menu/item.tsx +++ b/components/layout/search/sorting-menu/item.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import { SortFilterItem } from 'lib/constants'; -import { createUrl } from 'lib/utils'; +import { createUrl } from 'lib/shopify/utils'; import Link from 'next/link'; import { usePathname, useSearchParams } from 'next/navigation'; diff --git a/components/manufacturers-grid/button-group.tsx b/components/manufacturers-grid/button-group.tsx index a9385b685..6b40832d1 100644 --- a/components/manufacturers-grid/button-group.tsx +++ b/components/manufacturers-grid/button-group.tsx @@ -3,7 +3,7 @@ import { ArrowRightIcon } from '@heroicons/react/16/solid'; import { MAKE_FILTER_ID } from 'lib/constants'; import { Metaobject } from 'lib/shopify/types'; -import { createUrl } from 'lib/utils'; +import { createUrl } from 'lib/shopify/utils'; import { useRouter, useSearchParams } from 'next/navigation'; const ButtonGroup = ({ manufacturer }: { manufacturer: Metaobject }) => { diff --git a/components/manufacturers-grid/index.tsx b/components/manufacturers-grid/index.tsx index b4b0879d6..bd309f71e 100644 --- a/components/manufacturers-grid/index.tsx +++ b/components/manufacturers-grid/index.tsx @@ -9,7 +9,6 @@ type ManufacturersGridProps = { }; const ManufacturersGrid = ({ manufacturers, variant = 'home' }: ManufacturersGridProps) => { - console.log('manufacturers', manufacturers); const popularManufacturers = manufacturers.filter( (manufacturer) => manufacturer.is_popular === 'true' ); diff --git a/components/price.tsx b/components/price.tsx index 218bc3b21..abfca288d 100644 --- a/components/price.tsx +++ b/components/price.tsx @@ -3,11 +3,13 @@ import clsx from 'clsx'; const Price = ({ amount, className, + as, currencyCode = 'USD', currencyCodeClassName, showCurrency = false }: { amount: string; + as?: 'p' | 'span'; className?: string; currencyCode: string; currencyCodeClassName?: string; @@ -21,9 +23,10 @@ const Price = ({ return

Included

; } + const Component = as || 'p'; // Otherwise, format and display the price return ( -

+ {new Intl.NumberFormat(undefined, { style: 'currency', currency: currencyCode, @@ -32,7 +35,7 @@ const Price = ({ {showCurrency && ( {currencyCode} )} -

+ ); }; diff --git a/components/product/core-charge.tsx b/components/product/core-charge.tsx index efb817ff9..2ac50acdd 100644 --- a/components/product/core-charge.tsx +++ b/components/product/core-charge.tsx @@ -6,7 +6,7 @@ import Price from 'components/price'; import SideDialog from 'components/side-dialog'; import { CORE_VARIANT_ID_KEY, CORE_WAIVER } from 'lib/constants'; import { CoreChargeOption, ProductVariant } from 'lib/shopify/types'; -import { cn, createUrl } from 'lib/utils'; +import { cn, createUrl } from 'lib/shopify/utils'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useState } from 'react'; diff --git a/components/product/gallery.tsx b/components/product/gallery.tsx index efa3771b5..dad362414 100644 --- a/components/product/gallery.tsx +++ b/components/product/gallery.tsx @@ -2,7 +2,7 @@ import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'; import { TileImage } from 'components/grid/tile'; -import { createUrl } from 'lib/utils'; +import { createUrl } from 'lib/shopify/utils'; import Image from 'next/image'; import Link from 'next/link'; import { usePathname, useSearchParams } from 'next/navigation'; diff --git a/components/product/variant-selector.tsx b/components/product/variant-selector.tsx index 1cbc0ffbe..41bd211bb 100644 --- a/components/product/variant-selector.tsx +++ b/components/product/variant-selector.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx'; import Price from 'components/price'; import { CORE_VARIANT_ID_KEY, CORE_WAIVER } from 'lib/constants'; import { CoreChargeOption, Money, ProductOption, ProductVariant } from 'lib/shopify/types'; -import { createUrl } from 'lib/utils'; +import { createUrl } from 'lib/shopify/utils'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { Fragment, useEffect, useState } from 'react'; diff --git a/components/product/warranty-selector.tsx b/components/product/warranty-selector.tsx index 7526f3f29..96ec264f3 100644 --- a/components/product/warranty-selector.tsx +++ b/components/product/warranty-selector.tsx @@ -1,7 +1,7 @@ 'use client'; import Price from 'components/price'; -import { cn } from 'lib/utils'; +import { cn } from 'lib/shopify/utils'; import { ReactNode, useState } from 'react'; const options = ['Included', 'Premium Labor', '+1 Year'] as const; diff --git a/components/profile/popover.tsx b/components/profile/popover.tsx index c421e1a45..2af579db1 100644 --- a/components/profile/popover.tsx +++ b/components/profile/popover.tsx @@ -20,21 +20,15 @@ function SubmitButton(props: any) { <> {props?.message &&
{props?.message}
} ); diff --git a/components/spinner.tsx b/components/spinner.tsx new file mode 100644 index 000000000..a80fa722b --- /dev/null +++ b/components/spinner.tsx @@ -0,0 +1,19 @@ +import clsx from 'clsx'; +import React from 'react'; + +export default function Spinner({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/components/tooltip.tsx b/components/tooltip.tsx index adea2636a..31790b30f 100644 --- a/components/tooltip.tsx +++ b/components/tooltip.tsx @@ -1,6 +1,6 @@ 'use client'; -import { cn } from 'lib/utils'; +import { cn } from 'lib/shopify/utils'; import { ITooltip, Tooltip as ReactTooltip } from 'react-tooltip'; const Tooltip = ({ id, children, className }: ITooltip) => { diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 000000000..a70e349f1 --- /dev/null +++ b/components/ui/badge.tsx @@ -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 { + content: string | number; + className?: string; + children: React.ReactNode; +} + +export default function Badge({ className, color, children, content }: BadgeProps) { + return ( + + {children} + {content} + + ); +} diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 000000000..67a852431 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Slot } from '@radix-ui/react-slot'; + +import { VariantProps, tv } from 'tailwind-variants'; + +const cardStyles = tv({ + base: 'rounded p-6 text-left w-full', + variants: { + outlined: { + true: 'border bg-white', + false: {} + }, + elevated: { + true: 'shadow-lg shadow-content/10 bg-white', + false: {} + } + }, + defaultVariants: { + outlined: true + } +}); + +interface CardProps extends React.ComponentPropsWithoutRef<'div'>, VariantProps { + asChild?: boolean; +} + +const Card = React.forwardRef( + ({ className, asChild, outlined, elevated, ...props }, forwardedRef) => { + const Component = asChild ? Slot : 'div'; + return ( + + ); + } +); + +Card.displayName = 'Card'; + +export { Card, type CardProps }; diff --git a/components/ui/heading.tsx b/components/ui/heading.tsx new file mode 100644 index 000000000..a73dee028 --- /dev/null +++ b/components/ui/heading.tsx @@ -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 { + 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 {children}; +} diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 000000000..1dec14f18 --- /dev/null +++ b/components/ui/label.tsx @@ -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 { + 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 {children}; +} diff --git a/components/ui/text.tsx b/components/ui/text.tsx new file mode 100644 index 000000000..7ce0a09b7 --- /dev/null +++ b/components/ui/text.tsx @@ -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 { + 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 {children}; +} diff --git a/lib/shopify/customer/auth-helpers.ts b/lib/shopify/auth.ts similarity index 50% rename from lib/shopify/customer/auth-helpers.ts rename to lib/shopify/auth.ts index 314d5548c..e115b4b20 100644 --- a/lib/shopify/customer/auth-helpers.ts +++ b/lib/shopify/auth.ts @@ -1,12 +1,59 @@ -//you need to remain this as type so as not to confuse with the actual function -import type { NextRequest, NextResponse as NextResponseType } from 'next/server'; import { cookies } from 'next/headers'; -import { getNonce } from 'lib/shopify/customer/auth-utils'; -import { - SHOPIFY_CUSTOMER_ACCOUNT_API_URL, - SHOPIFY_USER_AGENT, - SHOPIFY_CLIENT_ID -} from './constants'; +import { NextRequest, NextResponse } from 'next/server'; + +export const CUSTOMER_API_URL = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_URL!; +export const CUSTOMER_API_CLIENT_ID = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID || ''; +export const ORIGIN_URL = process.env.SHOPIFY_ORIGIN_URL || ''; +export const USER_AGENT = '*'; + +export async function generateCodeVerifier() { + const randomCode = generateRandomCode(); + return base64UrlEncode(randomCode); +} + +export async function generateCodeChallenge(codeVerifier: string) { + const digestOp = await crypto.subtle.digest( + { name: 'SHA-256' }, + new TextEncoder().encode(codeVerifier) + ); + const hash = convertBufferToString(digestOp); + return base64UrlEncode(hash); +} + +function generateRandomCode() { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return String.fromCharCode.apply(null, Array.from(array)); +} + +function base64UrlEncode(str: string) { + const base64 = btoa(str); + // This is to ensure that the encoding does not have +, /, or = characters in it. + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function convertBufferToString(hash: ArrayBuffer) { + const uintArray = new Uint8Array(hash); + const numberArray = Array.from(uintArray); + return String.fromCharCode(...numberArray); +} + +export async function generateRandomString() { + const timestamp = Date.now().toString(); + const randomString = Math.random().toString(36).substring(2); + return timestamp + randomString; +} + +export async function getNonce(token: string) { + const [header, payload, signature] = token.split('.'); + const decodedHeader = JSON.parse(atob(header || '')); + const decodedPayload = JSON.parse(atob(payload || '')); + return { + header: decodedHeader, + payload: decodedPayload, + signature + }; +} export async function initialAccessToken( request: NextRequest, @@ -58,29 +105,30 @@ export async function initialAccessToken( headersNew.append('User-Agent', userAgent); headersNew.append('Origin', newOrigin || ''); const tokenRequestUrl = `${customerAccountApiUrl}/auth/oauth/token`; + console.log('sending request to', tokenRequestUrl); + const response = await fetch(tokenRequestUrl, { method: 'POST', headers: headersNew, body }); - const data = await response.json(); - console.log('data initial access token', data); + console.log('ok', response.ok); if (!response.ok) { - console.log('data response error auth', data.error); + const error = await response.text(); + console.log('data response error auth', error); console.log('response auth', response.status); return { success: false, message: `Response error auth` }; } + const data = await response.json(); if (data?.errors) { const errorMessage = data?.errors?.[0]?.message ?? 'Unknown error auth'; return { success: false, message: `${errorMessage}` }; } const nonce = await getNonce(data?.id_token || ''); + const nonceValue = nonce.payload.nonce; const shopNonce = request.cookies.get('shop_nonce'); const shopNonceValue = shopNonce?.value; - console.log('sent nonce', nonce); - console.log('original nonce', shopNonceValue); - if (nonce !== shopNonceValue) { - //make equal === to force error for testing + if (nonceValue !== shopNonceValue) { console.log('Error nonce match'); return { success: false, message: `Error: Nonce mismatch` }; } @@ -134,18 +182,16 @@ export async function refreshToken({ request, origin }: { request: NextRequest; console.log('Error: No Refresh Token'); return { success: false, message: `no_refresh_token` }; } - const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL; - const clientId = SHOPIFY_CLIENT_ID; - const userAgent = SHOPIFY_USER_AGENT; + const clientId = CUSTOMER_API_CLIENT_ID; newBody.append('grant_type', 'refresh_token'); newBody.append('refresh_token', refreshTokenValue); newBody.append('client_id', clientId); const headers = { 'content-type': 'application/x-www-form-urlencoded', - 'User-Agent': userAgent, + 'User-Agent': USER_AGENT, Origin: origin }; - const tokenRequestUrl = `${customerAccountApiUrl}/auth/oauth/token`; + const tokenRequestUrl = `${CUSTOMER_API_URL}/auth/oauth/token`; const response = await fetch(tokenRequestUrl, { method: 'POST', headers, @@ -164,7 +210,7 @@ export async function refreshToken({ request, origin }: { request: NextRequest; const customerAccessToken = await exchangeAccessToken( access_token, clientId, - customerAccountApiUrl, + CUSTOMER_API_URL, origin ); // console.log("Customer Access Token in refresh request", customerAccessToken) @@ -203,7 +249,7 @@ export async function checkExpires({ return { ranRefresh: isExpired, success: true }; } -export function removeAllCookies(response: NextResponseType) { +export function removeAllCookies(response: NextResponse) { //response.cookies.delete('shop_auth_token') //never set. We don't use it anywhere. response.cookies.delete('shop_customer_token'); response.cookies.delete('shop_refresh_token'); @@ -234,7 +280,7 @@ export async function createAllCookies({ expiresAt, id_token }: { - response: NextResponseType; + response: NextResponse; customerAccessToken: string; expires_in: number; refresh_token: string; @@ -284,3 +330,181 @@ export async function createAllCookies({ return response; } +export async function isLoggedIn(request: NextRequest, origin: string) { + const customerToken = request.cookies.get('shop_customer_token'); + const customerTokenValue = customerToken?.value; + const refreshToken = request.cookies.get('shop_refresh_token'); + const refreshTokenValue = refreshToken?.value; + + console.log('customer token', customerTokenValue); + const newHeaders = new Headers(request.headers); + if (!customerTokenValue && !refreshTokenValue) { + const redirectUrl = new URL(`${origin}`); + const response = NextResponse.redirect(`${redirectUrl}`); + return removeAllCookies(response); + } + + const expiresToken = request.cookies.get('shop_expires_at'); + const expiresTokenValue = expiresToken?.value; + if (!expiresTokenValue) { + const redirectUrl = new URL(`${origin}`); + const response = NextResponse.redirect(`${redirectUrl}`); + return removeAllCookies(response); + //return { success: false, message: `no_expires_at` } + } + const isExpired = await checkExpires({ + request: request, + expiresAt: expiresTokenValue, + origin: origin + }); + console.log('is Expired?', isExpired); + //only execute the code below to reset the cookies if it was expired! + if (isExpired.ranRefresh) { + const isSuccess = isExpired?.refresh?.success; + if (!isSuccess) { + const redirectUrl = new URL(`${origin}`); + const response = NextResponse.redirect(`${redirectUrl}`); + return removeAllCookies(response); + //return { success: false, message: `no_refresh_token` } + } else { + const refreshData = isExpired?.refresh?.data; + //console.log ("refresh data", refreshData) + console.log('We used the refresh token, so now going to reset the token and cookies'); + const newCustomerAccessToken = refreshData?.customerAccessToken; + const expires_in = refreshData?.expires_in; + //const test_expires_in = 180 //to test to see if it expires in 60 seconds! + const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + ''; + newHeaders.set('x-shop-customer-token', `${newCustomerAccessToken}`); + const resetCookieResponse = NextResponse.next({ + request: { + // New request headers + headers: newHeaders + } + }); + return await createAllCookies({ + response: resetCookieResponse, + customerAccessToken: newCustomerAccessToken, + expires_in, + refresh_token: refreshData?.refresh_token, + expiresAt + }); + } + } + + newHeaders.set('x-shop-customer-token', `${customerTokenValue}`); + return NextResponse.next({ + request: { + // New request headers + headers: newHeaders + } + }); +} + +//when we are running on the production website we just get the origin from the request.nextUrl +export function getOrigin(request: NextRequest) { + const nextOrigin = request.nextUrl.origin; + console.log('Current Origin', nextOrigin); + //when running localhost, we want to use fake origin otherwise we use the real origin + let newOrigin = nextOrigin; + if (nextOrigin === 'https://localhost:3000' || nextOrigin === 'http://localhost:3000') { + newOrigin = ORIGIN_URL; + } else { + newOrigin = nextOrigin; + } + console.log('New Origin', newOrigin); + return newOrigin; +} + +export async function authorize(request: NextRequest, origin: string) { + const clientId = CUSTOMER_API_CLIENT_ID; + const newHeaders = new Headers(request.headers); + /*** + STEP 1: Get the initial access token or deny access + ****/ + const dataInitialToken = await initialAccessToken(request, origin, CUSTOMER_API_URL, clientId); + console.log('data initial token', dataInitialToken); + if (!dataInitialToken.success) { + console.log('Error: Access Denied. Check logs', dataInitialToken.message); + newHeaders.set('x-shop-access', 'denied'); + return NextResponse.next({ + request: { + // New request headers + headers: newHeaders + } + }); + } + const { access_token, expires_in, id_token, refresh_token } = dataInitialToken.data; + /*** + STEP 2: Get a Customer Access Token + ****/ + const customerAccessToken = await exchangeAccessToken( + access_token, + clientId, + CUSTOMER_API_URL, + origin || '' + ); + console.log('customer access token', customerAccessToken); + if (!customerAccessToken.success) { + console.log('Error: Customer Access Token'); + newHeaders.set('x-shop-access', 'denied'); + return NextResponse.next({ + request: { + // New request headers + headers: newHeaders + } + }); + } + //console.log("customer access Token", customerAccessToken.data.access_token) + /**STEP 3: Set Customer Access Token cookies + We are setting the cookies here b/c if we set it on the request, and then redirect + it doesn't see to set sometimes + **/ + newHeaders.set('x-shop-access', 'allowed'); + /* + const authResponse = NextResponse.next({ + request: { + // New request headers + headers: newHeaders, + }, + }) + */ + const accountUrl = new URL(`${origin}/account`); + const authResponse = NextResponse.redirect(`${accountUrl}`); + + //sets an expires time 2 minutes before expiration which we can use in refresh strategy + //const test_expires_in = 180 //to test to see if it expires in 60 seconds! + const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + ''; + console.log('expires at', expiresAt); + + return await createAllCookies({ + response: authResponse, + customerAccessToken: customerAccessToken?.data?.access_token, + expires_in, + refresh_token, + expiresAt, + id_token + }); +} + +export async function logout(request: NextRequest, origin: string) { + //console.log("New Origin", newOrigin) + const idToken = request.cookies.get('shop_id_token'); + const idTokenValue = idToken?.value; + //revalidateTag(TAGS.customer); //this causes some strange error in Nextjs about invariant, so removing for now + + //if there is no idToken, then sending to logout url will redirect shopify, so just + //redirect to login here and delete cookies (presumably they don't even exist) + if (!idTokenValue) { + const logoutUrl = new URL(`${origin}/login`); + const response = NextResponse.redirect(`${logoutUrl}`); + return removeAllCookies(response); + } + + //console.log ("id toke value", idTokenValue) + const logoutUrl = new URL( + `${CUSTOMER_API_URL}/auth/logout?id_token_hint=${idTokenValue}&post_logout_redirect_uri=${origin}` + ); + //console.log ("logout url", logoutUrl) + const logoutResponse = NextResponse.redirect(logoutUrl); + return removeAllCookies(logoutResponse); +} diff --git a/lib/shopify/customer/auth-utils.ts b/lib/shopify/customer/auth-utils.ts deleted file mode 100644 index 63baa8a07..000000000 --- a/lib/shopify/customer/auth-utils.ts +++ /dev/null @@ -1,48 +0,0 @@ -// @ts-nocheck -export async function generateCodeVerifier() { - const randomCode = generateRandomCode(); - return base64UrlEncode(randomCode); -} -export async function generateCodeChallenge(codeVerifier: string) { - const digestOp = await crypto.subtle.digest( - { name: 'SHA-256' }, - new TextEncoder().encode(codeVerifier) - ); - const hash = convertBufferToString(digestOp); - return base64UrlEncode(hash); -} -function generateRandomCode() { - const array = new Uint8Array(32); - crypto.getRandomValues(array); - return String.fromCharCode.apply(null, Array.from(array)); -} -function base64UrlEncode(str: string) { - const base64 = btoa(str); - // This is to ensure that the encoding does not have +, /, or = characters in it. - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); -} -function convertBufferToString(hash: ArrayBuffer) { - const uintArray = new Uint8Array(hash); - const numberArray = Array.from(uintArray); - return String.fromCharCode(...numberArray); -} - -export async function generateRandomString() { - const timestamp = Date.now().toString(); - const randomString = Math.random().toString(36).substring(2); - return timestamp + randomString; -} - -export async function getNonce(token: string) { - return decodeJwt(token).payload.nonce; -} -function decodeJwt(token: string) { - const [header, payload, signature] = token.split('.'); - const decodedHeader = JSON.parse(atob(header || '')); - const decodedPayload = JSON.parse(atob(payload || '')); - return { - header: decodedHeader, - payload: decodedPayload, - signature - }; -} diff --git a/lib/shopify/customer/constants.ts b/lib/shopify/customer/constants.ts deleted file mode 100644 index 3c04d9162..000000000 --- a/lib/shopify/customer/constants.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const TAGS = { - customer: 'customer' -}; - -//ENVs -export const SHOPIFY_CUSTOMER_ACCOUNT_API_URL = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_URL || ''; -export const SHOPIFY_CLIENT_ID = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID || ''; -export const SHOPIFY_CUSTOMER_API_VERSION = process.env.SHOPIFY_CUSTOMER_API_VERSION || ''; -export const SHOPIFY_USER_AGENT = '*'; -export const SHOPIFY_ORIGIN = process.env.SHOPIFY_ORIGIN_URL || ''; diff --git a/lib/shopify/customer/index.ts b/lib/shopify/customer/index.ts deleted file mode 100644 index 590e26c7e..000000000 --- a/lib/shopify/customer/index.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { NextRequest } from 'next/server'; -import { NextResponse } from 'next/server'; -import { - checkExpires, - removeAllCookies, - initialAccessToken, - exchangeAccessToken, - createAllCookies -} from './auth-helpers'; -import { isShopifyError } from 'lib/type-guards'; -import { parseJSON } from 'lib/shopify/customer/utils/parse-json'; -import { - SHOPIFY_CUSTOMER_ACCOUNT_API_URL, - SHOPIFY_USER_AGENT, - SHOPIFY_CUSTOMER_API_VERSION, - SHOPIFY_CLIENT_ID, - SHOPIFY_ORIGIN -} from './constants'; - -type ExtractVariables = T extends { variables: object } ? T['variables'] : never; -const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL; -const apiVersion = SHOPIFY_CUSTOMER_API_VERSION; -const userAgent = SHOPIFY_USER_AGENT; -const customerEndpoint = `${customerAccountApiUrl}/account/customer/api/${apiVersion}/graphql`; - -//NEVER CACHE THIS! Doesn't see to be cached anyway b/c -//https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#opting-out-of-data-caching -//The fetch request comes after the usage of headers or cookies. -//and we always send this anyway after getting a cookie for the customer -export async function shopifyCustomerFetch({ - customerToken, - query, - tags, - variables -}: { - cache?: RequestCache; - customerToken: string; - query: string; - tags?: string[]; - variables?: ExtractVariables; -}): Promise<{ status: number; body: T } | never> { - try { - const customerOrigin = SHOPIFY_ORIGIN; - const result = await fetch(customerEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': userAgent, - Origin: customerOrigin, - Authorization: customerToken - }, - body: JSON.stringify({ - ...(query && { query }), - ...(variables && { variables }) - }), - cache: 'no-store', - ...(tags && { next: { tags } }) - }); - - const body = await result.json(); - - if (!result.ok) { - //the statuses here could be different, a 401 means - //https://shopify.dev/docs/api/customer#endpoints - //401 means the token is bad - console.log('Error in Customer Fetch Status', body.errors); - if (result.status === 401) { - // clear session because current access token is invalid - const errorMessage = 'unauthorized'; - throw errorMessage; //this should throw in the catch below in the non-shopify catch - } - let errors; - try { - errors = parseJSON(body); - } catch (_e) { - errors = [{ message: body }]; - } - throw errors; - } - - //this just throws an error and the error boundary is called - if (body.errors) { - //throw 'Error' - console.log('Error in Customer Fetch', body.errors[0]); - throw body.errors[0]; - } - - return { - status: result.status, - body - }; - } catch (e) { - if (isShopifyError(e)) { - throw { - cause: e.cause?.toString() || 'unknown', - status: e.status || 500, - message: e.message, - query - }; - } - - throw { - error: e, - query - }; - } -} - -export async function isLoggedIn(request: NextRequest, origin: string) { - const customerToken = request.cookies.get('shop_customer_token'); - const customerTokenValue = customerToken?.value; - const refreshToken = request.cookies.get('shop_refresh_token'); - const refreshTokenValue = refreshToken?.value; - const newHeaders = new Headers(request.headers); - if (!customerTokenValue && !refreshTokenValue) { - const redirectUrl = new URL(`${origin}`); - const response = NextResponse.redirect(`${redirectUrl}`); - return removeAllCookies(response); - } - - const expiresToken = request.cookies.get('shop_expires_at'); - const expiresTokenValue = expiresToken?.value; - if (!expiresTokenValue) { - const redirectUrl = new URL(`${origin}`); - const response = NextResponse.redirect(`${redirectUrl}`); - return removeAllCookies(response); - //return { success: false, message: `no_expires_at` } - } - const isExpired = await checkExpires({ - request: request, - expiresAt: expiresTokenValue, - origin: origin - }); - console.log('is Expired?', isExpired); - //only execute the code below to reset the cookies if it was expired! - if (isExpired.ranRefresh) { - const isSuccess = isExpired?.refresh?.success; - if (!isSuccess) { - const redirectUrl = new URL(`${origin}`); - const response = NextResponse.redirect(`${redirectUrl}`); - return removeAllCookies(response); - //return { success: false, message: `no_refresh_token` } - } else { - const refreshData = isExpired?.refresh?.data; - //console.log ("refresh data", refreshData) - console.log('We used the refresh token, so now going to reset the token and cookies'); - const newCustomerAccessToken = refreshData?.customerAccessToken; - const expires_in = refreshData?.expires_in; - //const test_expires_in = 180 //to test to see if it expires in 60 seconds! - const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + ''; - newHeaders.set('x-shop-customer-token', `${newCustomerAccessToken}`); - const resetCookieResponse = NextResponse.next({ - request: { - // New request headers - headers: newHeaders - } - }); - return await createAllCookies({ - response: resetCookieResponse, - customerAccessToken: newCustomerAccessToken, - expires_in, - refresh_token: refreshData?.refresh_token, - expiresAt - }); - } - } - - newHeaders.set('x-shop-customer-token', `${customerTokenValue}`); - return NextResponse.next({ - request: { - // New request headers - headers: newHeaders - } - }); -} - -//when we are running on the production website we just get the origin from the request.nextUrl -export function getOrigin(request: NextRequest) { - const nextOrigin = request.nextUrl.origin; - //console.log("Current Origin", nextOrigin) - //when running localhost, we want to use fake origin otherwise we use the real origin - let newOrigin = nextOrigin; - if (nextOrigin === 'https://localhost:3000' || nextOrigin === 'http://localhost:3000') { - newOrigin = SHOPIFY_ORIGIN; - } else { - newOrigin = nextOrigin; - } - return newOrigin; -} - -export async function authorizeFn(request: NextRequest, origin: string) { - const clientId = SHOPIFY_CLIENT_ID; - const newHeaders = new Headers(request.headers); - /*** - STEP 1: Get the initial access token or deny access - ****/ - const dataInitialToken = await initialAccessToken( - request, - origin, - customerAccountApiUrl, - clientId - ); - if (!dataInitialToken.success) { - console.log('Error: Access Denied. Check logs', dataInitialToken.message); - newHeaders.set('x-shop-access', 'denied'); - return NextResponse.next({ - request: { - // New request headers - headers: newHeaders - } - }); - } - const { access_token, expires_in, id_token, refresh_token } = dataInitialToken.data; - /*** - STEP 2: Get a Customer Access Token - ****/ - const customerAccessToken = await exchangeAccessToken( - access_token, - clientId, - customerAccountApiUrl, - origin || '' - ); - if (!customerAccessToken.success) { - console.log('Error: Customer Access Token'); - newHeaders.set('x-shop-access', 'denied'); - return NextResponse.next({ - request: { - // New request headers - headers: newHeaders - } - }); - } - //console.log("customer access Token", customerAccessToken.data.access_token) - /**STEP 3: Set Customer Access Token cookies - We are setting the cookies here b/c if we set it on the request, and then redirect - it doesn't see to set sometimes - **/ - newHeaders.set('x-shop-access', 'allowed'); - /* - const authResponse = NextResponse.next({ - request: { - // New request headers - headers: newHeaders, - }, - }) - */ - const accountUrl = new URL(`${origin}/account`); - const authResponse = NextResponse.redirect(`${accountUrl}`); - - //sets an expires time 2 minutes before expiration which we can use in refresh strategy - //const test_expires_in = 180 //to test to see if it expires in 60 seconds! - const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + ''; - - return await createAllCookies({ - response: authResponse, - customerAccessToken: customerAccessToken?.data?.access_token, - expires_in, - refresh_token, - expiresAt, - id_token - }); -} - -export async function logoutFn(request: NextRequest, origin: string) { - //console.log("New Origin", newOrigin) - const idToken = request.cookies.get('shop_id_token'); - const idTokenValue = idToken?.value; - //revalidateTag(TAGS.customer); //this causes some strange error in Nextjs about invariant, so removing for now - - //if there is no idToken, then sending to logout url will redirect shopify, so just - //redirect to login here and delete cookies (presumably they don't even exist) - if (!idTokenValue) { - const logoutUrl = new URL(`${origin}/login`); - const response = NextResponse.redirect(`${logoutUrl}`); - return removeAllCookies(response); - } - - //console.log ("id toke value", idTokenValue) - const logoutUrl = new URL( - `${customerAccountApiUrl}/auth/logout?id_token_hint=${idTokenValue}&post_logout_redirect_uri=${origin}` - ); - //console.log ("logout url", logoutUrl) - const logoutResponse = NextResponse.redirect(logoutUrl); - return removeAllCookies(logoutResponse); -} diff --git a/lib/shopify/customer/queries/customer.ts b/lib/shopify/customer/queries/customer.ts deleted file mode 100644 index a12110115..000000000 --- a/lib/shopify/customer/queries/customer.ts +++ /dev/null @@ -1,97 +0,0 @@ -//https://shopify.dev/docs/api/customer/2024-01/queries/customer -export const CUSTOMER_ME_QUERY = /* GraphQL */ ` - query customer { - customer { - emailAddress { - emailAddress - } - firstName - lastName - tags - } - } -`; - -const CUSTOMER_FRAGMENT = `#graphql - fragment OrderCard on Order { - id - number - processedAt - financialStatus - fulfillments(first: 1) { - nodes { - status - } - } - totalPrice { - amount - currencyCode - } - lineItems(first: 2) { - edges { - node { - title - image { - altText - height - url - width - } - } - } - } - } - - fragment AddressPartial on CustomerAddress { - id - formatted - firstName - lastName - company - address1 - address2 - territoryCode - zoneCode - city - zip - phoneNumber - } - - fragment CustomerDetails on Customer { - firstName - lastName - phoneNumber { - phoneNumber - } - emailAddress { - emailAddress - } - defaultAddress { - ...AddressPartial - } - addresses(first: 6) { - edges { - node { - ...AddressPartial - } - } - } - orders(first: 250, sortKey: PROCESSED_AT, reverse: true) { - edges { - node { - ...OrderCard - } - } - } - } -` as const; - -// NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer -export const CUSTOMER_DETAILS_QUERY = `#graphql - query CustomerDetails { - customer { - ...CustomerDetails - } - } - ${CUSTOMER_FRAGMENT} -` as const; diff --git a/lib/shopify/customer/types.ts b/lib/shopify/customer/types.ts deleted file mode 100644 index 2f9915148..000000000 --- a/lib/shopify/customer/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -export type Maybe = T | null; - -export type Connection = { - edges: Array>; -}; - -export type Edge = { - node: T; -}; - -export type CustomerData = { - data: { - customer: { - emailAddress: { - emailAddress: string; - }; - firstName: string; - lastName: string; - tags: any[]; - }; - }; -}; - -export type GenericObject = { [key: string]: any }; - -export type CustomerDetailsData = { - data: { - customer: { - emailAddress: { - emailAddress: string; - }; - // Using GenericObject to type 'orders' since the fields are not known in advance - orders: Connection; - }; - }; -}; diff --git a/lib/shopify/customer/utils/parse-json.ts b/lib/shopify/customer/utils/parse-json.ts deleted file mode 100644 index 3bf536d16..000000000 --- a/lib/shopify/customer/utils/parse-json.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function parseJSON(json: any) { - if (String(json).includes('__proto__')) return JSON.parse(json, noproto); - return JSON.parse(json); -} -function noproto(k: string, v: string) { - if (k !== '__proto__') return v; -} diff --git a/lib/shopify/fragments/customer-address.ts b/lib/shopify/fragments/customer-address.ts new file mode 100644 index 000000000..b407ac084 --- /dev/null +++ b/lib/shopify/fragments/customer-address.ts @@ -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; diff --git a/lib/shopify/fragments/customer-details.ts b/lib/shopify/fragments/customer-details.ts new file mode 100644 index 000000000..5240a7d5f --- /dev/null +++ b/lib/shopify/fragments/customer-details.ts @@ -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; diff --git a/lib/shopify/fragments/order-card.ts b/lib/shopify/fragments/order-card.ts new file mode 100644 index 000000000..98352add9 --- /dev/null +++ b/lib/shopify/fragments/order-card.ts @@ -0,0 +1,35 @@ +const orderCard = /* GraphQL */ ` + fragment OrderCard on Order { + id + number + name + processedAt + financialStatus + fulfillments(first: 1) { + edges { + node { + status + } + } + } + totalPrice { + amount + currencyCode + } + lineItems(first: 20) { + edges { + node { + title + image { + altText + height + url + width + } + } + } + } + } +`; + +export default orderCard; diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts index 1ffaae701..3e1808256 100644 --- a/lib/shopify/index.ts +++ b/lib/shopify/index.ts @@ -11,7 +11,7 @@ import { YEAR_FILTER_ID } from 'lib/constants'; import { isShopifyError } from 'lib/type-guards'; -import { ensureStartsWith, normalizeUrl, parseMetaFieldValue } from 'lib/utils'; +import { ensureStartsWith, normalizeUrl, parseJSON, parseMetaFieldValue } from 'lib/shopify/utils'; import { revalidatePath, revalidateTag } from 'next/cache'; import { headers } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; @@ -38,16 +38,21 @@ import { getProductsQuery } from './queries/product'; import { + Address, Cart, CartAttributeInput, CartItem, Collection, Connection, + Customer, Filter, Image, Menu, Metaobject, Money, + Order, + Fulfillment, + Transaction, Page, PageInfo, Product, @@ -60,6 +65,9 @@ import { ShopifyCollectionProductsOperation, ShopifyCollectionsOperation, ShopifyCreateCartOperation, + ShopifyCustomerOperation, + ShopifyCustomerOrderOperation, + ShopifyCustomerOrdersOperation, ShopifyFilter, ShopifyImageOperation, ShopifyMenuOperation, @@ -75,13 +83,31 @@ import { ShopifyProductsOperation, ShopifyRemoveFromCartOperation, ShopifySetCartAttributesOperation, - ShopifyUpdateCartOperation + ShopifyUpdateCartOperation, + ShopifyCustomer, + ShopifyOrder, + ShopifyAddress, + ShopifyMoneyV2, + LineItem } from './types'; +import { getCustomerQuery } from './queries/customer'; +import { getCustomerOrdersQuery } from './queries/orders'; +import { getCustomerOrderQuery } from './queries/order'; const domain = process.env.SHOPIFY_STORE_DOMAIN ? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://') : ''; -const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`; + +const customerApiUrl = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_URL; +const customerApiVersion = process.env.SHOPIFY_CUSTOMER_API_VERSION; + +const storefrontEndpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`; +const customerEndpoint = `${customerApiUrl}/account/customer/api/${customerApiVersion}/graphql`; + +const userAgent = '*'; +const placeholderProductImage = + 'https://cdn.shopify.com/shopifycloud/customer-account-web/production/assets/8bc6556601c510713d76.svg'; + const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!; type ExtractVariables = T extends { variables: object } ? T['variables'] : never; @@ -100,7 +126,7 @@ export async function shopifyFetch({ variables?: ExtractVariables; }): Promise<{ status: number; body: T } | never> { try { - const result = await fetch(endpoint, { + const result = await fetch(storefrontEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -142,6 +168,80 @@ export async function shopifyFetch({ } } +export async function shopifyCustomerFetch({ + query, + variables +}: { + query: string; + variables?: ExtractVariables; +}): 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) => { return array.edges.map((edge) => edge?.node); }; @@ -316,6 +416,143 @@ const reshapeProducts = (products: ShopifyProduct[]) => { return reshapedProducts; }; +function reshapeCustomer(customer: ShopifyCustomer): Customer { + return { + firstName: customer.firstName, + lastName: customer.lastName, + displayName: customer.displayName, + emailAddress: customer.emailAddress.emailAddress + }; +} + +function reshapeOrders(orders: ShopifyOrder[]): any[] | Promise { + const reshapedOrders: Order[] = []; + + for (const order of orders) { + const reshapedOrder = reshapeOrder(order); + if (!reshapedOrder) continue; + + reshapedOrders.push(reshapedOrder); + } + + return reshapedOrders; +} + +function reshapeOrder(shopifyOrder: ShopifyOrder): Order { + const reshapeAddress = (address?: ShopifyAddress): Address | undefined => { + if (!address) return undefined; + return { + address1: address.address1, + address2: address.address2, + firstName: address.firstName, + lastName: address.lastName, + provinceCode: address.provinceCode, + city: address.city, + zip: address.zip, + country: address.countryCodeV2, + company: address.company, + phone: address.phone + }; + }; + + const reshapeMoney = (money?: ShopifyMoneyV2): Money | undefined => { + if (!money) return undefined; + return { + amount: money.amount || '0.00', + currencyCode: money.currencyCode || 'USD' + }; + }; + + const orderFulfillments: Fulfillment[] = + shopifyOrder.fulfillments?.edges?.map((edge) => ({ + status: edge.node.status, + createdAt: edge.node.createdAt, + trackingInformation: + edge.node.trackingInformation?.map((tracking) => ({ + number: tracking.number, + company: tracking.company, + url: tracking.url + })) || [], + events: + edge.node.events?.edges.map((event) => ({ + status: event.node.status, + happenedAt: event.node.happenedAt + })) || [], + fulfilledLineItems: + edge.node.fulfillmentLineItems?.nodes.map((lineItem) => ({ + id: lineItem.lineItem.id, + quantity: lineItem.quantity, + image: { + url: lineItem.lineItem.image?.url || placeholderProductImage, + altText: lineItem.lineItem.image?.altText || lineItem.lineItem.title, + width: 100, + height: 100 + } + })) || [] + })) || []; + + const orderTransactions: Transaction[] = shopifyOrder.transactions?.map((transaction) => ({ + processedAt: transaction.processedAt, + paymentIcon: { + url: transaction.paymentIcon.url, + altText: transaction.paymentIcon.altText, + width: 100, + height: 100 + }, + paymentDetails: { + last4: transaction.paymentDetails.last4, + cardBrand: transaction.paymentDetails.cardBrand + }, + transactionAmount: reshapeMoney(transaction.transactionAmount.presentmentMoney)! + })); + + const orderLineItems: LineItem[] = + shopifyOrder.lineItems?.edges.map((edge) => ({ + id: edge.node.id, + title: edge.node.title, + quantity: edge.node.quantity, + image: { + url: edge.node.image?.url || placeholderProductImage, + altText: edge.node.image?.altText || edge.node.title, + width: edge.node.image?.width || 62, + height: edge.node.image?.height || 62 + }, + price: reshapeMoney(edge.node.price), + totalPrice: reshapeMoney(edge.node.totalPrice), + variantTitle: edge.node.variantTitle, + sku: edge.node.sku + })) || []; + + const order: Order = { + id: shopifyOrder.id.replace('gid://shopify/Order/', ''), + name: shopifyOrder.name, + processedAt: shopifyOrder.processedAt, + fulfillments: orderFulfillments, + transactions: orderTransactions, + lineItems: orderLineItems, + shippingAddress: reshapeAddress(shopifyOrder.shippingAddress), + billingAddress: reshapeAddress(shopifyOrder.billingAddress), + subtotal: reshapeMoney(shopifyOrder.subtotal), + totalShipping: reshapeMoney(shopifyOrder.totalShipping), + totalTax: reshapeMoney(shopifyOrder.totalTax), + totalPrice: reshapeMoney(shopifyOrder.totalPrice) + }; + + if (shopifyOrder.customer) { + order.customer = reshapeCustomer(shopifyOrder.customer); + } + + if (shopifyOrder.shippingLine) { + console.log('Shipping Line', shopifyOrder.shippingLine); + order.shippingMethod = { + name: shopifyOrder.shippingLine?.title, + price: reshapeMoney(shopifyOrder.shippingLine.originalPrice)! + }; + } + + return order; +} + export async function createCart(): Promise { const res = await shopifyFetch({ query: createCartMutation, @@ -650,6 +887,33 @@ export async function getProducts({ pageInfo }; } + +export async function getCustomer(): Promise { + const res = await shopifyCustomerFetch({ + query: getCustomerQuery + }); + + const customer = res.body.data.customer; + return reshapeCustomer(customer); +} + +export async function getCustomerOrders(): Promise { + const res = await shopifyCustomerFetch({ + query: getCustomerOrdersQuery + }); + + return reshapeOrders(removeEdgesAndNodes(res.body.data.customer.orders)); +} + +export async function getCustomerOrder(orderId: string): Promise { + const res = await shopifyCustomerFetch({ + query: getCustomerOrderQuery, + variables: { orderId: `gid://shopify/Order/${orderId}` } + }); + + return reshapeOrder(res.body.data.order); +} + // This is called from `app/api/revalidate.ts` so providers can control revalidation logic. export async function revalidate(req: NextRequest): Promise { console.log(`Receiving revalidation request from Shopify.`); diff --git a/lib/shopify/queries/customer.ts b/lib/shopify/queries/customer.ts new file mode 100644 index 000000000..4f9f2bb24 --- /dev/null +++ b/lib/shopify/queries/customer.ts @@ -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 + } + } +`; diff --git a/lib/shopify/queries/order.ts b/lib/shopify/queries/order.ts new file mode 100644 index 000000000..cc8e7d5f8 --- /dev/null +++ b/lib/shopify/queries/order.ts @@ -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 + } + } + } + } + } +`; diff --git a/lib/shopify/queries/orders.ts b/lib/shopify/queries/orders.ts new file mode 100644 index 000000000..1c55a2b9a --- /dev/null +++ b/lib/shopify/queries/orders.ts @@ -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} +`; diff --git a/lib/shopify/types.ts b/lib/shopify/types.ts index 7443823c4..4416d34af 100644 --- a/lib/shopify/types.ts +++ b/lib/shopify/types.ts @@ -35,6 +35,13 @@ export type Collection = ShopifyCollection & { path: string; }; +export type Customer = { + emailAddress: string; + firstName?: string; + lastName?: string; + displayName?: string; +}; + export type Image = { url: string; altText: string; @@ -58,6 +65,278 @@ export type PageMetafield = { value: string; }; +export type Fulfillment = { + status: string; + createdAt: string; + fulfilledLineItems: { + id: string; + quantity: number; + image: Image; + }[]; + trackingInformation: { + number: string; + company: string; + url: string; + }[]; + events: { + status: string; + happenedAt: string; + }[]; +}; + +export type Transaction = { + processedAt: string; + paymentIcon: Image; + paymentDetails: { + last4: string; + cardBrand: string; + }; + transactionAmount: Money; +}; + +export type Address = { + address1: string; + address2: string | null; + firstName: string; + lastName: string; + provinceCode: string; + city: string; + zip: string; + country: string; + company: string | null; + phone: string; +}; + +export type LineItem = { + id: string; + title: string; + image: Image; + price?: Money; + quantity?: number; + sku?: string; + totalPrice?: Money; + variantTitle?: string; +}; + +export type Order = { + id: string; + name: string; + customer?: Customer; + processedAt: string; + fulfillments: Fulfillment[]; + transactions: Transaction[]; + lineItems: LineItem[]; + shippingAddress: Address; + billingAddress: Address; + /** the price of all line items, excluding taxes and surcharges */ + subtotal: Money; + totalShipping: Money; + totalTax: Money; + totalPrice: Money; + shippingMethod?: { + name: string; + price: Money; + }; +}; + +export type ShopifyOrder = { + id: string; + name: string; + confirmationNumber: string; + customer: ShopifyCustomer; + processedAt: string; + cancelledAt: string | null; + currencyCode: string; + transactions: ShopifyOrderTransaction[]; + billingAddress: ShopifyAddress; + shippingAddress: ShopifyAddress; + fulfillments: Connection; + lineItems: Connection; + 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; +}; + +type ShopifyTrackingInformation = { + number: string; + company: string; + url: string; +}; + +type ShopifyFulfillmentLineItemConnection = { + nodes: ShopifyFulfillmentLineItem[]; +}; + +type ShopifyFulfillmentLineItem = { + id: string; + quantity: number; + lineItem: ShopifyLineItem; +}; + +type ShopifyLineItem = { + id: string; + title: string; + image: ShopifyImage; + price: ShopifyMoneyV2; + quantity: number; + sku: string; + totalPrice: ShopifyMoneyV2; + variantTitle: string; +}; + +type ShopifyImage = { + altText: string; + height: number; + url: string; + width: number; +}; + +type ShopifyFulfillmentEventConnection = { + edges: ShopifyFulfillmentEventEdge[]; +}; + +type ShopifyFulfillmentEventEdge = { + node: ShopifyFulfillmentEvent; +}; + +type ShopifyFulfillmentEvent = { + status: string; + happenedAt: string; +}; + +type ShopifyRefund = { + id: string; + createdAt: string; +}; + +type ShopifyOrderPaymentInformation = { + paymentCollectionUrl: string; + paymentStatus: string; + totalPaidAmount: ShopifyMoneyV2; + totalOutstandingAmount: ShopifyMoneyV2; + paymentTerms: ShopifyPaymentTerms | null; +}; + +type ShopifyPaymentTerms = { + id: string; + overdue: boolean; + nextDueAt: string; + paymentSchedules: ShopifyPaymentScheduleConnection; +}; + +type ShopifyPaymentScheduleConnection = { + nodes: ShopifyPaymentSchedule[]; +}; + +type ShopifyPaymentSchedule = { + id: string; + dueAt: string; + completed: boolean; + amount: ShopifyMoneyV2; +}; + +export type ShopifyCustomer = { + id: string; + emailAddress: ShopifyCustomerEmailAddress; + firstName: string; + lastName: string; + phoneNumber: ShopifyCustomerPhoneNumber | null; + imageUrl: string; + displayName: string; +}; + +type ShopifyCustomerEmailAddress = { + emailAddress: string; + marketingState: string; +}; + +type ShopifyCustomerPhoneNumber = { + phoneNumber: string; + marketingState: string; +}; + export const PAGE_TYPES = [ 'image', 'icon_content_section', @@ -399,6 +678,29 @@ export type ShopifyProductsOperation = { }; }; +export type ShopifyCustomerOperation = { + data: { + customer: ShopifyCustomer; + }; +}; + +export type ShopifyCustomerOrdersOperation = { + data: { + customer: { + orders: Connection; + }; + }; +}; + +export type ShopifyCustomerOrderOperation = { + data: { + order: ShopifyOrder; + }; + variables: { + orderId: string; + }; +}; + export type CoreChargeOption = { label: string; value: string; diff --git a/lib/utils.ts b/lib/shopify/utils.ts similarity index 82% rename from lib/utils.ts rename to lib/shopify/utils.ts index de885097e..10d018ac8 100644 --- a/lib/utils.ts +++ b/lib/shopify/utils.ts @@ -1,7 +1,7 @@ import clsx, { ClassValue } from 'clsx'; import { ReadonlyURLSearchParams } from 'next/navigation'; import { twMerge } from 'tailwind-merge'; -import { Menu } from './shopify/types'; +import { Menu } from './types'; export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => { const paramsString = params.toString(); @@ -14,7 +14,14 @@ export const ensureStartsWith = (stringToCheck: string, startsWith: string) => stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`; export const validateEnvironmentVariables = () => { - const requiredEnvironmentVariables = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_STOREFRONT_ACCESS_TOKEN']; + const requiredEnvironmentVariables = [ + 'SHOPIFY_STORE_DOMAIN', + 'SHOPIFY_STOREFRONT_ACCESS_TOKEN', + 'SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID', + 'SHOPIFY_CUSTOMER_ACCOUNT_API_URL', + 'SHOPIFY_CUSTOMER_API_VERSION', + 'SHOPIFY_ORIGIN_URL' + ]; const missingEnvironmentVariables = [] as string[]; requiredEnvironmentVariables.forEach((envVar) => { @@ -71,3 +78,12 @@ export const findParentCollection = (menu: Menu[], collection: string): Menu | n } return parentCollection; }; + +export function parseJSON(json: any) { + if (String(json).includes('__proto__')) return JSON.parse(json, noproto); + return JSON.parse(json); +} + +function noproto(k: string, v: string) { + if (k !== '__proto__') return v; +} diff --git a/lib/styles.ts b/lib/styles.ts index e858daf8a..60c08db14 100644 --- a/lib/styles.ts +++ b/lib/styles.ts @@ -1,5 +1,16 @@ export const colors = { - primary: '#EF6C02', + primary: { + DEFAULT: '#EF6C02', + emphasis: '#C85900', + muted: '#E6CCB7' + }, + content: { + subtle: '#9ca3af', // gray-400 + DEFAULT: '#6b7280', // gray-500 + emphasis: '#374151', // gray-700 + strong: '#111827', // gray-900 + inverted: '#ffffff' // white + }, dark: '#091242', secondary: '#EF6C02', blue: { diff --git a/middleware.ts b/middleware.ts index 9d831e7f3..7352974b0 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,5 +1,5 @@ import type { NextRequest } from 'next/server'; -import { isLoggedIn, getOrigin, authorizeFn, logoutFn } from 'lib/shopify/customer'; +import { isLoggedIn, getOrigin, authorize, logout } from 'lib/shopify/auth'; // This function can be marked `async` if using `await` inside export async function middleware(request: NextRequest) { @@ -9,8 +9,8 @@ export async function middleware(request: NextRequest) { if (request.nextUrl.pathname.startsWith('/authorize')) { console.log('Running Initial Authorization Middleware'); const origin = getOrigin(request); - //console.log ("origin", origin) - return await authorizeFn(request, origin); + console.log('origin', origin); + return await authorize(request, origin); } /**** END OF Authorize Middleware to get access tokens @@ -22,7 +22,7 @@ export async function middleware(request: NextRequest) { if (request.nextUrl.pathname.startsWith('/logout')) { console.log('Running Logout middleware'); const origin = getOrigin(request); - return await logoutFn(request, origin); + return await logout(request, origin); } /**** END OF LOGOUT @@ -45,5 +45,5 @@ export async function middleware(request: NextRequest) { } export const config = { - matcher: ['/authorize', '/logout', '/account'] + matcher: ['/authorize', '/logout', '/account/:path*'] }; diff --git a/tailwind.config.js b/tailwind.config.js index ce8153b51..4d2f2fc73 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -11,6 +11,14 @@ module.exports = { fontFamily: { sans: ['var(--font-geist-sans)'] }, + fontSize: { + 'label-sm': ['0.75rem', { lineHeight: '1rem' }], + 'label-md': ['0.875rem', { lineHeight: '1.25rem' }], + 'label-lg': ['1rem', { lineHeight: '1.5rem' }], + 'heading-sm': ['1.125rem', { lineHeight: '1.75rem', fontWeight: '600' }], + 'heading-md': ['1.5rem', { lineHeight: '2rem', fontWeight: '600' }], + 'heading-lg': ['1.875rem', { lineHeight: '2.25rem', fontWeight: '600' }] + }, keyframes: { fadeIn: { from: { opacity: 0 }, @@ -37,7 +45,6 @@ module.exports = { hoverOnlyWhenSupported: true }, plugins: [ - require('@tailwindcss/container-queries'), require('@tailwindcss/typography'), plugin(({ matchUtilities, theme }) => { matchUtilities(