mirror of
https://github.com/vercel/commerce.git
synced 2025-05-08 10:47:51 +00:00
some more cleanup
This commit is contained in:
parent
a4d2102933
commit
fbf42b7551
@ -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`
|
||||
|
@ -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);
|
||||
}
|
@ -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'
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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 productsPromise = getProducts({}).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
|
||||
}))
|
||||
);
|
||||
const collections = await getCollections();
|
||||
|
||||
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
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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?¤cy=${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}?¤cy=${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 [];
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
37
lib/types.ts
37
lib/types.ts
@ -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,9 +65,8 @@ export type Product = {
|
||||
minVariantPrice: Money;
|
||||
};
|
||||
featuredImage: Image;
|
||||
seo: SEO;
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
updatedAt: string;
|
||||
variants: ProductVariant[];
|
||||
images: Image[];
|
||||
};
|
||||
@ -118,8 +88,3 @@ export type ProductVariant = {
|
||||
price: Money;
|
||||
images: Image[];
|
||||
};
|
||||
|
||||
export type SEO = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
13
lib/utils.ts
13
lib/utils.ts
@ -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.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -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',
|
||||
|
Loading…
x
Reference in New Issue
Block a user