some more cleanup

This commit is contained in:
Jieren Chen 2024-09-23 16:46:29 -04:00
parent a4d2102933
commit fbf42b7551
No known key found for this signature in database
GPG Key ID: 2FF322D21B5DB10B
15 changed files with 62 additions and 204 deletions

View File

@ -43,7 +43,7 @@ Integrations enable upgraded or additional functionality for Next.js Commerce
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary.
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store.
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Fourthwall store.
1. Install Vercel CLI: `npm i -g vercel`
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`

View File

@ -1,6 +0,0 @@
import { revalidate } from 'lib/shopify';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest): Promise<NextResponse> {
return revalidate(req);
}

View File

@ -6,7 +6,7 @@ import { getCart } from 'lib/fourthwall';
import { cookies } from 'next/headers';
export const metadata = {
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
description: 'High-performance ecommerce store built with Next.js, Vercel, and Fourthwall.',
openGraph: {
type: 'website'
}

View File

@ -1,16 +1,14 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { GridTileImage } from 'components/grid/tile';
import Footer from 'components/layout/footer';
import { Gallery } from 'components/product/gallery';
import { ProductProvider } from 'components/product/product-context';
import { ProductDescription } from 'components/product/product-description';
import { Wrapper } from 'components/wrapper';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getCart, getProduct, getProductRecommendations } from 'lib/fourthwall';
import { getCart, getProduct } from 'lib/fourthwall';
import { cookies } from 'next/headers';
import Link from 'next/link';
import { Suspense } from 'react';
export async function generateMetadata({
@ -26,8 +24,8 @@ export async function generateMetadata({
const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
return {
title: product.seo.title || product.title,
description: product.seo.description || product.description,
title: product.title,
description: product.description,
robots: {
index: indexable,
follow: indexable,
@ -110,48 +108,9 @@ export default async function ProductPage({ params, searchParams }: { params: {
</Suspense>
</div>
</div>
<RelatedProducts id={product.id} />
</div>
<Footer />
</ProductProvider>
</Wrapper>
);
}
async function RelatedProducts({ id }: { id: string }) {
const relatedProducts = await getProductRecommendations(id);
if (!relatedProducts.length) return null;
return (
<div className="py-8">
<h2 className="mb-4 text-2xl font-bold">Related Products</h2>
<ul className="flex w-full gap-4 overflow-x-auto pt-1">
{relatedProducts.map((product) => (
<li
key={product.handle}
className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
>
<Link
className="relative h-full w-full"
href={`/product/${product.handle}`}
prefetch={true}
>
<GridTileImage
alt={product.title}
label={{
title: product.title,
amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode
}}
src={product.featuredImage?.url}
fill
sizes="(min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, (min-width: 475px) 50vw, 100vw"
/>
</Link>
</li>
))}
</ul>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { getCollections, getPages, getProducts } from 'lib/shopify';
import { getCollectionProducts, getCollections } from 'lib/fourthwall';
import { validateEnvironmentVariables } from 'lib/utils';
import { MetadataRoute } from 'next';
@ -21,31 +21,21 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
lastModified: new Date().toISOString()
}));
const collectionsPromise = getCollections().then((collections) =>
collections.map((collection) => ({
url: `${baseUrl}${collection.path}`,
lastModified: collection.updatedAt
}))
);
const collections = await getCollections();
const productsPromise = getProducts({}).then((products) =>
const productsPromises = collections.map((collection) => {
return getCollectionProducts({ collection: collection.handle, currency: 'USD' }).then((products) =>
products.map((product) => ({
url: `${baseUrl}/product/${product.handle}`,
lastModified: product.updatedAt
}))
);
const pagesPromise = getPages().then((pages) =>
pages.map((page) => ({
url: `${baseUrl}/${page.handle}`,
lastModified: page.updatedAt
}))
);
});
let fetchedRoutes: Route[] = [];
try {
fetchedRoutes = (await Promise.all([collectionsPromise, productsPromise, pagesPromise])).flat();
fetchedRoutes = (await Promise.all([...productsPromises])).flat();
} catch (error) {
throw JSON.stringify(error, null, 2);
}

View File

@ -1,9 +1,6 @@
import Link from 'next/link';
import FooterMenu from 'components/layout/footer-menu';
import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/fourthwall';
import { Suspense } from 'react';
const { COMPANY_NAME, SITE_NAME } = process.env;
@ -11,7 +8,6 @@ export default async function Footer() {
const currentYear = new Date().getFullYear();
const copyrightDate = 2023 + (currentYear > 2023 ? `-${currentYear}` : '');
const skeleton = 'w-full h-6 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700';
const menu = await getMenu('next-js-frontend-footer-menu');
const copyrightName = COMPANY_NAME || SITE_NAME || '';
return (
@ -23,20 +19,6 @@ export default async function Footer() {
<span className="uppercase">{SITE_NAME}</span>
</Link>
</div>
<Suspense
fallback={
<div className="flex h-[188px] w-[200px] flex-col gap-2">
<div className={skeleton} />
<div className={skeleton} />
<div className={skeleton} />
<div className={skeleton} />
<div className={skeleton} />
<div className={skeleton} />
</div>
}
>
<FooterMenu menu={menu} />
</Suspense>
<div className="md:ml-auto">
<a
className="flex h-8 w-max flex-none items-center justify-center rounded-md border border-neutral-200 bg-white text-xs text-black dark:border-neutral-700 dark:bg-black dark:text-white"
@ -57,11 +39,11 @@ export default async function Footer() {
</p>
<hr className="mx-4 hidden h-4 w-[1px] border-l border-neutral-400 md:inline-block" />
<p>
<a href="https://github.com/vercel/commerce">View the source</a>
<a href="https://github.com/FourthwallHQ/vercel-commerce">View the source</a>
</p>
<p className="md:ml-auto">
<a href="https://vercel.com" className="text-black dark:text-white">
Created by Vercel
<a href="https://fourthwall.com" className="text-black dark:text-white">
Created by Fourthwall
</a>
</p>
</div>

View File

@ -16,7 +16,7 @@ export function WelcomeToast() {
},
description: (
<>
This is a high-performance, SSR storefront powered by Shopify, Next.js, and Vercel.{' '}
This is a high-performance, SSR storefront powered by Fourthwall, Next.js, and Vercel.{' '}
<a
href="https://vercel.com/templates/next.js/nextjs-commerce"
className="text-blue-600 hover:underline"

View File

@ -28,4 +28,4 @@ export const TAGS = {
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
export const DEFAULT_OPTION = 'Default Title';
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';

View File

@ -1,6 +1,6 @@
import { Cart, Menu, Product } from "lib/types";
import { Cart, Collection, Product } from "lib/types";
import { reshapeCart, reshapeProduct, reshapeProducts } from "./reshape";
import { FourthwallCart, FourthwallCheckout, FourthwallProduct } from "./types";
import { FourthwallCart, FourthwallCheckout, FourthwallCollection, FourthwallProduct } from "./types";
const API_URL = process.env.NEXT_PUBLIC_FW_API_URL;
const FW_PUBLIC_TOKEN = process.env.NEXT_PUBLIC_FW_PUBLIC_TOKEN;
@ -68,16 +68,26 @@ async function fourthwallPost<T>(url: string, data: any, options: RequestInit =
/**
* Collection operations
*/
export async function getCollections(): Promise<Collection[]> {
const res = await fourthwallGet<{ results: FourthwallCollection[] }>(`${API_URL}/api/public/v1.0/collections`, {
headers: {
'X-ShopId': process.env.FW_SHOPID || ''
}
});
return res.body.results.map((collection) => ({
handle: collection.slug,
title: collection.name,
description: collection.description,
}));
}
export async function getCollectionProducts({
collection,
currency,
reverse,
sortKey
}: {
collection: string;
currency: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await fourthwallGet<{results: FourthwallProduct[]}>(`${API_URL}/api/public/v1.0/collections/${collection}/products?&currency=${currency}`, {
headers: {
@ -98,17 +108,11 @@ export async function getCollectionProducts({
* Product operations
*/
export async function getProduct({ handle, currency } : { handle: string, currency: string }): Promise<Product | undefined> {
// TODO: replace with real URL
const res = await fourthwallGet<FourthwallProduct>(`${API_URL}/api/public/v1.0/products/${handle}?&currency=${currency}`);
return reshapeProduct(res.body);
}
export async function getProductRecommendations(productId: string): Promise<Product[]> {
// TODO: replace with real URL
return [];
}
/**
* Cart operations
*/
@ -125,7 +129,7 @@ export async function getCart(cartId: string | undefined, currency: string): Pro
}
export async function createCart(): Promise<Cart> {
const res = await fourthwallPost<FourthwallCart>(`https://api.staging.fourthwall.com/api/public/v1.0/carts`, {
const res = await fourthwallPost<FourthwallCart>(`${API_URL}/api/public/v1.0/carts`, {
items: []
});
@ -194,10 +198,3 @@ export async function createCheckout(
return res.body;
}
/**
* TODO: Stubbed out
*/
export async function getMenu(handle: string): Promise<Menu[]> {
return [];
}

View File

@ -53,6 +53,8 @@ export const reshapeProduct = (product: FourthwallProduct): Product | undefined
const sizes = new Set(variants.map((v) => v.attributes.size.name));
const colors = new Set(variants.map((v) => v.attributes.color.name));
const reshapedVariants = reshapeVariants(variants);
return {
...rest,
handle: product.slug,
@ -60,7 +62,7 @@ export const reshapeProduct = (product: FourthwallProduct): Product | undefined
descriptionHtml: product.description,
description: product.description,
images: reshapeImages(images, product.name),
variants: reshapeVariants(variants),
variants: reshapedVariants,
priceRange: {
minVariantPrice: {
amount: minPrice.toString(),
@ -81,12 +83,7 @@ export const reshapeProduct = (product: FourthwallProduct): Product | undefined
name: 'Size',
values: [...sizes]
}],
// TODO: stubbed out
availableForSale: true,
seo: {
title: product.name,
description: product.description,
},
availableForSale: reshapedVariants.some((v) => v.availableForSale),
tags: [],
updatedAt: new Date().toISOString(),
};
@ -106,7 +103,7 @@ const reshapeVariants = (variants: FourthwallProductVariant[]): ProductVariant[]
return variants.map((v) => ({
id: v.id,
title: v.name,
availableForSale: true,
availableForSale: v.stock.type === 'UNLIMITED' || (v.stock.inStock || 0) > 0,
images: reshapeImages(v.images, v.name),
selectedOptions: [{
name: 'Size',

View File

@ -3,6 +3,14 @@ export type FourthwallMoney = {
currency: string;
}
export type FourthwallCollection = {
id: string;
name: string;
slug: string;
description: string;
updatedAt: string;
};
export type FourthwallProduct = {
id: string;
name: string;
@ -11,6 +19,8 @@ export type FourthwallProduct = {
images: FourthwallProductImage[];
variants: FourthwallProductVariant[];
updatedAt: string;
};
export type FourthwallProductImage = {
@ -28,6 +38,11 @@ export type FourthwallProductVariant = {
images: FourthwallProductImage[];
stock: {
type: 'UNLIMITED' | 'LIMITED';
inStock?: number;
}
// other attr
attributes: {
description: string;

View File

@ -1,27 +0,0 @@
export interface ShopifyErrorLike {
status: number;
message: Error;
cause?: Error;
}
export const isObject = (object: unknown): object is Record<string, unknown> => {
return typeof object === 'object' && object !== null && !Array.isArray(object);
};
export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
if (!isObject(error)) return false;
if (error instanceof Error) return true;
return findError(error);
};
function findError<T extends object>(error: T): boolean {
if (Object.prototype.toString.call(error) === '[object Error]') {
return true;
}
const prototype = Object.getPrototypeOf(error) as T | null;
return prototype === null ? false : findError(prototype);
}

View File

@ -1,13 +1,3 @@
export type Maybe<T> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
node: T;
};
export type Cart = {
id: string | undefined;
checkoutUrl: string;
@ -48,9 +38,6 @@ export type Collection = {
handle: string;
title: string;
description: string;
seo: SEO;
updatedAt: string;
path: string;
};
export type Image = {
@ -60,27 +47,11 @@ export type Image = {
height: number;
};
export type Menu = {
title: string;
path: string;
};
export type Money = {
amount: string;
currencyCode: string;
};
export type Page = {
id: string;
title: string;
handle: string;
body: string;
bodySummary: string;
seo?: SEO;
createdAt: string;
updatedAt: string;
};
export type Product = {
id: string;
handle: string;
@ -94,7 +65,6 @@ export type Product = {
minVariantPrice: Money;
};
featuredImage: Image;
seo: SEO;
tags: string[];
updatedAt: string;
variants: ProductVariant[];
@ -118,8 +88,3 @@ export type ProductVariant = {
price: Money;
images: Image[];
};
export type SEO = {
title: string;
description: string;
};

View File

@ -11,7 +11,7 @@ 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 = ['NEXT_PUBLIC_FW_API_URL', 'NEXT_PUBLIC_FW_PUBLIC_TOKEN', 'NEXT_PUBLIC_FW_COLLECTION', 'NEXT_PUBLIC_FW_CHECKOUT'];
const missingEnvironmentVariables = [] as string[];
requiredEnvironmentVariables.forEach((envVar) => {
@ -22,18 +22,9 @@ export const validateEnvironmentVariables = () => {
if (missingEnvironmentVariables.length) {
throw new Error(
`The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/shopify#configure-environment-variables\n\n${missingEnvironmentVariables.join(
`The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/fourthwall#configure-environment-variables\n\n${missingEnvironmentVariables.join(
'\n'
)}\n`
);
}
if (
process.env.SHOPIFY_STORE_DOMAIN?.includes('[') ||
process.env.SHOPIFY_STORE_DOMAIN?.includes(']')
) {
throw new Error(
'Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.'
);
}
};

View File

@ -3,11 +3,6 @@ module.exports = {
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.shopify.com',
pathname: '/s/files/**'
},
{
protocol: 'https',
hostname: 'storage.googleapis.com',