feat/add-geins-as-provider (#1)

* feat: add geins as provider
This commit is contained in:
Kristian Arvidsson 2024-11-28 11:24:28 +01:00 committed by GitHub
parent 3a26bae429
commit db63db1331
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 5702 additions and 1465 deletions

View File

@ -1,7 +1,15 @@
COMPANY_NAME="Vercel Inc."
TWITTER_CREATOR="@vercel"
TWITTER_SITE="https://nextjs.org/commerce"
SITE_NAME="Next.js Commerce"
SHOPIFY_REVALIDATION_SECRET=""
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
COMPANY_NAME= # Company name for eg. Geins
SITE_NAME= # Site name for eg. Geins Store
GEINS_API_KEY= # API key from Geins for eg. XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXX
GEINS_ACCOUNT_NAME= # Account name from geins for eg. acme
GEINS_CHANNEL= # Channel Id from Geins for eg. 1
GEINS_TLD= # Top-level domain for eg. com
GEINS_LOCALE= # Locale for eg. en-US
GEINS_MARKET= # Market for eg. us
GEINS_ENVIRONMENT= # Environment for eg. qa, prod
GEINS_PRODUCT_DESCRIPTION_SHORT_TEXT= # Property for Product description from Geins for eg. text1, text2, text3 default is text2
GEINS_CURRENCY_CODE= # Currency code for eg. USD
GEINS_PAYMENT_ID= # Payment ID from Geins for desired checkout for eg. 23
GEINS_USE_CATEGORY_FOR_RECOMMENDATIONS_BACKUP= # Use category for recommendations backup on PDP true/false defalut is true
GEINS_SKU_DEFAULT_VARIATION= # Default variation for SKU for eg. Size
GEINS_REVALIDATION_SECRET= # Revalidation secret from your Geins webhook for eg. XYZ123-123-123

View File

@ -1,73 +1,72 @@
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=COMPANY_NAME,SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STORE_DOMAIN,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SITE_NAME,TWITTER_CREATOR,TWITTER_SITE)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fgeins-io%2Fvercel-nextjs-commerce&env=GEINS_API_KEY,GEINS_ACCOUNT_NAME,GEINS_CHANNEL,GEINS_TLD,GEINS_LOCALE,GEINS_MARKET,GEINS_IMAGE_URL,GEINS_CURRENCY_CODE,GEINS_CHECKOUT_ID&envDescription=Read%20more%20about%20environment%20varibles%20in%20the%20example%20file&envLink=https%3A%2F%2Fgithub.com%2Fgeins-io%2Fvercel-nextjs-commerce%2Fblob%2Fmain%2F.env.example&project-name=geins-nextjs-commerce-starter&repository-name=geins-nextjs-commerce-starter&demo-title=Geins%20Next.js%20Commerce%20Starter&demo-description=Commerce%20website%20created%20with%20Next.js&demo-url=http%3A%2F%2Fgeins.io&demo-image=avatars.githubusercontent.com%2Fu%2F123540473)
# Next.js Commerce
# Next.js Commerce x Geins
A high-performance, server-rendered Next.js App Router ecommerce application.
A high-performance, server-rendered Next.js (15 RC) App Router ecommerce application.
This template uses React Server Components, Server Actions, `Suspense`, `useOptimistic`, and more.
This template showcases the integration of [Geins Commerce API](https://docs.geins.io) with [Next.js Commerce](https://github.com/vercel/commerce), leveraging the open-source [Geins SDK](https://github.com/geins-io/geins).
<h3 id="v1-note"></h3>
## Features
> Note: Looking for Next.js Commerce v1? View the [code](https://github.com/vercel/commerce/tree/v1), [demo](https://commerce-v1.vercel.store), and [release notes](https://github.com/vercel/commerce/releases/tag/v1).
- **React Server Components**: Build fast and scalable UIs with Next.js's server-first approach.
- **Server Actions**: Simplify backend logic and data fetching.
- **Modern React APIs**: Including `Suspense` and `useOptimistic`.
- **Integration with Geins**: Harness the power of Geins for exceptional ecommerce capabilities.
## Providers
## What is Geins?
Vercel will only be actively maintaining a Shopify version [as outlined in our vision and strategy for Next.js Commerce](https://github.com/vercel/commerce/pull/966).
[Geins](https://geins.io/) is the ultimate toolkit for modern commerce. With Geins, developers and agencies can craft unique, tailored shopping experiences using:
Vercel is happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
- A hybrid model combining the reliability of a managed platform with open-source flexibility.
- A robust API-first approach enabling precise customization.
- Features for managing channels, content, CRM, events, and more.
- Shopify (this repository)
- [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
- [Ecwid by Lightspeed](https://github.com/Ecwid/ecwid-nextjs-commerce/) ([Demo](https://ecwid-nextjs-commerce.vercel.app/))
- [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
- [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
- [Shopware](https://github.com/shopwareLabs/vercel-commerce) ([Demo](https://shopware-vercel-commerce-react.vercel.app/))
- [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
- [Umbraco](https://github.com/umbraco/Umbraco.VercelCommerce.Demo) ([Demo](https://vercel-commerce-demo.umbraco.com/))
- [Wix](https://github.com/wix/nextjs-commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/))
- [Fourthwall](https://github.com/FourthwallHQ/vercel-commerce) ([Demo](https://vercel-storefront.fourthwall.app/))
Explore the [Geins Commerce API documentation](https://docs.geins.io) for detailed usage instructions and capabilities.
> Note: Providers, if you are looking to use similar products for your demo, you can [download these assets](https://drive.google.com/file/d/1q_bKerjrwZgHwCw0ovfUMW6He9VtepO_/view?usp=sharing).
## Getting Started
## Integrations
To run this application locally, follow these steps:
Integrations enable upgraded or additional functionality for Next.js Commerce
### Prerequisites
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
Ensure you have the following installed:
- Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
- [Node.js](https://nodejs.org/) (v20 or later)
- [Geins API-Key](https://geins.io/)
- [React Bricks](https://github.com/ReactBricks/nextjs-commerce-rb) ([Demo](https://nextjs-commerce.reactbricks.com/))
- Edit pages, product details, and footer content visually using [React Bricks](https://www.reactbricks.com) visual headless CMS.
### Environment Variables
## Running locally
Set up your environment variables as defined in `.env.example`. It's recommended to use [Vercel's Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for secure storage.
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.
> ⚠️ **Important**: Never commit your `.env` file to version control.
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store.
### Steps to Run Locally
1. Install Vercel CLI: `npm i -g vercel`
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
3. Download your environment variables: `vercel env pull`
1. Clone this repository:
```bash
pnpm install
pnpm dev
```
```bash
git clone https://github.com/geins-io/vercel-nextjs-commerce.git
cd <your-repo>
```
Your app should now be running on [localhost:3000](http://localhost:3000/).
2. Install dependencies:
<details>
<summary>Expand if you work at Vercel and want to run locally and / or contribute</summary>
```bash
pnpm install
```
1. Run `vc link`.
1. Select the `Vercel Solutions` scope.
1. Connect to the existing `commerce-shopify` project.
1. Run `vc env pull` to get environment variables.
1. Run `pnpm dev` to ensure everything is working correctly.
</details>
3. Link your local instance with Vercel and pull environment variables:
## Vercel, Next.js Commerce, and Shopify Integration Guide
```bash
npm i -g vercel
vercel link
vercel env pull
```
You can use this comprehensive [integration guide](https://vercel.com/docs/integrations/ecommerce/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel.
4. Start the development server:
```bash
pnpm dev
```
5. Access the app on [http://localhost:3000](http://localhost:3000).

View File

@ -1,5 +1,5 @@
import OpengraphImage from 'components/opengraph-image';
import { getPage } from 'lib/shopify';
import { getPage } from 'lib/geins';
export const runtime = 'edge';

View File

@ -1,7 +1,6 @@
import type { Metadata } from 'next';
import Prose from 'components/prose';
import { getPage } from 'lib/shopify';
import { getPage } from 'lib/geins';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
export async function generateMetadata(props: {
@ -33,6 +32,7 @@ export default async function Page(props: { params: Promise<{ page: string }> })
<>
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
<Prose className="mb-8" html={page.body} />
<p className="text-sm italic">
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
year: 'numeric',

View File

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

View File

@ -2,7 +2,7 @@ import { CartProvider } from 'components/cart/cart-context';
import { Navbar } from 'components/layout/navbar';
import { WelcomeToast } from 'components/welcome-toast';
import { GeistSans } from 'geist/font/sans';
import { getCart } from 'lib/shopify';
import { getCart } from 'lib/geins';
import { ensureStartsWith } from 'lib/utils';
import { cookies } from 'next/headers';
import { ReactNode } from 'react';

View File

@ -7,8 +7,8 @@ import { Gallery } from 'components/product/gallery';
import { ProductProvider } from 'components/product/product-context';
import { ProductDescription } from 'components/product/product-description';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct, getProductRecommendations } from 'lib/shopify';
import { Image } from 'lib/shopify/types';
import { getProduct, getProductRecommendations } from 'lib/geins';
import { ProductImageType, ProductType } from 'lib/geins/types';
import Link from 'next/link';
import { Suspense } from 'react';
@ -89,7 +89,7 @@ export default async function ProductPage(props: { params: Promise<{ handle: str
}
>
<Gallery
images={product.images.slice(0, 5).map((image: Image) => ({
images={product.images.slice(0, 5).map((image: ProductImageType) => ({
src: image.url,
altText: image.altText
}))}
@ -103,15 +103,15 @@ export default async function ProductPage(props: { params: Promise<{ handle: str
</Suspense>
</div>
</div>
<RelatedProducts id={product.id} />
<RelatedProducts product={product} />
</div>
<Footer />
</ProductProvider>
);
}
async function RelatedProducts({ id }: { id: string }) {
const relatedProducts = await getProductRecommendations(id);
async function RelatedProducts({ product }: { product: ProductType }) {
const relatedProducts = await getProductRecommendations(product);
if (!relatedProducts.length) return null;

View File

@ -1,5 +1,5 @@
import OpengraphImage from 'components/opengraph-image';
import { getCollection } from 'lib/shopify';
import { getCollection } from 'lib/geins';
export const runtime = 'edge';

View File

@ -1,4 +1,4 @@
import { getCollection, getCollectionProducts } from 'lib/shopify';
import { getCollection, getCollectionProducts } from 'lib/geins';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
@ -11,7 +11,6 @@ export async function generateMetadata(props: {
}): Promise<Metadata> {
const params = await props.params;
const collection = await getCollection(params.collection);
if (!collection) return notFound();
return {
@ -29,6 +28,7 @@ export default async function CategoryPage(props: {
const params = await props.params;
const { sort } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });
return (

View File

@ -1,7 +1,7 @@
import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items';
import { defaultSort, sorting } from 'lib/constants';
import { getProducts } from 'lib/shopify';
import { getProducts } from 'lib/geins';
export const metadata = {
title: 'Search',

View File

@ -1,5 +1,4 @@
import { getCollections, getPages, getProducts } from 'lib/shopify';
import { validateEnvironmentVariables } from 'lib/utils';
import { getCollections, getPages, getProducts } from 'lib/geins';
import { MetadataRoute } from 'next';
type Route = {
@ -14,7 +13,7 @@ const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
export const dynamic = 'force-dynamic';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
validateEnvironmentVariables();
// validateEnvironmentVariables();
const routesMap = [''].map((route) => ({
url: `${baseUrl}${route}`,
@ -24,21 +23,21 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const collectionsPromise = getCollections().then((collections) =>
collections.map((collection) => ({
url: `${baseUrl}${collection.path}`,
lastModified: collection.updatedAt
lastModified: collection.updatedAt ?? new Date().toISOString()
}))
);
const productsPromise = getProducts({}).then((products) =>
products.map((product) => ({
url: `${baseUrl}/product/${product.handle}`,
lastModified: product.updatedAt
lastModified: product.updatedAt ?? new Date().toISOString()
}))
);
const pagesPromise = getPages().then((pages) =>
pages.map((page) => ({
url: `${baseUrl}/${page.handle}`,
lastModified: page.updatedAt
lastModified: page.updatedAt ?? new Date().toISOString()
}))
);

View File

@ -1,4 +1,4 @@
import { getCollectionProducts } from 'lib/shopify';
import { getCollectionProducts } from 'lib/geins';
import Link from 'next/link';
import { GridTileImage } from './grid/tile';
@ -27,7 +27,7 @@ export async function Carousel() {
amount: product.priceRange.maxVariantPrice.amount,
currencyCode: product.priceRange.maxVariantPrice.currencyCode
}}
src={product.featuredImage?.url}
src={product.featuredImage?.url || ''}
fill
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
/>

View File

@ -1,14 +1,14 @@
'use server';
import { TAGS } from 'lib/constants';
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/geins';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export async function addItem(prevState: any, selectedVariantId: string | undefined) {
let cartId = (await cookies()).get('cartId')?.value;
if (!cartId || !selectedVariantId) {
return 'Error adding item to cart';
}

View File

@ -4,7 +4,7 @@ import { PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { addItem } from 'components/cart/actions';
import { useProduct } from 'components/product/product-context';
import { Product, ProductVariant } from 'lib/shopify/types';
import { ProductType, ProductVariantType } from 'lib/geins/types';
import { useActionState } from 'react';
import { useCart } from './cart-context';
@ -27,7 +27,6 @@ function SubmitButton({
);
}
console.log(selectedVariantId);
if (!selectedVariantId) {
return (
<button
@ -58,20 +57,19 @@ function SubmitButton({
);
}
export function AddToCart({ product }: { product: Product }) {
export function AddToCart({ product }: { product: ProductType }) {
const { variants, availableForSale } = product;
const { addCartItem } = useCart();
const { state } = useProduct();
const [message, formAction] = useActionState(addItem, null);
const variant = variants.find((variant: ProductVariant) =>
const variant = variants.find((variant: ProductVariantType) =>
variant.selectedOptions.every((option) => option.value === state[option.name.toLowerCase()])
);
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
const selectedVariantId = variant?.id || defaultVariantId;
const actionWithVariant = formAction.bind(null, selectedVariantId);
const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!;
return (
<form
action={async () => {

View File

@ -1,18 +1,18 @@
'use client';
import type { Cart, CartItem, Product, ProductVariant } from 'lib/shopify/types';
import type { CartItemType, CartType, ProductType, ProductVariantType } from 'lib/geins/types';
import React, { createContext, use, useContext, useMemo, useOptimistic } from 'react';
type UpdateType = 'plus' | 'minus' | 'delete';
type CartAction =
| { type: 'UPDATE_ITEM'; payload: { merchandiseId: string; updateType: UpdateType } }
| { type: 'ADD_ITEM'; payload: { variant: ProductVariant; product: Product } };
| { type: 'ADD_ITEM'; payload: { variant: ProductVariantType; product: ProductType } };
type CartContextType = {
cart: Cart | undefined;
cart: CartType | undefined;
updateCartItem: (merchandiseId: string, updateType: UpdateType) => void;
addCartItem: (variant: ProductVariant, product: Product) => void;
addCartItem: (variant: ProductVariantType, product: ProductType) => void;
};
const CartContext = createContext<CartContextType | undefined>(undefined);
@ -21,7 +21,7 @@ function calculateItemCost(quantity: number, price: string): string {
return (Number(price) * quantity).toString();
}
function updateCartItem(item: CartItem, updateType: UpdateType): CartItem | null {
function updateCartItem(item: CartItemType, updateType: UpdateType): CartItemType | null {
if (updateType === 'delete') return null;
const newQuantity = updateType === 'plus' ? item.quantity + 1 : item.quantity - 1;
@ -44,10 +44,10 @@ function updateCartItem(item: CartItem, updateType: UpdateType): CartItem | null
}
function createOrUpdateCartItem(
existingItem: CartItem | undefined,
variant: ProductVariant,
product: Product
): CartItem {
existingItem: CartItemType | undefined,
variant: ProductVariantType,
product: ProductType
): CartItemType {
const quantity = existingItem ? existingItem.quantity + 1 : 1;
const totalAmount = calculateItemCost(quantity, variant.price.amount);
@ -74,7 +74,7 @@ function createOrUpdateCartItem(
};
}
function updateCartTotals(lines: CartItem[]): Pick<Cart, 'totalQuantity' | 'cost'> {
function updateCartTotals(lines: CartItemType[]): Pick<CartType, 'totalQuantity' | 'cost'> {
const totalQuantity = lines.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = lines.reduce((sum, item) => sum + Number(item.cost.totalAmount.amount), 0);
const currencyCode = lines[0]?.cost.totalAmount.currencyCode ?? 'USD';
@ -89,7 +89,7 @@ function updateCartTotals(lines: CartItem[]): Pick<Cart, 'totalQuantity' | 'cost
};
}
function createEmptyCart(): Cart {
function createEmptyCart(): CartType {
return {
id: undefined,
checkoutUrl: '',
@ -103,7 +103,7 @@ function createEmptyCart(): Cart {
};
}
function cartReducer(state: Cart | undefined, action: CartAction): Cart {
function cartReducer(state: CartType | undefined, action: CartAction): CartType {
const currentCart = state || createEmptyCart();
switch (action.type) {
@ -113,7 +113,7 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
.map((item) =>
item.merchandise.id === merchandiseId ? updateCartItem(item, updateType) : item
)
.filter(Boolean) as CartItem[];
.filter(Boolean) as CartItemType[];
if (updatedLines.length === 0) {
return {
@ -150,7 +150,7 @@ export function CartProvider({
cartPromise
}: {
children: React.ReactNode;
cartPromise: Promise<Cart | undefined>;
cartPromise: Promise<CartType | undefined>;
}) {
const initialCart = use(cartPromise);
const [optimisticCart, updateOptimisticCart] = useOptimistic(initialCart, cartReducer);
@ -159,7 +159,7 @@ export function CartProvider({
updateOptimisticCart({ type: 'UPDATE_ITEM', payload: { merchandiseId, updateType } });
};
const addCartItem = (variant: ProductVariant, product: Product) => {
const addCartItem = (variant: ProductVariantType, product: ProductType) => {
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } });
};
@ -180,5 +180,6 @@ export function useCart() {
if (context === undefined) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}

View File

@ -2,14 +2,14 @@
import { XMarkIcon } from '@heroicons/react/24/outline';
import { removeItem } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types';
import type { CartItemType } from 'lib/geins/types';
import { useActionState } from 'react';
export function DeleteItemButton({
item,
optimisticUpdate
}: {
item: CartItem;
item: CartItemType;
optimisticUpdate: any;
}) {
const [message, formAction] = useActionState(removeItem, null);

View File

@ -3,7 +3,7 @@
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { updateItemQuantity } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types';
import type { CartItemType } from 'lib/geins/types';
import { useActionState } from 'react';
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
@ -32,7 +32,7 @@ export function EditItemQuantityButton({
type,
optimisticUpdate
}: {
item: CartItem;
item: CartItemType;
type: 'plus' | 'minus';
optimisticUpdate: any;
}) {

View File

@ -1,6 +1,6 @@
import { GridTileImage } from 'components/grid/tile';
import { getCollectionProducts } from 'lib/shopify';
import type { Product } from 'lib/shopify/types';
import { getCollectionProducts } from 'lib/geins';
import type { ProductType } from 'lib/geins/types';
import Link from 'next/link';
function ThreeItemGridItem({
@ -8,7 +8,7 @@ function ThreeItemGridItem({
size,
priority
}: {
item: Product;
item: ProductType;
size: 'full' | 'half';
priority?: boolean;
}) {

View File

@ -0,0 +1,81 @@
export default function GeinsIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 726.5 271.2"
xmlSpace="preserve"
className={className}
style={{ color: 'black' }}
>
<path
d="M292.6,133.4c-10,0-18.1,8.1-18.1,18.1v0.2c0.3,4.1-0.7,7.4-2.9,9.7c-3.4,3.6-8.9,4.4-10.9,4.5c-0.3,0-33.3,0-33.3,0
c-0.1,0-7.5-0.1-11.7-4.5c-2.2-2.3-3.2-5.6-2.9-9.7V87v-0.2c-0.3-4.1,0.7-7.4,2.9-9.7c4.2-4.5,11.6-4.5,11.7-4.5h30.1l0,0
c1.5,0,3.1,0,4.2,0c5.9,0.9,11.9,4.6,12.3,13.3V86c0.9,12.6-6.4,15-12,15.3c-0.9,0-17.4-0.1-17.4-0.1c-10,0-18.1,8.1-18.1,18.1
s8.1,18.2,18.1,18.2h15.3c10,0,18.1-8.1,18.1-18.2c0-0.4,0.1-1.5,0.1-1.8c1.2-12.4,10.9-11.8,14.1-12.3h0.2c10,0,18.1-8.1,18.1-18.1
s-8.1-18.1-18.1-18.1h-0.2c-2.7,0-13.2-0.5-14.2-12.6c0-0.7,0-1.8,0-2.2c0-10-8.1-17.8-18.1-17.8h-32.5c-10,0-18.1,8.1-18.1,18
C207.7,69.1,196,69,194.7,68.9h-0.2c-10,0-18.1,8.1-18.1,18.1v64.5c0,10,8.1,18.1,18.1,18.1h0.2c1.3-0.1,13-0.2,14.4,14.6
c0.1,8.4,5.8,15.4,13.6,17.4c0,0,0,0,0,0c0.2,0,0.4,0.1,0.5,0.1c0.3,0.1,0.6,0.1,0.9,0.2c1.1,0.2,2.1,0.4,3.3,0.4h32.5
c10,0,18.1-8.1,18.1-18c1.4-14.8,13.1-14.7,14.4-14.6h0.2c10,0,18.1-8.1,18.1-18.1S302.6,133.4,292.6,133.4L292.6,133.4z"
fill="currentColor"
/>
<path
d="M536.9,68.5h-0.2c-1.3,0.1-13,0.2-14.4-14.6c-0.1-7.3-4.5-13.6-10.7-16.4c0,0-2.7-1.6-7.2-1.6H472c-10,0-18.1,8.1-18.1,18
c-1.4,14.8-13.1,14.7-14.4,14.6h-0.2c-10,0-18.1,8.1-18.1,18.1v97.2c0,10,8.1,18.1,18.1,18.1s18.1-8.1,18.1-18.1V86.5
c-0.3-4.1,0.7-7.4,2.9-9.7c4.2-4.5,11.6-4.5,11.7-4.5h32.2c0.1,0,7.5,0.1,11.7,4.5c2.2,2.3,3.2,5.6,2.9,9.7v12.1l0,0v52.9
c0,0.1-0.1,7.5-4.5,11.7c-2.3,2.2-5.6,3.2-9.7,2.9h-0.2c-10,0-18.1,8.1-18.1,18.1s8.1,18.1,18.1,18.1s18.1-8.1,18.1-18.1v-0.2
c-0.1-1.3-0.2-13,14.6-14.4c9.9-0.1,18-8.2,18-18.1v-20.4l0,0V86.7C555.1,76.7,546.9,68.5,536.9,68.5L536.9,68.5z"
fill="currentColor"
/>
<path
d="M365.7,82.1c-10,0-18.1,8.1-18.1,18.1v84c0,10,8.1,18.1,18.1,18.1s18.1-8.1,18.1-18.1v-84C383.9,90.2,375.7,82.1,365.7,82.1
z"
fill="currentColor"
/>
<path
d="M708.4,133.8h-0.2c-0.5,0-13,0.6-14.4-14.7c0-0.1,0-0.2,0-0.2c-0.3-9.7-8.4-17.6-18.1-17.6h-32.6c-0.8,0-7.6-0.3-11.6-4.5
c-2.2-2.3-2.9-5.6-2.9-9.7v-0.4c0-4.1,0.7-7.4,2.9-9.7c4.2-4.5,11.6-4.5,11.7-4.5l32.3,0c10,0,18.2-8.1,18.2-18.2c0-0.1,0-0.2,0-0.3
c0,0,0.1,0,0.1,0c-0.2-9.8-8.2-17.8-18.1-17.8h-32.5c-10,0-18.1,8.1-18.1,18c-1.4,14.8-13.1,14.7-14.4,14.6h-0.2
c-10,0-18.1,8.1-18.1,18.1s8.1,18.1,18.1,18.1h0.2c0.5,0,12.9-0.6,14.4,14.6c0.1,9.9,8.2,18,18.1,18h32.5c0.5,0,7.5,0.1,11.7,4.5
c2.2,2.3,2.9,5,2.9,9.3v0.6c0,4.1-0.7,7.5-2.9,9.9c-4.1,4.4-11.4,4.6-11.7,4.6h-32.3c-0.1,0-7.5-0.1-11.7-4.5
c-2.2-2.3-3.2-5.5-2.9-9.6l0-0.4c0-10-8.1-18.1-18.1-18.1s-18.1,8.1-18.1,18.1s8.1,18.1,18.1,18.1h0.3c1.6,0,12.9-0.2,14.3,14.6
c0.1,9.9,8.2,18,18.1,18h32.5c10,0,18.1-8.1,18.1-18.1c0-0.5,0-0.9,0-1.3c2-13.3,13-13.2,14.2-13.1h0.2c10,0,18.1-8.1,18.1-18.1
S718.4,133.7,708.4,133.8L708.4,133.8z"
fill="currentColor"
/>
<path
d="M365.7,36c-10.1,0-18.2,8.2-18.2,18.2s8.2,18.2,18.2,18.2S384,64.3,384,54.2S375.8,36,365.7,36z"
fill="currentColor"
/>
<g>
<path
d="M120.8,68.5h-0.2c-1.1,0.1-8.9,0.1-12.6-7.7c0,0,0-0.1-0.1-0.1c-0.1-0.2-0.2-0.5-0.3-0.7c0-0.1-0.1-0.2-0.1-0.3
c-0.1-0.2-0.1-0.4-0.2-0.6c-0.1-0.1-0.1-0.3-0.2-0.5s-0.1-0.3-0.1-0.5c-0.1-0.2-0.1-0.4-0.2-0.7c0-0.1-0.1-0.2-0.1-0.4
c-0.1-0.3-0.1-0.6-0.2-0.9c0-0.1,0-0.1,0-0.2c-0.1-0.4-0.1-0.7-0.2-1.1c0-0.3-0.1-0.7-0.1-1c-0.1-7.3-4.4-13.6-10.7-16.4
c0,0-2.9-1.4-5.6-1.5c-0.4,0-1.4-0.1-1.6-0.1H55.8c-10,0-18.1,8.1-18.1,18c-1.4,14.8-13.1,14.7-14.4,14.6h-0.2
c-10,0-18.1,8.1-18.1,18.1v65.2c0,10,8.1,18.1,18.1,18.1h0.2c1.7,0,12-0.2,14.2,12.8l0,1.8C36,199.5,25.2,198.7,22.9,199h-0.2
c-10.7,0.3-18.1,8.1-18.1,18.1s8.1,18.1,18.1,18.1c0.4,0,0.6,0,1.1,0c3,0.1,12.3,1.6,13.5,14.6c0.1,8.4,5.8,15.4,13.6,17.4
c0,0,0,0,0,0c0.2,0,0.4,0.1,0.5,0.1c0.3,0.1,0.6,0.1,0.9,0.2c1.1,0.2,2.2,0.4,3.3,0.4H88c9.9,0,18.1-8.1,18.1-18
c1.4-14.8,13.1-14.7,14.4-14.6h0.2c10,0,18.1-8.1,18.1-18.1s-8.1-18.1-18.1-18.1s-18.1,8.1-18.1,18.1v0.2c0.3,4.1-0.7,7.4-2.9,9.7
c-3.4,3.6-8.9,4.4-10.9,4.5c-0.3,0-33.3,0-33.3,0c-0.1,0-7.5-0.1-11.7-4.5c-2.2-2.3-2.9-5.6-2.9-9.7v-2.7c0.2-3,1.2-5.5,2.9-7.3
c3.4-3.6,8.9-4.8,10.9-5c0.2,0,33.3,0,33.3,0c10,0,18.1-7.7,18.1-17.6c1.4-14.8,13.1-14.7,14.4-14.6h0.2c10,0,18.1-8.1,18.1-18.1
V86.7C138.9,76.7,130.8,68.5,120.8,68.5L120.8,68.5z M99.7,161.8c-4.2,4.5-11.6,4.5-11.7,4.5H55.8c-0.1,0-7.5-0.1-11.7-4.5
c-2.2-2.3-3.2-5.6-2.9-9.7v-0.2V86.7v-0.2v0c0-0.5,0-1,0-1.5c0-3.4,1-6.2,2.9-8.2c3.5-3.7,9.2-4.4,11.1-4.5c0.1,0,0.2,0,0.4,0h32.5
c0.2,0,0.3,0,0.5,0c1.7,0.1,7.6,0.7,11.2,4.5c1.9,2,2.9,4.8,2.9,8.2c0,0.5,0,1,0,1.5v0.2v65.2v0.2
C102.9,156.2,101.9,159.5,99.7,161.8L99.7,161.8z"
fill="currentColor"
/>
<path
d="M120.9,39.9c10.1,0,18.2-8.2,18.2-18.2S131,3.4,120.9,3.4s-18.2,8.2-18.2,18.2S110.8,39.9,120.9,39.9z"
fill="currentColor"
/>
</g>
<path
d="M708.3,104.8c10.1,0,18.2-8.2,18.2-18.2s-8.2-18.2-18.2-18.2s-18.2,8.2-18.2,18.2S698.3,104.8,708.3,104.8z"
fill="currentColor"
/>
</svg>
);
}

View File

@ -1,12 +1,12 @@
'use client';
import clsx from 'clsx';
import { Menu } from 'lib/shopify/types';
import { MenuItemType } from 'lib/geins/types';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
export function FooterMenuItem({ item }: { item: Menu }) {
export function FooterMenuItem({ item }: { item: MenuItemType }) {
const pathname = usePathname();
const [active, setActive] = useState(pathname === item.path);
@ -31,13 +31,13 @@ export function FooterMenuItem({ item }: { item: Menu }) {
);
}
export default function FooterMenu({ menu }: { menu: Menu[] }) {
export default function FooterMenu({ menu }: { menu: MenuItemType[] }) {
if (!menu.length) return null;
return (
<nav>
<ul>
{menu.map((item: Menu) => {
{menu.map((item: MenuItemType) => {
return <FooterMenuItem key={item.title} item={item} />;
})}
</ul>

View File

@ -1,8 +1,9 @@
import Link from 'next/link';
import GeinsIcon from 'components/icons/geins';
import FooterMenu from 'components/layout/footer-menu';
import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/shopify';
import { getMenu } from 'lib/geins';
import { Suspense } from 'react';
const { COMPANY_NAME, SITE_NAME } = process.env;
@ -41,7 +42,7 @@ export default async function Footer() {
<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"
aria-label="Deploy on Vercel"
href="https://vercel.com/templates/next.js/nextjs-commerce"
href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fgeins-io%2Fvercel-nextjs-commerce&env=GEINS_API_KEY,GEINS_ACCOUNT_NAME,GEINS_CHANNEL,GEINS_TLD,GEINS_LOCALE,GEINS_MARKET,GEINS_IMAGE_URL,GEINS_CURRENCY_CODE,GEINS_CHECKOUT_ID&envDescription=Read%20more%20about%20environment%20varibles%20in%20the%20example%20file&envLink=https%3A%2F%2Fgithub.com%2Fgeins-io%2Fvercel-nextjs-commerce%2Fblob%2Fmain%2F.env.example&project-name=geins-nextjs-commerce-starter&repository-name=geins-nextjs-commerce-starter&demo-title=Geins%20Next.js%20Commerce%20Starter&demo-description=Commerce%20website%20created%20with%20Next.js&demo-url=http%3A%2F%2Fgeins.io&demo-image=avatars.githubusercontent.com%2Fu%2F123540473"
>
<span className="px-3"></span>
<hr className="h-full border-r border-neutral-200 dark:border-neutral-700" />
@ -57,11 +58,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/geins-io/vercel-nextjs-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://geins.io" className="flex items-center text-black dark:text-white">
Powered by <GeinsIcon className="ml-1 h-5 fill-current align-middle" />
</a>
</p>
</div>

View File

@ -1,7 +1,7 @@
import CartModal from 'components/cart/modal';
import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/shopify';
import { Menu } from 'lib/shopify/types';
import { getMenu } from 'lib/geins';
import { MenuItemType } from 'lib/geins/types';
import Link from 'next/link';
import { Suspense } from 'react';
import MobileMenu from './mobile-menu';
@ -33,7 +33,7 @@ export async function Navbar() {
</Link>
{menu.length ? (
<ul className="hidden gap-6 text-sm md:flex md:items-center">
{menu.map((item: Menu) => (
{menu.map((item: MenuItemType) => (
<li key={item.title}>
<Link
href={item.path}

View File

@ -6,10 +6,10 @@ import { usePathname, useSearchParams } from 'next/navigation';
import { Fragment, Suspense, useEffect, useState } from 'react';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import { Menu } from 'lib/shopify/types';
import { MenuItemType } from 'lib/geins/types';
import Search, { SearchSkeleton } from './search';
export default function MobileMenu({ menu }: { menu: Menu[] }) {
export default function MobileMenu({ menu }: { menu: MenuItemType[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
@ -78,7 +78,7 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
</div>
{menu.length ? (
<ul className="flex w-full flex-col">
{menu.map((item: Menu) => (
{menu.map((item: MenuItemType) => (
<li
className="py-2 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white"
key={item.title}

View File

@ -1,9 +1,9 @@
import Grid from 'components/grid';
import { GridTileImage } from 'components/grid/tile';
import { Product } from 'lib/shopify/types';
import { ProductType } from 'lib/geins/types';
import Link from 'next/link';
export default function ProductGridItems({ products }: { products: Product[] }) {
export default function ProductGridItems({ products }: { products: ProductType[] }) {
return (
<>
{products.map((product) => (

View File

@ -1,7 +1,7 @@
import clsx from 'clsx';
import { Suspense } from 'react';
import { getCollections } from 'lib/shopify';
import { getCollections } from 'lib/geins';
import FilterList from './filter';
async function CollectionList() {

View File

@ -1,5 +1,5 @@
import clsx from 'clsx';
const locale = process.env.GEINS_LOCALE || 'en-US';
const Price = ({
amount,
className,
@ -12,7 +12,7 @@ const Price = ({
currencyCodeClassName?: string;
} & React.ComponentProps<'p'>) => (
<p suppressHydrationWarning={true} className={className}>
{`${new Intl.NumberFormat(undefined, {
{`${new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
currencyDisplay: 'narrowSymbol'

View File

@ -1,10 +1,10 @@
import { AddToCart } from 'components/cart/add-to-cart';
import Price from 'components/price';
import Prose from 'components/prose';
import { Product } from 'lib/shopify/types';
import { ProductType } from 'lib/geins/types';
import { VariantSelector } from './variant-selector';
export function ProductDescription({ product }: { product: Product }) {
export function ProductDescription({ product }: { product: ProductType }) {
return (
<>
<div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700">

View File

@ -2,7 +2,7 @@
import clsx from 'clsx';
import { useProduct, useUpdateURL } from 'components/product/product-context';
import { ProductOption, ProductVariant } from 'lib/shopify/types';
import { ProductOptionType, ProductVariantType } from 'lib/geins/types';
type Combination = {
id: string;
@ -14,8 +14,8 @@ export function VariantSelector({
options,
variants
}: {
options: ProductOption[];
variants: ProductVariant[];
options: ProductOptionType[];
variants: ProductVariantType[];
}) {
const { state, updateOption } = useProduct();
const updateURL = useUpdateURL();

View File

@ -3,6 +3,7 @@ import clsx from 'clsx';
const Prose = ({ html, className }: { html: string; className?: string }) => {
return (
<div
suppressHydrationWarning={true}
className={clsx(
'prose mx-auto max-w-6xl text-base leading-7 text-black prose-headings:mt-8 prose-headings:font-semibold prose-headings:tracking-wide prose-headings:text-black prose-h1:text-5xl prose-h2:text-4xl prose-h3:text-3xl prose-h4:text-2xl prose-h5:text-xl prose-h6:text-lg prose-a:text-black prose-a:underline hover:prose-a:text-neutral-300 prose-strong:text-black prose-ol:mt-8 prose-ol:list-decimal prose-ol:pl-6 prose-ul:mt-8 prose-ul:list-disc prose-ul:pl-6 dark:text-white dark:prose-headings:text-white dark:prose-a:text-white dark:prose-strong:text-white',
className

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 Geins, Next.js, and Vercel.{' '}
<a
href="https://vercel.com/templates/next.js/nextjs-commerce"
className="text-blue-600 hover:underline"

26
lib/geins/cms.ts Normal file
View File

@ -0,0 +1,26 @@
import { GeinsCMS } from '@geins/cms';
import { GeinsCore } from '@geins/core';
import { GeinsMenuType } from '@geins/types';
import { reshapeMenu, reshapePage } from './reshape';
import { MenuItemType } from './types';
export const getMenu = async (
geinsCore: GeinsCore,
locationId: string
): Promise<MenuItemType[]> => {
const geinsCMS = new GeinsCMS(geinsCore);
const menu = await geinsCMS.menu.get({ menuLocationId: locationId }).then((result) => {
return result as GeinsMenuType;
});
return reshapeMenu(menu, locationId);
};
export const getPage = async (geinsCore: GeinsCore, alias: string) => {
const geinsCMS = new GeinsCMS(geinsCore);
const data = await geinsCMS.page.get({ alias }).then((result) => {
return result;
});
return reshapePage(data, alias);
};

9
lib/geins/constants.ts Normal file
View File

@ -0,0 +1,9 @@
export const USE_CATEGORY_FOR_RECOMMENDATIONS_BACKUP =
process.env.GEINS_USE_CATEGORY_FOR_RECOMMENDATIONS_BACKUP || true;
export const IMAGE_URL =
process.env.GEINS_IMAGE_URL || `https://${process.env.GEINS_ACCOUNT_NAME}.commerce.services`;
export const DEFAULT_SKU_VARIATION = process.env.GEINS_SKU_DEFAULT_VARIATION || 'Size';
export const CURRENCY_CODE = process.env.GEINS_CURRENCY_CODE || 'USD';
export const PAYMENT_ID = parseInt(process.env.GEINS_PAYMENT_ID ?? '0');
export const SHORT_DESCRIPTION = process.env.GEINS_PRODUCT_DESCRIPTION_SHORT_TEXT || 'text2';
export const LONG_DESCRIPTION = process.env.GEINS_PRODUCT_DESCRIPTION_LONG_TEXT || 'text1';

214
lib/geins/index.ts Normal file
View File

@ -0,0 +1,214 @@
import { GeinsCore, GeinsSettings } from '@geins/core';
import { TAGS } from 'lib/constants';
import { revalidateTag } from 'next/cache';
import { cookies, headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import * as cms from './cms';
import * as oms from './oms';
import * as pim from './pim';
import { CartItemInputType, CartType, MenuItemType, PageType, ProductType } from './types';
const geinsSettings: GeinsSettings = {
apiKey: process.env.GEINS_API_KEY || '',
accountName: process.env.GEINS_ACCOUNT_NAME || '',
channel: process.env.GEINS_CHANNEL || '',
tld: process.env.GEINS_TLD || '',
locale: process.env.GEINS_LOCALE || '',
market: process.env.GEINS_MARKET || '',
environment: 'qa'
};
const geinsCore = new GeinsCore(geinsSettings);
/*
COLLECTIONS / CATEFORIES
*/
export const getCollections = async (parentNodeId: number = 0) => {
const data = await pim.getCategories(geinsCore, parentNodeId);
data.forEach((item) => {
item.path = `/search/${item.slug}`;
});
return data;
};
export const getCollection = async (slug: string): Promise<any> => {
return pim.getCategoryMetadata(geinsCore, slug);
};
/*
PRODUCT LISTS
*/
export async function getProducts({
query,
reverse,
sortKey
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<ProductType[]> {
return await pim.getProducts(geinsCore, { query: query, reverse, sortKey });
}
export async function getCollectionProducts({
collection,
reverse,
sortKey
}: {
collection: string;
reverse?: boolean;
sortKey?: string;
}): Promise<ProductType[]> {
let category = collection;
if (collection === 'hidden-homepage-featured-items') {
category = 'start';
} else if (collection === 'hidden-homepage-carousel') {
category = 'carousel-on-start';
}
return await pim.getCategoryProducts(geinsCore, { category, reverse, sortKey });
}
/*
PRODUCT
*/
export const getProduct = async (slug: string) => {
return pim.getProduct(geinsCore, slug);
};
export async function getProductRecommendations(product: ProductType): Promise<ProductType[]> {
return pim.getProductRecommendations(geinsCore, product);
}
/*
CMS
*/
export const getMenu = async (id: string): Promise<MenuItemType[]> => {
let menuId = '';
if (id === 'next-js-frontend-footer-menu') {
menuId = 'footer-first';
} else if (id === 'next-js-frontend-header-menu') {
menuId = 'main-desktop';
}
const data: MenuItemType[] = await cms.getMenu(geinsCore, menuId);
return data.filter((item) => item.title !== '');
};
export async function getPages(): Promise<PageType[]> {
return [] as PageType[];
}
export async function getPage(handle: string): Promise<PageType> {
if (handle === 'checkout') {
let cartId = (await cookies()).get('cartId')?.value;
if (!cartId) {
return {} as PageType;
}
return oms.getCheckoutPage(geinsCore, cartId);
}
return cms.getPage(geinsCore, handle);
}
/*
CART
*/
export async function createCart(): Promise<CartType> {
return await oms.createCart(geinsCore);
}
export async function getCart(cartId: string | undefined): Promise<CartType | undefined> {
if (!cartId) {
return undefined;
}
return await oms.getCart(geinsCore, cartId);
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<CartType | undefined> {
if (!cartId) {
return undefined;
}
const items = lines.map((item) => {
return {
skuId: parseInt(item.merchandiseId),
quantity: item.quantity
};
});
let cart = {} as CartType;
for (let i = 0; i < items.length; i++) {
cart = await oms.addToCart(geinsCore, cartId, items[i] as CartItemInputType);
}
return cart;
}
export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<CartType | undefined> {
if (!cartId) {
return undefined;
}
// create geins item to add from lines
const items = lines.map((item) => {
return {
id: item.id,
quantity: item.quantity
};
});
let cart = {} as CartType;
for (let i = 0; i < items.length; i++) {
cart = await oms.updateCart(geinsCore, cartId, items[i] as CartItemInputType);
}
return cart;
}
export async function removeFromCart(
cartId: string,
lineIds: string[]
): Promise<CartType | undefined> {
if (!cartId) {
return undefined;
}
let cart = {} as CartType;
for (let i = 0; i < lineIds.length; i++) {
cart = await oms.removeFromCart(geinsCore, cartId, lineIds[i] as string);
}
return cart;
}
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
export async function revalidate(req: NextRequest): Promise<NextResponse> {
// We always need to respond with a 200 status code to Geins,
// otherwise it will continue to retry the request.
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
const topic = (await headers()).get('x-geins-topic') || 'unknown';
const secret = req.nextUrl.searchParams.get('secret');
const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.GEINS_REVALIDATION_SECRET) {
console.error('Invalid revalidation secret.');
return NextResponse.json({ status: 401 });
}
if (!isCollectionUpdate && !isProductUpdate) {
// We don't need to revalidate anything for any other topics.
return NextResponse.json({ status: 200 });
}
if (isCollectionUpdate) {
revalidateTag(TAGS.collections);
}
if (isProductUpdate) {
revalidateTag(TAGS.products);
}
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
}

102
lib/geins/oms.ts Normal file
View File

@ -0,0 +1,102 @@
'use server';
import { GeinsCore } from '@geins/core';
import { PAYMENT_ID } from './constants';
import { cartAddMutation } from './queries/mutations/cart-add';
import { cartUpdateMutation } from './queries/mutations/cart-line-update';
import { checkoutMutation } from './queries/mutations/checkout';
import { cartCreateQuery } from './queries/queries/cart-create';
import { cartGetQuery } from './queries/queries/cart-get';
import { reshapeCart, reshapeCheckout } from './reshape';
import { CartItemInputType, PageType } from './types';
export const createCart = async (geinsCore: GeinsCore): Promise<any> => {
const data = await geinsCore.graphql.query({
queryAsString: cartCreateQuery,
variables: {},
requestOptions: { fetchPolicy: 'no-cache' }
});
if (!data || !data.getCart) {
return {};
}
return reshapeCart(data.getCart);
};
export const getCart = async (geinsCore: GeinsCore, id: string | undefined): Promise<any> => {
const data = await geinsCore.graphql.query({
queryAsString: cartGetQuery,
variables: { id },
requestOptions: { fetchPolicy: 'no-cache' }
});
if (!data || !data.getCart) {
return {};
}
return reshapeCart(data.getCart);
};
export const addToCart = async (
geinsCore: GeinsCore,
id: string,
item: CartItemInputType
): Promise<any> => {
const data = await geinsCore.graphql.mutation({
queryAsString: cartAddMutation,
variables: { id: id, item: item },
requestOptions: { fetchPolicy: 'no-cache' }
});
if (!data || !data.addToCart) {
return {};
}
return reshapeCart(data.addToCart);
};
export const removeFromCart = async (
geinsCore: GeinsCore,
id: string,
itemId: string
): Promise<any> => {
const item = {
id: itemId,
quantity: 0
};
const data = await geinsCore.graphql.mutation({
queryAsString: cartUpdateMutation,
variables: { id, item },
requestOptions: { fetchPolicy: 'no-cache' }
});
if (!data || !data.updateCartItem) {
return {};
}
return reshapeCart(data.updateCartItem);
};
export const updateCart = async (
geinsCore: GeinsCore,
id: string,
item: CartItemInputType
): Promise<any> => {
const data = await geinsCore.graphql.mutation({
queryAsString: cartUpdateMutation,
variables: { id, item },
requestOptions: { fetchPolicy: 'no-cache' }
});
if (!data || !data.updateCartItem) {
return {};
}
return reshapeCart(data.updateCartItem);
};
export const getCheckoutPage = async (geinsCore: GeinsCore, cartId: string): Promise<PageType> => {
const variables = {
cartId: cartId,
checkout: {
paymentId: PAYMENT_ID
}
};
const data = await geinsCore.graphql.mutation({
queryAsString: checkoutMutation,
variables,
requestOptions: { fetchPolicy: 'no-cache' }
});
return reshapeCheckout(data);
};

151
lib/geins/pim.ts Normal file
View File

@ -0,0 +1,151 @@
import { GeinsCore } from '@geins/core';
import { USE_CATEGORY_FOR_RECOMMENDATIONS_BACKUP } from './constants';
import { categoriesQuery } from './queries/queries/categories';
import { listPageInfoQuery } from './queries/queries/listPageInfo';
import { productQuery } from './queries/queries/product';
import { productsQuery } from './queries/queries/products';
import { relatedProductsQuery } from './queries/queries/products-related';
import {
reshapeCategories,
reshapeListPageMetadata,
reshapeProduct,
reshapeProducts,
translateSortKey
} from './reshape';
import {
CategoryItemType,
CollectionType,
ProductRelationType,
ProductRelationTypeEnum,
ProductType
} from './types';
export const getCategoryMetadata = async (
geinsCore: GeinsCore,
slug: string
): Promise<CollectionType> => {
const data = await geinsCore.graphql.query({
queryAsString: listPageInfoQuery,
variables: { url: slug }
});
return reshapeListPageMetadata(data);
};
export const getCategories = async (
geinsCore: GeinsCore,
parentNodeId?: number
): Promise<CategoryItemType[]> => {
const variables = {
includeHidden: false,
parentCategoryId: parentNodeId
};
const data = await geinsCore.graphql.query({ queryAsString: categoriesQuery, variables });
return reshapeCategories(data);
};
export const getProduct = async (
geinsCore: GeinsCore,
slug: string
): Promise<ProductType | undefined> => {
const variables = {
alias: slug
};
const data = await geinsCore.graphql.query({ queryAsString: productQuery, variables });
if (!data.product) {
return undefined;
}
return reshapeProduct(data.product);
};
export const getProducts = async (
geinsCore: GeinsCore,
{
query,
reverse,
sortKey
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}
): Promise<ProductType[]> => {
const variables = {
filter: {
sort: translateSortKey(sortKey || '', reverse || false),
includeCollapsed: true,
filterMode: 'CURRENT',
searchText: query
}
};
const data = await geinsCore.graphql.query({ queryAsString: productsQuery, variables });
if (!data || !data.products || !data.products.products) {
return [];
}
return reshapeProducts(data.products.products);
};
export const getProductRecommendations = async (
geinsCore: GeinsCore,
product: ProductType
): Promise<ProductType[]> => {
const variables = {
alias: product.slug
};
const data = await geinsCore.graphql.query({ queryAsString: relatedProductsQuery, variables });
if (data?.relatedProducts && data.relatedProducts.length > 0) {
return reshapeProducts(data.relatedProducts);
}
if (USE_CATEGORY_FOR_RECOMMENDATIONS_BACKUP) {
const categoryAlias = product.relations?.filter(
(relation: ProductRelationType) => relation.type === ProductRelationTypeEnum.CATEGORY
);
if (categoryAlias && categoryAlias[0]) {
return getCategoryProducts(geinsCore, { category: categoryAlias[0].alias, take: 4 });
}
}
return [];
};
export const getCategoryProducts = async (
geinsCore: GeinsCore,
{
category,
reverse,
sortKey,
take,
skip
}: {
category: string;
reverse?: boolean;
sortKey?: string;
take?: number;
skip?: number;
}
): Promise<ProductType[]> => {
const variables = {
categoryAlias: category,
filter: {
sort: translateSortKey(sortKey || '', reverse || false),
includeCollapsed: false
},
...(take && { take }),
...(skip && { skip })
};
const data = await geinsCore.graphql.query({
queryAsString: productsQuery,
variables,
requestOptions: { fetchPolicy: 'no-cache' }
});
if (!data || !data.products || !data.products.products) {
return [];
}
return reshapeProducts(data.products.products);
};

View File

@ -0,0 +1,7 @@
const campaignFragment = /* GraphQL */ `
fragment Campaign on CampaignRuleType {
name
hideTitle
}
`;
export default campaignFragment;

View File

@ -0,0 +1,107 @@
import campaignFragment from './campaign';
import priceFragment from './price';
import stockFragment from './stock';
const cartFragment = /* GraphQL */ `
fragment Cart on CartType {
id
promoCode
appliedCampaigns {
...Campaign
}
items {
id
campaign {
appliedCampaigns {
...Campaign
}
prices {
price {
...Price
}
quantity
}
}
unitPrice {
...Price
}
product {
productId
articleNumber
brand {
name
}
name
productImages {
fileName
}
alias
canonicalUrl
primaryCategory {
name
}
skus {
skuId
name
stock {
...Stock
}
}
unitPrice {
...Price
}
}
quantity
skuId
totalPrice {
...Price
}
}
summary {
fixedAmountDiscountIncVat
fixedAmountDiscountExVat
balance {
pending
pendingFormatted
totalSellingPriceExBalanceExVat
totalSellingPriceExBalanceIncVat
totalSellingPriceExBalanceIncVatFormatted
}
subTotal {
regularPriceIncVatFormatted
regularPriceExVatFormatted
sellingPriceIncVatFormatted
sellingPriceExVatFormatted
sellingPriceExVat
sellingPriceIncVat
vat
}
shipping {
amountLeftToFreeShipping
amountLeftToFreeShippingFormatted
feeExVatFormatted
feeIncVatFormatted
feeIncVat
feeExVat
isDefault
}
total {
isDiscounted
sellingPriceIncVatFormatted
sellingPriceExVatFormatted
sellingPriceIncVat
sellingPriceExVat
discountIncVatFormatted
discountExVatFormatted
discountExVat
discountIncVat
vatFormatted
vat
}
}
}
${priceFragment}
${stockFragment}
${campaignFragment}
`;
export default cartFragment;

View File

@ -0,0 +1,26 @@
import metaFragment from './meta';
const listInfoFragment = /* GraphQL */ `
fragment ListInfo on PageInfoType {
id
alias
canonicalUrl
primaryImage
name
primaryDescription
secondaryDescription
hideTitle
hideDescription
logo
meta {
...Meta
}
subCategories {
name
alias
canonicalUrl
}
}
${metaFragment}
`;
export default listInfoFragment;

View File

@ -0,0 +1,32 @@
import priceFragment from './price';
import stockFragment from './stock';
const listProductFragment = /* GraphQL */ `
fragment ListProduct on ProductType {
brand {
name
alias
}
name
productId
articleNumber
alias
canonicalUrl
unitPrice {
...Price
}
productImages {
fileName
}
primaryCategory {
name
alias
}
totalStock {
...Stock
}
}
${priceFragment}
${stockFragment}
`;
export default listProductFragment;

View File

@ -0,0 +1,7 @@
const metaFragment = /* GraphQL */ `
fragment Meta on MetadataType {
title
description
}
`;
export default metaFragment;

View File

@ -0,0 +1,16 @@
const priceFragment = /* GraphQL */ `
fragment Price on PriceType {
isDiscounted
regularPriceIncVatFormatted
sellingPriceIncVatFormatted
regularPriceExVatFormatted
sellingPriceExVatFormatted
sellingPriceIncVat
sellingPriceExVat
regularPriceIncVat
regularPriceExVat
vat
discountPercentage
}
`;
export default priceFragment;

View File

@ -0,0 +1,14 @@
import stockFragment from './stock';
const skuFragment = /* GraphQL */ `
fragment Sku on SkuType {
skuId
gtin
name
productId
stock {
...Stock
}
}
${stockFragment}
`;
export default skuFragment;

View File

@ -0,0 +1,10 @@
const stockFragment = /* GraphQL */ `
fragment Stock on StockType {
inStock
oversellable
totalStock
static
incoming
}
`;
export default stockFragment;

View File

@ -0,0 +1,22 @@
import stockFragment from './stock';
const variantFragment = /* GraphQL */ `
fragment Variant on VariantType {
alias
level
attributes {
key
value
}
label
value
dimension
skuId
productId
stock {
...Stock
}
primaryImage
}
${stockFragment}
`;
export default variantFragment;

View File

@ -0,0 +1,22 @@
import cartFragment from '../fragments/cart';
export const cartAddMutation = /* GraphQL */ `
mutation addToCart(
$id: String!
$item: CartItemInputType!
$channelId: String
$languageId: String
$marketId: String
) {
addToCart(
id: $id
item: $item
channelId: $channelId
languageId: $languageId
marketId: $marketId
) {
...Cart
}
}
${cartFragment}
`;

View File

@ -0,0 +1,22 @@
import cartFragment from '../fragments/cart';
export const cartUpdateMutation = /* GraphQL */ `
mutation updateCartItem(
$id: String!
$item: CartItemInputType!
$channelId: String
$languageId: String
$marketId: String
) {
updateCartItem(
id: $id
item: $item
channelId: $channelId
languageId: $languageId
marketId: $marketId
) {
...Cart
}
}
${cartFragment}
`;

View File

@ -0,0 +1,21 @@
export const checkoutMutation = /* GraphQL */ `
mutation createOrUpdateCheckout(
$cartId: String!
$checkout: CheckoutInputType
$channelId: String
$languageId: String
$marketId: String
) {
createOrUpdateCheckout(
cartId: $cartId
checkout: $checkout
channelId: $channelId
languageId: $languageId
marketId: $marketId
) {
paymentOptions {
paymentData
}
}
}
`;

View File

@ -0,0 +1,10 @@
import cartFragment from '../fragments/cart';
export const cartCreateQuery = /* GraphQL */ `
query getCart($channelId: String, $languageId: String, $marketId: String) {
getCart(channelId: $channelId, languageId: $languageId, marketId: $marketId) {
...Cart
}
}
${cartFragment}
`;

View File

@ -0,0 +1,10 @@
import cartFragment from '../fragments/cart';
export const cartGetQuery = /* GraphQL */ `
query getCart($id: String, $channelId: String, $languageId: String, $marketId: String) {
getCart(id: $id, channelId: $channelId, languageId: $languageId, marketId: $marketId) {
...Cart
}
}
${cartFragment}
`;

View File

@ -0,0 +1,22 @@
export const categoriesQuery = /* GraphQL */ `
query categories(
$parentCategoryId: Int
$channelId: String
$languageId: String
$marketId: String
) {
categories(
parentCategoryId: $parentCategoryId
channelId: $channelId
languageId: $languageId
marketId: $marketId
) {
alias
canonicalUrl
name
categoryId
parentCategoryId
order
}
}
`;

View File

@ -0,0 +1,10 @@
import listInfoFragment from '../fragments/list-info';
export const listPageInfoQuery = /* GraphQL */ `
query listPageInfo($url: String!, $channelId: String, $languageId: String, $marketId: String) {
listPageInfo(url: $url, channelId: $channelId, languageId: $languageId, marketId: $marketId) {
...ListInfo
}
}
${listInfoFragment}
`;

View File

@ -0,0 +1,92 @@
import campaignFragment from '../fragments/campaign';
import metaFragment from '../fragments/meta';
import priceFragment from '../fragments/price';
import skuFragment from '../fragments/sku';
import stockFragment from '../fragments/stock';
import variantFragment from '../fragments/variant';
export const productQuery = /* GraphQL */ `
query product($alias: String!, $channelId: String, $languageId: String, $marketId: String) {
product(alias: $alias, channelId: $channelId, languageId: $languageId, marketId: $marketId) {
productId
alias
articleNumber
canonicalUrl
name
meta {
...Meta
}
brand {
name
alias
canonicalUrl
}
productImages {
fileName
}
primaryCategory {
name
alias
canonicalUrl
}
categories {
name
alias
}
unitPrice {
...Price
}
texts {
text1
text2
text3
}
parameterGroups {
name
parameterGroupId
parameters {
name
value
show
identifier
}
}
skus {
...Sku
}
totalStock {
...Stock
}
variantDimensions {
dimension
value
level
label
}
variantGroup {
variants {
variants {
variants {
...Variant
}
...Variant
}
...Variant
}
}
discountCampaigns {
...Campaign
}
rating {
score
voteCount
}
}
}
${priceFragment}
${stockFragment}
${skuFragment}
${variantFragment}
${metaFragment}
${campaignFragment}
`;

View File

@ -0,0 +1,43 @@
//import relatedProductFragment from './fragments/related-product';
// ${relatedProductFragment}
import priceFragment from '../fragments/price';
import skuFragment from '../fragments/sku';
export const relatedProductsQuery = /* GraphQL */ `
query relatedProducts(
$alias: String!
$channelId: String
$languageId: String
$marketId: String
) {
relatedProducts(
alias: $alias
channelId: $channelId
languageId: $languageId
marketId: $marketId
) {
alias
name
canonicalUrl
brand {
name
}
unitPrice {
...Price
}
relationType
productImages {
fileName
}
primaryImage
primaryCategory {
name
}
skus {
...Sku
}
}
}
${priceFragment}
${skuFragment}
`;

View File

@ -0,0 +1,38 @@
import listProductFragment from '../fragments/list-product';
export const productsQuery = /* GraphQL */ `
query products(
$skip: Int = null
$take: Int = null
$categoryAlias: String = null
$brandAlias: String = null
$discountCampaignAlias: String = null
$url: String = null
$filter: FilterInputType = null
$channelId: String
$languageId: String
$marketId: String
) {
products(
skip: $skip
take: $take
categoryAlias: $categoryAlias
brandAlias: $brandAlias
discountCampaignAlias: $discountCampaignAlias
url: $url
filter: $filter
channelId: $channelId
languageId: $languageId
marketId: $marketId
) {
products {
brand {
name
}
...ListProduct
}
count
}
}
${listProductFragment}
`;

439
lib/geins/reshape.ts Normal file
View File

@ -0,0 +1,439 @@
import { GeinsMenuType } from '@geins/types';
import {
CURRENCY_CODE,
DEFAULT_SKU_VARIATION,
IMAGE_URL,
LONG_DESCRIPTION,
SHORT_DESCRIPTION
} from './constants';
import {
CartItemType,
CartType,
CategoryItemType,
CollectionType,
PageType,
ProductImageType,
ProductOptionType,
ProductRelationType,
ProductRelationTypeEnum,
ProductType,
ProductVariantType
} from './types';
export const translateSortKey = (sortKey: string, reverse: boolean): string => {
switch (sortKey) {
case 'BEST_SELLING':
return 'MOST_SOLD';
case 'CREATED_AT':
return 'LATEST';
case 'PRICE':
if (reverse) {
return 'PRICE';
}
return 'PRICE_DESC';
default:
return 'RELEVANCE';
}
};
export const reshapeCategories = (geinsCategories: any): CategoryItemType[] => {
if (!geinsCategories || !geinsCategories?.categories.length) {
return [];
}
return geinsCategories.categories.map((item: any) => ({
id: item?.categoryId ?? '',
title: item?.name ?? '',
name: item?.name ?? '',
parentId: item?.parentCategoryId || '',
path: item?.canonicalUrl?.split('/').pop() || '',
slug: item?.alias ?? ''
}));
};
export const reshapeListPageMetadata = (geinsCategoryMetadata: any): CollectionType => {
const rawCategory = geinsCategoryMetadata?.listPageInfo;
if (!rawCategory) {
return {} as CollectionType;
}
let seoTitle = rawCategory?.meta?.title || rawCategory?.name || '';
seoTitle = seoTitle.replace(/\[name\]/g, rawCategory?.name || '');
const seoDescription = rawCategory?.meta?.description || '';
return {
id: rawCategory?.id || '',
title: rawCategory?.name || '',
handle: rawCategory?.alias || '',
seo: {
title: seoTitle,
description: seoDescription
},
description: rawCategory?.primaryDescription || ''
};
};
export const reshapeProducts = (geinsData: any): ProductType[] => {
if (!geinsData) {
throw new Error('Invalid products query response');
}
const rawProducts = geinsData;
if (!rawProducts) {
return [];
}
return rawProducts.map((product: any) => reshapeProduct(product));
};
export const reshapeProduct = (geinsProductData: any): ProductType => {
const rawProduct = geinsProductData;
const tags: string[] = [];
const relations: ProductRelationType[] = [];
if (rawProduct.primaryCategory) {
tags.push(rawProduct.primaryCategory.name);
relations.push({
type: ProductRelationTypeEnum.CATEGORY,
name: rawProduct.primaryCategory.name,
alias: rawProduct.primaryCategory.alias
});
}
if (rawProduct.brand) {
tags.push(rawProduct.brand.name);
relations.push({
type: ProductRelationTypeEnum.BRAND,
name: rawProduct.brand.name,
alias: rawProduct.brand.alias
});
}
if (rawProduct.categories) {
rawProduct.categories.forEach((category: any) => {
tags.push(category.name);
});
}
// remove duplicates
const uniqueTags = [...new Set(tags)];
tags.push(...uniqueTags);
// add descriptions from environment variables
const shortDescription = rawProduct.texts?.[SHORT_DESCRIPTION] || '';
const longDescription = rawProduct.texts?.[LONG_DESCRIPTION] || '';
// add images
const images = rawProduct.productImages?.map(
(image: any): ProductImageType => ({
caption: rawProduct.name,
altText: rawProduct.name,
src: `${IMAGE_URL}/product/1200f1500/${image.fileName}`,
url: `${IMAGE_URL}/product/1200f1500/${image.fileName}`,
height: 1600,
width: 2000
})
);
// add variations and options
let variations: ProductVariantType[] = [];
let options: ProductOptionType[] = [];
if (rawProduct.variantGroup) {
variations = [...reshapeProductVariations(rawProduct)];
options = [...reshapeProductOptions(variations)];
}
return {
id: rawProduct.productId,
seo: {
title: rawProduct.meta?.title,
description: rawProduct.meta?.description
},
metaTitle: rawProduct.meta?.title || rawProduct.name,
metaDescription: rawProduct.meta?.description || shortDescription,
name: rawProduct.name,
title: rawProduct.name,
slug: rawProduct.alias,
handle: rawProduct.alias,
description: shortDescription,
descriptionHtml: rawProduct.texts?.text1 || '',
priceRange: {
minVariantPrice: {
amount: rawProduct.unitPrice?.sellingPriceIncVat,
currencyCode: CURRENCY_CODE
},
maxVariantPrice: {
amount: rawProduct.unitPrice?.sellingPriceIncVat,
currencyCode: CURRENCY_CODE
}
},
price: rawProduct.unitPrice.sellingPriceIncVat,
currency: CURRENCY_CODE,
stockTracking: !!rawProduct.totalStock,
stockPurchasable: rawProduct.totalStock?.inStock || false,
stockLevel: rawProduct.totalStock?.totalStock || 0,
availableForSale: true,
tags: tags || [],
options: [...options] || [],
variants: variations || [],
featuredImage: images[0],
images: images,
relations: relations
};
};
const reshapeProductOptions = (variants: any[]): ProductOptionType[] => {
const optionsMap: Record<string, { id: string; name: string; values: Set<string> }> = {};
variants.forEach((variant) => {
if (variant.selectedOptions && Array.isArray(variant.selectedOptions)) {
variant.selectedOptions.forEach((option: { name: string; value: string }) => {
// Ensure `optionsMap[option.name]` is initialized
if (!optionsMap[option.name]) {
optionsMap[option.name] = {
id: option.name.toLowerCase(),
name: option.name,
values: new Set()
};
}
// Now it is safe to access `optionsMap[option.name].values`
optionsMap[option.name]?.values.add(option.value);
});
}
});
// Convert the map to an array and transform the Set of values to an array
return Object.values(optionsMap).map((option) => ({
id: option.id,
name: option.name,
values: Array.from(option.values)
}));
};
const reshapeSkusToVariants = (skus: any[]): ProductVariantType[] => {
return skus.map((sku) => ({
id: sku.skuId.toString(),
title: sku.name,
availableForSale: sku.stock?.totalStock > 0 || false,
selectedOptions: [{ name: DEFAULT_SKU_VARIATION, value: sku.name }],
price: {
amount: '0',
currencyCode: CURRENCY_CODE
}
}));
};
const reshapeProductVariations = (geinsProductData: any): ProductVariantType[] => {
// filter out the default product from dimensions
const dimensions = geinsProductData.variantDimensions.filter(
(dimension: any) => dimension.dimension !== 'DefaultProduct'
);
if (dimensions.length === 0) {
return reshapeSkusToVariants(geinsProductData.skus);
}
const buildVariantsArray = (
variants: any[],
selectedOptions: any[] = [],
parent: any = undefined
): any[] => {
const result: any[] = []; // Collect the final results here
if (!Array.isArray(variants)) {
return result; // Safeguard against invalid input
}
for (const variant of variants) {
// Look ahead to check if the current variant is DefaultSku with a single value
const hasNextLevel = Array.isArray(variant.variants) && variant.variants.length > 0;
// Create the current option
const newSelectedOptions = [...selectedOptions];
const currentOption = {
name: variant.dimension,
value: variant.value
};
newSelectedOptions.push(currentOption);
if (hasNextLevel) {
const nestedResults = buildVariantsArray(variant.variants, newSelectedOptions, variant);
result.push(...nestedResults);
} else {
const hasOnlyOneOption =
(parent && parent.variants.length === 1 && variant.dimension === 'DefaultSku') || false;
if (hasOnlyOneOption) {
newSelectedOptions.pop();
}
result.push({
id: variant.skuId.toString(),
title: newSelectedOptions.map((opt) => opt.value).join(','),
availableForSale: variant.stock?.totalStock > 0 || false, // Handle stock availability
selectedOptions: newSelectedOptions,
price: {
amount: '',
currencyCode: CURRENCY_CODE // Replace with actual currency code
}
});
}
}
return result;
};
const reshapedVariants = buildVariantsArray(geinsProductData.variantGroup.variants);
return reshapedVariants;
};
export const reshapeCart = (geinsData: any): CartType => {
if (!geinsData) {
return {} as CartType;
}
const items: CartItemType[] = [];
let totalQuantity = 0;
geinsData.items?.forEach((item: any) => {
totalQuantity += item.quantity;
const sku = item.product.skus[0];
items.push({
id: item.id,
quantity: item.quantity,
cost: {
totalAmount: {
amount: item.totalPrice.sellingPriceIncVat + '',
currencyCode: CURRENCY_CODE
}
},
merchandise: {
id: item.skuId,
title: sku.name,
selectedOptions: [{ name: 'Size', value: sku.name }],
product: {
id: item.product.productId,
handle: item.product.alias,
title: item.product.name,
featuredImage: {
caption: item.product.name,
altText: item.product.name,
url: `${IMAGE_URL}/product/100f125/${item.product.productImages[0].fileName}`,
src: `${IMAGE_URL}/product/100f125/${item.product.productImages[0].fileName}`,
height: 1600,
width: 2000
}
}
}
});
});
const vatSum =
geinsData.summary.total.sellingPriceIncVat - geinsData.summary.total.sellingPriceExVat;
const data = {
id: geinsData.id || 'no-cart-id',
lines: items || [],
totalQuantity: totalQuantity,
cost: {
subtotalAmount: {
amount: geinsData.summary.total.sellingPriceExVat + '',
currencyCode: CURRENCY_CODE
},
totalTaxAmount: {
amount: vatSum + '',
currencyCode: CURRENCY_CODE
},
totalAmount: {
amount: geinsData.summary.total.sellingPriceIncVat + '',
currencyCode: CURRENCY_CODE
}
},
checkoutUrl: '/checkout'
};
return data;
};
export const reshapeCheckout = (geinsData: any): PageType => {
const checkoutPage: PageType = {
id: 'checkout',
title: 'Checkout example',
handle: 'checkout',
body: '',
bodySummary: '',
seo: {
title: 'Checkout',
description: 'Checkout page'
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
if (!geinsData || !geinsData.createOrUpdateCheckout) {
checkoutPage.body = 'No payment options available';
return checkoutPage;
}
if (
!geinsData.createOrUpdateCheckout.paymentOptions ||
geinsData.createOrUpdateCheckout.paymentOptions.length === 0
) {
checkoutPage.body = 'No payment options available';
return checkoutPage;
}
checkoutPage.body = geinsData.createOrUpdateCheckout.paymentOptions[0].paymentData;
return checkoutPage;
};
export const reshapeMenu = (geinsMenu: GeinsMenuType, locationId: string) => {
if (!geinsMenu.menuItems) {
return [];
}
return geinsMenu.menuItems.map((item) => {
let itemPath = item?.canonicalUrl?.split('/').pop() || '';
if (item?.type === 'category') {
itemPath = '/search/' + itemPath;
} else if (item?.type === 'custom') {
itemPath = item?.canonicalUrl || '';
}
return {
id: locationId + ':' + item?.id || '',
title: item?.title || '',
path: itemPath || ''
};
});
};
export const reshapePage = (geinsPage: any, alias: string): PageType => {
if (!geinsPage) {
undefined;
}
const title = geinsPage.meta.title || '';
const today = new Date().toISOString();
const body = geinsPage.containers.map((container: any) => {
const content = container.content.map((item: any) => {
if (item.config.type === 'TextPageWidget') {
return `
<h3>${item.data.title}</h3>
<p>
${item.data.text}
</p>
`;
}
if (item.config.type === 'HTMLPageWidget') {
return `${item.data.html}`;
}
if (item.config.type === 'ImagePageWidget') {
return `<img src="${IMAGE_URL}/pagewidget/600w/${item.data.image.filename}" alt="${item.data.image.altText}" />`;
}
return '';
});
return content.join(' ');
});
return {
id: geinsPage.id || '',
title: geinsPage.title || title,
handle: alias || '',
body: body || '',
bodySummary: geinsPage?.bodySummary || '',
seo: geinsPage?.seo || '',
createdAt: geinsPage?.createdAt || today,
updatedAt: geinsPage?.updatedAt || today
};
};

184
lib/geins/types.ts Normal file
View File

@ -0,0 +1,184 @@
export type Maybe<T> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
node: T;
};
export type CartType = {
id: string | undefined;
checkoutUrl: string;
cost: {
subtotalAmount: MoneyType;
totalAmount: MoneyType;
totalTaxAmount: MoneyType;
};
totalQuantity: number;
lines: CartItemType[];
};
export type CartProductType = {
id: string;
handle: string;
title: string;
featuredImage: ProductImageType;
};
export type CartItemType = {
id: string | undefined;
quantity: number;
cost: {
totalAmount: MoneyType;
};
merchandise: {
id: string;
title: string;
selectedOptions: {
name: string;
value: string;
}[];
product: CartProductType;
};
};
export type CartItemInputType = {
id?: string;
skuId?: number;
quantity: number;
};
export type SeoType = {
title: string;
description: string;
};
export type CollectionType = {
id: string;
title: string;
description: string;
handle: string;
seo?: SeoType;
};
export type PageType = {
id: string;
title: string;
handle: string;
body: string;
bodySummary: string;
seo?: SeoType;
createdAt: string;
updatedAt: string;
};
export type MenuType = {
id: string;
title?: string;
name: string;
items: MenuItemType[];
};
export type MenuItemType = {
id: string;
title: string;
path: string;
slug?: string;
};
export type CategoryItemType = {
id: string;
parentId: string;
title: string;
path: string;
slug?: string;
updatedAt?: string;
};
export enum ProductRelationTypeEnum {
BRAND = 'BRAND',
CATEGORY = 'CATEGORY',
RELATED = 'RELATED',
SIMILAR = 'SIMILAR',
CROSS_SELL = 'CROSS_SELL',
UP_SELL = 'UP_SELL'
}
export type ProductRelationType = {
type: ProductRelationTypeEnum;
name: string;
alias: string;
};
export type MoneyType = {
amount: string;
currencyCode: string;
};
export type ProductType = {
id: string;
seo: {
title?: string;
description?: string;
};
currency: string;
slug: string;
handle: string;
stockTracking: boolean;
stockPurchasable: boolean;
stockLevel: number;
title: string;
name: string;
description: string;
price: string;
priceRange: {
maxVariantPrice: MoneyType;
minVariantPrice: MoneyType;
};
metaTitle: string;
metaDescription: string;
tags: string[];
options: ProductOptionType[];
variants: ProductVariantType[];
featuredImage: ProductImageType;
images: ProductImageType[];
updatedAt?: string;
availableForSale: boolean;
descriptionHtml?: string;
relations?: ProductRelationType[];
};
export type ProductOptionType = {
id: string;
name: string;
values: string[];
};
export type ProductVariantType = {
id: string;
title: string;
availableForSale: boolean;
selectedOptions: {
name: string;
value: string;
}[];
price: MoneyType;
};
export type ProductImageType = {
caption: string;
altText: string;
url: string;
src: string;
width: number;
height: number;
};
export type ImageType = {
url: string;
altText: string;
width: number;
height: number;
};

View File

@ -1,53 +0,0 @@
import productFragment from './product';
const cartFragment = /* GraphQL */ `
fragment cart on Cart {
id
checkoutUrl
cost {
subtotalAmount {
amount
currencyCode
}
totalAmount {
amount
currencyCode
}
totalTaxAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
cost {
totalAmount {
amount
currencyCode
}
}
merchandise {
... on ProductVariant {
id
title
selectedOptions {
name
value
}
product {
...product
}
}
}
}
}
}
totalQuantity
}
${productFragment}
`;
export default cartFragment;

View File

@ -1,10 +0,0 @@
const imageFragment = /* GraphQL */ `
fragment image on Image {
url
altText
width
height
}
`;
export default imageFragment;

View File

@ -1,64 +0,0 @@
import imageFragment from './image';
import seoFragment from './seo';
const productFragment = /* GraphQL */ `
fragment product on Product {
id
handle
availableForSale
title
description
descriptionHtml
options {
id
name
values
}
priceRange {
maxVariantPrice {
amount
currencyCode
}
minVariantPrice {
amount
currencyCode
}
}
variants(first: 250) {
edges {
node {
id
title
availableForSale
selectedOptions {
name
value
}
price {
amount
currencyCode
}
}
}
}
featuredImage {
...image
}
images(first: 20) {
edges {
node {
...image
}
}
}
seo {
...seo
}
tags
updatedAt
}
${imageFragment}
${seoFragment}
`;
export default productFragment;

View File

@ -1,8 +0,0 @@
const seoFragment = /* GraphQL */ `
fragment seo on SEO {
description
title
}
`;
export default seoFragment;

View File

@ -1,455 +0,0 @@
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
import { isShopifyError } from 'lib/type-guards';
import { ensureStartsWith } from 'lib/utils';
import { revalidateTag } from 'next/cache';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import {
addToCartMutation,
createCartMutation,
editCartItemsMutation,
removeFromCartMutation
} from './mutations/cart';
import { getCartQuery } from './queries/cart';
import {
getCollectionProductsQuery,
getCollectionQuery,
getCollectionsQuery
} from './queries/collection';
import { getMenuQuery } from './queries/menu';
import { getPageQuery, getPagesQuery } from './queries/page';
import {
getProductQuery,
getProductRecommendationsQuery,
getProductsQuery
} from './queries/product';
import {
Cart,
Collection,
Connection,
Image,
Menu,
Page,
Product,
ShopifyAddToCartOperation,
ShopifyCart,
ShopifyCartOperation,
ShopifyCollection,
ShopifyCollectionOperation,
ShopifyCollectionProductsOperation,
ShopifyCollectionsOperation,
ShopifyCreateCartOperation,
ShopifyMenuOperation,
ShopifyPageOperation,
ShopifyPagesOperation,
ShopifyProduct,
ShopifyProductOperation,
ShopifyProductRecommendationsOperation,
ShopifyProductsOperation,
ShopifyRemoveFromCartOperation,
ShopifyUpdateCartOperation
} from './types';
const domain = process.env.SHOPIFY_STORE_DOMAIN
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
: '';
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
export async function shopifyFetch<T>({
cache = 'force-cache',
headers,
query,
tags,
variables
}: {
cache?: RequestCache;
headers?: HeadersInit;
query: string;
tags?: string[];
variables?: ExtractVariables<T>;
}): Promise<{ status: number; body: T } | never> {
try {
const result = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': key,
...headers
},
body: JSON.stringify({
...(query && { query }),
...(variables && { variables })
}),
cache,
...(tags && { next: { tags } })
});
const body = await result.json();
if (body.errors) {
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 = <T>(array: Connection<T>): T[] => {
return array.edges.map((edge) => edge?.node);
};
const reshapeCart = (cart: ShopifyCart): Cart => {
if (!cart.cost?.totalTaxAmount) {
cart.cost.totalTaxAmount = {
amount: '0.0',
currencyCode: cart.cost.totalAmount.currencyCode
};
}
return {
...cart,
lines: removeEdgesAndNodes(cart.lines)
};
};
const reshapeCollection = (collection: ShopifyCollection): Collection | undefined => {
if (!collection) {
return undefined;
}
return {
...collection,
path: `/search/${collection.handle}`
};
};
const reshapeCollections = (collections: ShopifyCollection[]) => {
const reshapedCollections = [];
for (const collection of collections) {
if (collection) {
const reshapedCollection = reshapeCollection(collection);
if (reshapedCollection) {
reshapedCollections.push(reshapedCollection);
}
}
}
return reshapedCollections;
};
const reshapeImages = (images: Connection<Image>, productTitle: string) => {
const flattened = removeEdgesAndNodes(images);
return flattened.map((image) => {
const filename = image.url.match(/.*\/(.*)\..*/)?.[1];
return {
...image,
altText: image.altText || `${productTitle} - ${filename}`
};
});
};
const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean = true) => {
if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) {
return undefined;
}
const { images, variants, ...rest } = product;
return {
...rest,
images: reshapeImages(images, product.title),
variants: removeEdgesAndNodes(variants)
};
};
const reshapeProducts = (products: ShopifyProduct[]) => {
const reshapedProducts = [];
for (const product of products) {
if (product) {
const reshapedProduct = reshapeProduct(product);
if (reshapedProduct) {
reshapedProducts.push(reshapedProduct);
}
}
}
return reshapedProducts;
};
export async function createCart(): Promise<Cart> {
const res = await shopifyFetch<ShopifyCreateCartOperation>({
query: createCartMutation,
cache: 'no-store'
});
return reshapeCart(res.body.data.cartCreate.cart);
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyAddToCartOperation>({
query: addToCartMutation,
variables: {
cartId,
lines
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesAdd.cart);
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
query: removeFromCartMutation,
variables: {
cartId,
lineIds
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesRemove.cart);
}
export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
query: editCartItemsMutation,
variables: {
cartId,
lines
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(cartId: string | undefined): Promise<Cart | undefined> {
if (!cartId) {
return undefined;
}
const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery,
variables: { cartId },
tags: [TAGS.cart]
});
// Old carts becomes `null` when you checkout.
if (!res.body.data.cart) {
return undefined;
}
return reshapeCart(res.body.data.cart);
}
export async function getCollection(handle: string): Promise<Collection | undefined> {
const res = await shopifyFetch<ShopifyCollectionOperation>({
query: getCollectionQuery,
tags: [TAGS.collections],
variables: {
handle
}
});
return reshapeCollection(res.body.data.collection);
}
export async function getCollectionProducts({
collection,
reverse,
sortKey
}: {
collection: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
query: getCollectionProductsQuery,
tags: [TAGS.collections, TAGS.products],
variables: {
handle: collection,
reverse,
sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey
}
});
if (!res.body.data.collection) {
console.log(`No collection found for \`${collection}\``);
return [];
}
return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products));
}
export async function getCollections(): Promise<Collection[]> {
const res = await shopifyFetch<ShopifyCollectionsOperation>({
query: getCollectionsQuery,
tags: [TAGS.collections]
});
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
const collections = [
{
handle: '',
title: 'All',
description: 'All products',
seo: {
title: 'All',
description: 'All products'
},
path: '/search',
updatedAt: new Date().toISOString()
},
// Filter out the `hidden` collections.
// Collections that start with `hidden-*` need to be hidden on the search page.
...reshapeCollections(shopifyCollections).filter(
(collection) => !collection.handle.startsWith('hidden')
)
];
return collections;
}
export async function getMenu(handle: string): Promise<Menu[]> {
const res = await shopifyFetch<ShopifyMenuOperation>({
query: getMenuQuery,
tags: [TAGS.collections],
variables: {
handle
}
});
return (
res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
title: item.title,
path: item.url.replace(domain, '').replace('/collections', '/search').replace('/pages', '')
})) || []
);
}
export async function getPage(handle: string): Promise<Page> {
const res = await shopifyFetch<ShopifyPageOperation>({
query: getPageQuery,
cache: 'no-store',
variables: { handle }
});
return res.body.data.pageByHandle;
}
export async function getPages(): Promise<Page[]> {
const res = await shopifyFetch<ShopifyPagesOperation>({
query: getPagesQuery,
cache: 'no-store'
});
return removeEdgesAndNodes(res.body.data.pages);
}
export async function getProduct(handle: string): Promise<Product | undefined> {
const res = await shopifyFetch<ShopifyProductOperation>({
query: getProductQuery,
tags: [TAGS.products],
variables: {
handle
}
});
return reshapeProduct(res.body.data.product, false);
}
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
query: getProductRecommendationsQuery,
tags: [TAGS.products],
variables: {
productId
}
});
return reshapeProducts(res.body.data.productRecommendations);
}
export async function getProducts({
query,
reverse,
sortKey
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductsOperation>({
query: getProductsQuery,
tags: [TAGS.products],
variables: {
query,
reverse,
sortKey
}
});
return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
}
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
export async function revalidate(req: NextRequest): Promise<NextResponse> {
// We always need to respond with a 200 status code to Shopify,
// otherwise it will continue to retry the request.
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
const topic = (await headers()).get('x-shopify-topic') || 'unknown';
const secret = req.nextUrl.searchParams.get('secret');
const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
console.error('Invalid revalidation secret.');
return NextResponse.json({ status: 401 });
}
if (!isCollectionUpdate && !isProductUpdate) {
// We don't need to revalidate anything for any other topics.
return NextResponse.json({ status: 200 });
}
if (isCollectionUpdate) {
revalidateTag(TAGS.collections);
}
if (isProductUpdate) {
revalidateTag(TAGS.products);
}
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
}

View File

@ -1,45 +0,0 @@
import cartFragment from '../fragments/cart';
export const addToCartMutation = /* GraphQL */ `
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const createCartMutation = /* GraphQL */ `
mutation createCart($lineItems: [CartLineInput!]) {
cartCreate(input: { lines: $lineItems }) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const editCartItemsMutation = /* GraphQL */ `
mutation editCartItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const removeFromCartMutation = /* GraphQL */ `
mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
...cart
}
}
}
${cartFragment}
`;

View File

@ -1,10 +0,0 @@
import cartFragment from '../fragments/cart';
export const getCartQuery = /* GraphQL */ `
query getCart($cartId: ID!) {
cart(id: $cartId) {
...cart
}
}
${cartFragment}
`;

View File

@ -1,56 +0,0 @@
import productFragment from '../fragments/product';
import seoFragment from '../fragments/seo';
const collectionFragment = /* GraphQL */ `
fragment collection on Collection {
handle
title
description
seo {
...seo
}
updatedAt
}
${seoFragment}
`;
export const getCollectionQuery = /* GraphQL */ `
query getCollection($handle: String!) {
collection(handle: $handle) {
...collection
}
}
${collectionFragment}
`;
export const getCollectionsQuery = /* GraphQL */ `
query getCollections {
collections(first: 100, sortKey: TITLE) {
edges {
node {
...collection
}
}
}
}
${collectionFragment}
`;
export const getCollectionProductsQuery = /* GraphQL */ `
query getCollectionProducts(
$handle: String!
$sortKey: ProductCollectionSortKeys
$reverse: Boolean
) {
collection(handle: $handle) {
products(sortKey: $sortKey, reverse: $reverse, first: 100) {
edges {
node {
...product
}
}
}
}
}
${productFragment}
`;

View File

@ -1,10 +0,0 @@
export const getMenuQuery = /* GraphQL */ `
query getMenu($handle: String!) {
menu(handle: $handle) {
items {
title
url
}
}
}
`;

View File

@ -1,41 +0,0 @@
import seoFragment from '../fragments/seo';
const pageFragment = /* GraphQL */ `
fragment page on Page {
... on Page {
id
title
handle
body
bodySummary
seo {
...seo
}
createdAt
updatedAt
}
}
${seoFragment}
`;
export const getPageQuery = /* GraphQL */ `
query getPage($handle: String!) {
pageByHandle(handle: $handle) {
...page
}
}
${pageFragment}
`;
export const getPagesQuery = /* GraphQL */ `
query getPages {
pages(first: 100) {
edges {
node {
...page
}
}
}
}
${pageFragment}
`;

View File

@ -1,32 +0,0 @@
import productFragment from '../fragments/product';
export const getProductQuery = /* GraphQL */ `
query getProduct($handle: String!) {
product(handle: $handle) {
...product
}
}
${productFragment}
`;
export const getProductsQuery = /* GraphQL */ `
query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
edges {
node {
...product
}
}
}
}
${productFragment}
`;
export const getProductRecommendationsQuery = /* GraphQL */ `
query getProductRecommendations($productId: ID!) {
productRecommendations(productId: $productId) {
...product
}
}
${productFragment}
`;

View File

@ -1,272 +0,0 @@
export type Maybe<T> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
node: T;
};
export type Cart = Omit<ShopifyCart, 'lines'> & {
lines: CartItem[];
};
export type CartProduct = {
id: string;
handle: string;
title: string;
featuredImage: Image;
};
export type CartItem = {
id: string | undefined;
quantity: number;
cost: {
totalAmount: Money;
};
merchandise: {
id: string;
title: string;
selectedOptions: {
name: string;
value: string;
}[];
product: CartProduct;
};
};
export type Collection = ShopifyCollection & {
path: string;
};
export type Image = {
url: string;
altText: string;
width: number;
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 = Omit<ShopifyProduct, 'variants' | 'images'> & {
variants: ProductVariant[];
images: Image[];
};
export type ProductOption = {
id: string;
name: string;
values: string[];
};
export type ProductVariant = {
id: string;
title: string;
availableForSale: boolean;
selectedOptions: {
name: string;
value: string;
}[];
price: Money;
};
export type SEO = {
title: string;
description: string;
};
export type ShopifyCart = {
id: string | undefined;
checkoutUrl: string;
cost: {
subtotalAmount: Money;
totalAmount: Money;
totalTaxAmount: Money;
};
lines: Connection<CartItem>;
totalQuantity: number;
};
export type ShopifyCollection = {
handle: string;
title: string;
description: string;
seo: SEO;
updatedAt: string;
};
export type ShopifyProduct = {
id: string;
handle: string;
availableForSale: boolean;
title: string;
description: string;
descriptionHtml: string;
options: ProductOption[];
priceRange: {
maxVariantPrice: Money;
minVariantPrice: Money;
};
variants: Connection<ProductVariant>;
featuredImage: Image;
images: Connection<Image>;
seo: SEO;
tags: string[];
updatedAt: string;
};
export type ShopifyCartOperation = {
data: {
cart: ShopifyCart;
};
variables: {
cartId: string;
};
};
export type ShopifyCreateCartOperation = {
data: { cartCreate: { cart: ShopifyCart } };
};
export type ShopifyAddToCartOperation = {
data: {
cartLinesAdd: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyRemoveFromCartOperation = {
data: {
cartLinesRemove: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lineIds: string[];
};
};
export type ShopifyUpdateCartOperation = {
data: {
cartLinesUpdate: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
id: string;
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyCollectionOperation = {
data: {
collection: ShopifyCollection;
};
variables: {
handle: string;
};
};
export type ShopifyCollectionProductsOperation = {
data: {
collection: {
products: Connection<ShopifyProduct>;
};
};
variables: {
handle: string;
reverse?: boolean;
sortKey?: string;
};
};
export type ShopifyCollectionsOperation = {
data: {
collections: Connection<ShopifyCollection>;
};
};
export type ShopifyMenuOperation = {
data: {
menu?: {
items: {
title: string;
url: string;
}[];
};
};
variables: {
handle: string;
};
};
export type ShopifyPageOperation = {
data: { pageByHandle: Page };
variables: { handle: string };
};
export type ShopifyPagesOperation = {
data: {
pages: Connection<Page>;
};
};
export type ShopifyProductOperation = {
data: { product: ShopifyProduct };
variables: {
handle: string;
};
};
export type ShopifyProductRecommendationsOperation = {
data: {
productRecommendations: ShopifyProduct[];
};
variables: {
productId: string;
};
};
export type ShopifyProductsOperation = {
data: {
products: Connection<ShopifyProduct>;
};
variables: {
query?: string;
reverse?: boolean;
sortKey?: string;
};
};

View File

@ -4,8 +4,13 @@ export default {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.shopify.com',
pathname: '/s/files/**'
hostname: 'labs.commerce.services',
pathname: '/product/**'
},
{
protocol: 'https',
hostname: 'labs.commerce.services',
pathname: '/pagewidget/**'
}
]
}

View File

@ -13,19 +13,23 @@
"test": "pnpm prettier:check"
},
"dependencies": {
"@geins/cms": "^0.3.6",
"@geins/core": "^0.3.6",
"@headlessui/react": "^2.1.2",
"@heroicons/react": "^2.1.5",
"clsx": "^2.1.1",
"geist": "^1.3.1",
"next": "15.0.0-rc.1",
"graphql-tag": "^2.12.6",
"next": "15.0.3",
"react": "19.0.0-rc-cd22717c-20241013",
"react-dom": "19.0.0-rc-cd22717c-20241013",
"sonner": "^1.5.0"
},
"devDependencies": {
"@geins/types": "^0.3.6",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.13",
"@types/node": "20.14.12",
"@types/node": "22.9.0",
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"autoprefixer": "^10.4.19",

991
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

164
repopack-output.txt Normal file
View File

@ -0,0 +1,164 @@
Help me finnish this reshaping of a product for a product card.
Here is the Types:
export type ProductType = {
id: string;
seo: {
title?: string;
description?: string;
};
currency: string;
slug: string;
handle: string;
stockTracking: boolean;
stockPurchasable: boolean;
stockLevel: number;
title: string;
name: string;
description: string;
price: string;
priceRange: {
maxVariantPrice: MoneyType;
minVariantPrice: MoneyType;
};
metaTitle: string;
metaDescription: string;
tags: string[];
options: ProductOptionType[];
variants: ProductVariantType[];
featuredImage: ProductImageType;
images: ProductImageType[];
updatedAt?: string;
availableForSale: boolean;
descriptionHtml?: string;
relations?: ProductRelationType[];
};
export type ProductOptionType = {
id: string;
name: string;
values: string[];
};
export type ProductOptionValueType = {
id: string;
name: string;
price: number;
};
export type ProductVariantType = {
id: string;
title: string;
availableForSale: boolean;
selectedOptions: {
name: string;
value: string;
}[];
price: MoneyType;
};
Here is what i got so far:
const reshapeProduct = (geinsProductData: any): ProductType => {
const rawProduct = geinsProductData;
const tags: string[] = [];
const relations: ProductRelationType[] = [];
if (rawProduct.primaryCategory) {
tags.push(rawProduct.primaryCategory.name);
relations.push({
type: ProductRelationTypeEnum.CATEGORY,
name: rawProduct.primaryCategory.name,
alias: rawProduct.primaryCategory.alias,
});
}
if (rawProduct.brand) {
tags.push(rawProduct.brand.name);
relations.push({
type: ProductRelationTypeEnum.BRAND,
name: rawProduct.brand.name,
alias: rawProduct.brand.alias,
});
}
if(rawProduct.categories) {
rawProduct.categories.forEach((category: any) => {
tags.push(category.name);
});
}
// remove duplicates
const uniqueTags = [...new Set(tags)];
tags.push(...uniqueTags);
// add descriptions from environment variables
const shortDescription = rawProduct.texts?.[SHORT_DESCRIPTION] || '';
const longDescription = rawProduct.texts?.[LONG_DESCRIPTION] || '';
// add images
const images = rawProduct.productImages?.map((image: any): ProductImageType => ({
caption: rawProduct.name,
altText: rawProduct.name,
src: `${IMAGE_URL}/product/1200f1500/${image.fileName}`,
url: `${IMAGE_URL}/product/1200f1500/${image.fileName}`,
height: 1600,
width: 2000,
}));
// add variations and options
const variations: ProductVariantType[] = reshapeProductVariations(rawProduct);
const options: ProductOptionType[] = reshapeProductOptions(rawProduct);
return {
id: rawProduct.productId,
seo: {
title: rawProduct.meta?.title,
description: rawProduct.meta?.description,
},
metaTitle: rawProduct.meta?.title || rawProduct.name,
metaDescription: rawProduct.meta?.description || shortDescription,
name: rawProduct.name,
title: rawProduct.name,
slug: rawProduct.alias,
handle: rawProduct.alias,
description: shortDescription,
descriptionHtml: rawProduct.texts?.text1 || '',
priceRange: {
minVariantPrice: {
amount: rawProduct.unitPrice?.sellingPriceIncVat,
currencyCode: CURRENCY_CODE,
},
maxVariantPrice: {
amount: rawProduct.unitPrice?.sellingPriceIncVat,
currencyCode: CURRENCY_CODE,
},
},
price: rawProduct.unitPrice.sellingPriceIncVat,
currency: CURRENCY_CODE,
stockTracking: !!rawProduct.totalStock,
stockPurchasable: rawProduct.totalStock?.inStock || false,
stockLevel: rawProduct.totalStock?.totalStock || 0,
availableForSale: true,
tags: tags || [],
options: [...options]|| [],
variants: variations || [],
featuredImage: images[0],
images: images,
relations: relations,
};
};
const reshapeProductVariations = (geinsProductData: any): ProductVariantType[] => {
return [];
};
const reshapeProductOptions = (geinsProductData: any): ProductOptionType[] => {
return [];
};
And here is the query to API in graphql

2927
yarn.lock Normal file

File diff suppressed because it is too large Load Diff