This commit is contained in:
Michal Miszczyszyn 2023-05-02 00:32:20 +02:00
parent 112d51303f
commit a8e49ee3f4
No known key found for this signature in database
35 changed files with 31558 additions and 710 deletions

View File

@ -1,5 +1,4 @@
TWITTER_CREATOR="@vercel"
TWITTER_SITE="https://nextjs.org/commerce"
SITE_NAME="Next.js Commerce"
SHOPIFY_STOREFRONT_ACCESS_TOKEN=
SHOPIFY_STORE_DOMAIN=
TWITTER_CREATOR="@getsaleor"
TWITTER_SITE="https://saleor.io/"
SITE_NAME="Next.js Commerce by Saleor"
SALEOR_INSTANCE_URL=https://vercel.saleor.cloud/graphql/

25
.graphqlrc.yml Normal file
View File

@ -0,0 +1,25 @@
overwrite: true
schema: '${SALEOR_INSTANCE_URL}'
documents: 'lib/**/*.graphql'
generates:
lib/saleor/generated/:
preset: 'client'
config:
defaultScalarType: 'unknown'
useTypeImports: true
dedupeFragments: true
skipTypename: true
scalars:
_Any: 'unknown'
Date: 'string'
DateTime: 'string'
Decimal: 'number'
GenericScalar: 'unknown'
JSON: 'unknown'
JSONString: 'string'
Metadata: 'Record<string, string>'
PositiveDecimal: 'number'
Upload: 'unknown'
UUID: 'string'
WeightScalar: 'number'
plugins: []

View File

@ -5,5 +5,6 @@
"source.fixAll": true,
"source.organizeImports": true,
"source.sortMembers": true
}
},
"editor.formatOnSave": true
}

View File

@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import Prose from 'components/prose';
import { getPage } from 'lib/shopify';
import { getPage } from 'lib/saleor';
import { notFound } from 'next/navigation';
export const runtime = 'edge';

View File

@ -1,7 +1,8 @@
// @ts-nocheck
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { addToCart, removeFromCart, updateCart } from 'lib/shopify';
import { addToCart, removeFromCart, updateCart } from 'lib/saleor';
import { isShopifyError } from 'lib/type-guards';
function formatErrorMessage(err: Error): string {

View File

@ -10,7 +10,7 @@ import { Gallery } from 'components/product/gallery';
import { VariantSelector } from 'components/product/variant-selector';
import Prose from 'components/prose';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct, getProductRecommendations } from 'lib/shopify';
import { getProduct, getProductRecommendations } from 'lib/saleor';
import { Image } from 'lib/types';
export const runtime = 'edge';

View File

@ -1,4 +1,4 @@
import { getCollection, getCollectionProducts } from 'lib/shopify';
import { getCollection, getCollectionProducts } from 'lib/saleor';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';

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/saleor';
export const runtime = 'edge';

View File

@ -1,4 +1,4 @@
import { getCollections, getPages, getProducts } from 'lib/shopify';
import { getCollections, getPages, getProducts } from 'lib/saleor';
import { MetadataRoute } from 'next';
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL

View File

@ -1,4 +1,4 @@
import { getCollectionProducts } from 'lib/shopify';
import { getCollectionProducts } from 'lib/saleor';
import Image from 'next/image';
import Link from 'next/link';

View File

@ -1,4 +1,4 @@
import { createCart, getCart } from 'lib/shopify';
import { createCart, getCart } from 'lib/saleor';
import { cookies } from 'next/headers';
import CartButton from './button';

View File

@ -1,5 +1,5 @@
import { GridTileImage } from 'components/grid/tile';
import { getCollectionProducts } from 'lib/shopify';
import { getCollectionProducts } from 'lib/saleor';
import type { Product } from 'lib/types';
import Link from 'next/link';

View File

@ -3,7 +3,7 @@ import Link from 'next/link';
import GitHubIcon from 'components/icons/github';
import LogoIcon from 'components/icons/logo';
import VercelIcon from 'components/icons/vercel';
import { getMenu } from 'lib/shopify';
import { getMenu } from 'lib/saleor';
import { Menu } from 'lib/types';
const { SITE_NAME } = process.env;

View File

@ -1,10 +1,9 @@
import Link from 'next/link';
import { Suspense } from 'react';
import Cart from 'components/cart';
import CartIcon from 'components/icons/cart';
import LogoIcon from 'components/icons/logo';
import { getMenu } from 'lib/shopify';
import { getMenu } from 'lib/saleor';
import { Menu } from 'lib/types';
import MobileMenu from './mobile-menu';
import Search from './search';
@ -44,8 +43,8 @@ export default async function Navbar() {
<div className="flex w-1/3 justify-end">
<Suspense fallback={<CartIcon className="h-6" />}>
{/* @ts-expect-error Server Component */}
<Cart />
{/* @ts- expect-error Server Component */}
{/* <Cart /> */}
</Suspense>
</div>
</nav>

View File

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

View File

@ -1,23 +1,40 @@
import { ProductOrderField } from './saleor/generated/graphql';
export type SortFilterItem = {
title: string;
slug: string | null;
sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE';
sortKey: ProductOrderField;
reverse: boolean;
};
export const defaultSort: SortFilterItem = {
title: 'Relevance',
slug: null,
sortKey: 'RELEVANCE',
sortKey: ProductOrderField.Rank,
reverse: false
};
export const sorting: SortFilterItem[] = [
defaultSort,
{ title: 'Trending', slug: 'trending-desc', sortKey: 'BEST_SELLING', reverse: false }, // asc
{ title: 'Latest arrivals', slug: 'latest-desc', sortKey: 'CREATED_AT', reverse: true },
{ title: 'Price: Low to high', slug: 'price-asc', sortKey: 'PRICE', reverse: false }, // asc
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
{ title: 'Trending', slug: 'trending-desc', sortKey: ProductOrderField.Rating, reverse: false }, // asc
{
title: 'Latest arrivals',
slug: 'latest-desc',
sortKey: ProductOrderField.PublishedAt,
reverse: true
},
{
title: 'Price: Low to high',
slug: 'price-asc',
sortKey: ProductOrderField.MinimalPrice,
reverse: false
}, // asc
{
title: 'Price: High to low',
slug: 'price-desc',
sortKey: ProductOrderField.MinimalPrice,
reverse: true
}
];
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';

View File

@ -2,8 +2,18 @@ fragment FeaturedProduct on Product {
id
slug
name
isAvailableForPurchase
description
seoTitle
seoDescription
pricing {
priceRange {
start {
gross {
currency
amount
}
}
stop {
gross {
currency
@ -17,4 +27,20 @@ fragment FeaturedProduct on Product {
type
alt
}
collections {
name
}
updatedAt
variants {
id
name
pricing {
price {
gross {
currency
amount
}
}
}
}
}

View File

@ -1,21 +0,0 @@
fragment Menu on Menu {
id
name
items {
...MenuItem
}
}
fragment MenuItem on MenuItem {
id
name
url
collection {
slug
}
children {
id
collection {
slug
}
}
}

View File

@ -0,0 +1,48 @@
import type { ResultOf, DocumentTypeDecoration } from '@graphql-typed-document-node/core';
export type FragmentType<TDocumentType extends DocumentTypeDecoration<any, any>> =
TDocumentType extends DocumentTypeDecoration<infer TType, any>
? TType extends { ' $fragmentName'?: infer TKey }
? TKey extends string
? { ' $fragmentRefs'?: { [key in TKey]: TType } }
: never
: never
: never;
// return non-nullable if `fragmentType` is non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>>
): TType;
// return nullable if `fragmentType` is nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null | undefined
): TType | null | undefined;
// return array of non-nullable if `fragmentType` is array of non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
): ReadonlyArray<TType>;
// return array of nullable if `fragmentType` is array of nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): ReadonlyArray<TType> | null | undefined;
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType:
| FragmentType<DocumentTypeDecoration<TType, any>>
| ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
| null
| undefined
): TType | ReadonlyArray<TType> | null | undefined {
return fragmentType as any;
}
export function makeFragmentData<
F extends DocumentTypeDecoration<any, any>,
FT extends ResultOf<F>
>(data: FT, _fragment: F): FragmentType<F> {
return data as FragmentType<F>;
}

118
lib/saleor/generated/gql.ts Normal file
View File

@ -0,0 +1,118 @@
/* eslint-disable */
import * as types from './graphql';
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
/**
* Map of all GraphQL operations in the project.
*
* This map has several performance disadvantages:
* 1. It is not tree-shakeable, so it will include all operations in the project.
* 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
* 3. It does not support dead code elimination, so it will add unused operations.
*
* Therefore it is highly recommended to use the babel or swc plugin for production.
*/
const documents = {
'fragment FeaturedProduct on Product {\n id\n slug\n name\n isAvailableForPurchase\n description\n seoTitle\n seoDescription\n pricing {\n priceRange {\n start {\n gross {\n currency\n amount\n }\n }\n stop {\n gross {\n currency\n amount\n }\n }\n }\n }\n media {\n url(size: 2160)\n type\n alt\n }\n collections {\n name\n }\n updatedAt\n variants {\n id\n name\n pricing {\n price {\n gross {\n currency\n amount\n }\n }\n }\n }\n}':
types.FeaturedProductFragmentDoc,
'query GetCollectionBySlug($slug: String!) {\n collection(channel: "default-channel", slug: $slug) {\n id\n name\n slug\n description\n seoTitle\n seoDescription\n }\n}':
types.GetCollectionBySlugDocument,
'query GetCollectionProductsBySlug($slug: String!) {\n collection(channel: "default-channel", slug: $slug) {\n products(first: 100) {\n edges {\n node {\n id\n slug\n name\n isAvailableForPurchase\n description\n seoTitle\n seoDescription\n pricing {\n priceRange {\n start {\n gross {\n currency\n amount\n }\n }\n stop {\n gross {\n currency\n amount\n }\n }\n }\n }\n media {\n url(size: 2160)\n type\n alt\n }\n collections {\n name\n }\n updatedAt\n variants {\n id\n name\n pricing {\n price {\n gross {\n currency\n amount\n }\n }\n }\n }\n }\n }\n }\n }\n}':
types.GetCollectionProductsBySlugDocument,
'query GetCollections {\n collections(channel: "default-channel", first: 100) {\n edges {\n node {\n id\n name\n slug\n description\n seoTitle\n seoDescription\n }\n }\n }\n}':
types.GetCollectionsDocument,
'query GetFeaturedProducts($first: Int!) {\n products(first: $first, channel: "default-channel") {\n edges {\n node {\n ...FeaturedProduct\n }\n }\n }\n}':
types.GetFeaturedProductsDocument,
'query GetMenuBySlug($slug: String!) {\n menu(slug: $slug, channel: "default-channel") {\n id\n slug\n name\n items {\n id\n name\n url\n collection {\n slug\n }\n children {\n id\n collection {\n slug\n }\n }\n }\n }\n}':
types.GetMenuBySlugDocument,
'query GetPageBySlug($slug: String!) {\n page(slug: $slug) {\n id\n title\n slug\n content\n seoTitle\n seoDescription\n created\n }\n}':
types.GetPageBySlugDocument,
'query GetProductBySlug($slug: String!) {\n product(slug: $slug) {\n id\n slug\n name\n isAvailableForPurchase\n description\n seoTitle\n seoDescription\n pricing {\n priceRange {\n start {\n gross {\n currency\n amount\n }\n }\n stop {\n gross {\n currency\n amount\n }\n }\n }\n }\n media {\n url(size: 2160)\n type\n alt\n }\n collections {\n name\n }\n updatedAt\n variants {\n id\n name\n pricing {\n price {\n gross {\n currency\n amount\n }\n }\n }\n }\n }\n}':
types.GetProductBySlugDocument,
'query SearchProducts($search: String!, $sortBy: ProductOrderField!, $sortDirection: OrderDirection!) {\n products(\n first: 100\n channel: "default-channel"\n sortBy: {field: $sortBy, direction: $sortDirection}\n filter: {search: $search}\n ) {\n edges {\n node {\n id\n slug\n name\n isAvailableForPurchase\n description\n seoTitle\n seoDescription\n pricing {\n priceRange {\n start {\n gross {\n currency\n amount\n }\n }\n stop {\n gross {\n currency\n amount\n }\n }\n }\n }\n media {\n url(size: 2160)\n type\n alt\n }\n collections {\n name\n }\n updatedAt\n variants {\n id\n name\n pricing {\n price {\n gross {\n currency\n amount\n }\n }\n }\n }\n }\n }\n }\n}':
types.SearchProductsDocument,
'query GetProducts {\n products(first: 10, channel: "default-channel") {\n edges {\n node {\n name\n }\n }\n }\n}':
types.GetProductsDocument
};
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*
*
* @example
* ```ts
* const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`);
* ```
*
* The query argument is unknown!
* Please regenerate the types.
*/
export function graphql(source: string): unknown;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: 'fragment FeaturedProduct on Product {\n id\n slug\n name\n isAvailableForPurchase\n description\n seoTitle\n seoDescription\n pricing {\n priceRange {\n start {\n gross {\n currency\n amount\n }\n }\n stop {\n gross {\n currency\n amount\n }\n }\n }\n }\n media {\n url(size: 2160)\n type\n alt\n }\n collections {\n name\n }\n updatedAt\n variants {\n id\n name\n pricing {\n price {\n gross {\n currency\n amount\n }\n }\n }\n }\n}'
): (typeof documents)['fragment FeaturedProduct on Product {\n id\n slug\n name\n isAvailableForPurchase\n description\n seoTitle\n seoDescription\n pricing {\n priceRange {\n start {\n gross {\n currency\n amount\n }\n }\n stop {\n gross {\n currency\n amount\n }\n }\n }\n }\n media {\n url(size: 2160)\n type\n alt\n }\n collections {\n name\n }\n updatedAt\n variants {\n id\n name\n pricing {\n price {\n gross {\n currency\n amount\n }\n }\n }\n }\n}'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: 'query GetCollectionBySlug($slug: String!) {\n collection(channel: "default-channel", slug: $slug) {\n id\n name\n slug\n description\n seoTitle\n seoDescription\n }\n}'
): (typeof documents)['query GetCollectionBySlug($slug: String!) {\n collection(channel: "default-channel", slug: $slug) {\n id\n name\n slug\n description\n seoTitle\n seoDescription\n }\n}'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: 'query GetCollectionProductsBySlug($slug: String!) {\n collection(channel: "default-channel", slug: $slug) {\n products(first: 100) {\n edges {\n node {\n id\n slug\n name\n isAvailableForPurchase\n description\n seoTitle\n seoDescription\n pricing {\n priceRange {\n start {\n gross {\n currency\n amount\n }\n }\n stop {\n gross {\n currency\n amount\n }\n }\n }\n }\n media {\n url(size: 2160)\n type\n alt\n }\n collections {\n name\n }\n updatedAt\n variants {\n id\n name\n pricing {\n price {\n gross {\n currency\n amount\n }\n }\n }\n }\n }\n }\n }\n }\n}'
): (typeof documents)['query GetCollectionProductsBySlug($slug: String!) {\n collection(channel: "default-channel", slug: $slug) {\n products(first: 100) {\n edges {\n node {\n id\n slug\n name\n isAvailableForPurchase\n description\n seoTitle\n seoDescription\n pricing {\n priceRange {\n start {\n gross {\n currency\n amount\n }\n }\n stop {\n gross {\n currency\n amount\n }\n }\n }\n }\n media {\n url(size: 2160)\n type\n alt\n }\n collections {\n name\n }\n updatedAt\n variants {\n id\n name\n pricing {\n price {\n gross {\n currency\n amount\n }\n }\n }\n }\n }\n }\n }\n }\n}'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: 'query GetCollections {\n collections(channel: "default-channel", first: 100) {\n edges {\n node {\n id\n name\n slug\n description\n seoTitle\n seoDescription\n }\n }\n }\n}'
): (typeof documents)['query GetCollections {\n collections(channel: "default-channel", first: 100) {\n edges {\n node {\n id\n name\n slug\n description\n seoTitle\n seoDescription\n }\n }\n }\n}'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: 'query GetFeaturedProducts($first: Int!) {\n products(first: $first, channel: "default-channel") {\n edges {\n node {\n ...FeaturedProduct\n }\n }\n }\n}'
): (typeof documents)['query GetFeaturedProducts($first: Int!) {\n products(first: $first, channel: "default-channel") {\n edges {\n node {\n ...FeaturedProduct\n }\n }\n }\n}'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: 'query GetMenuBySlug($slug: String!) {\n menu(slug: $slug, channel: "default-channel") {\n id\n slug\n name\n items {\n id\n name\n url\n collection {\n slug\n }\n children {\n id\n collection {\n slug\n }\n }\n }\n }\n}'
): (typeof documents)['query GetMenuBySlug($slug: String!) {\n menu(slug: $slug, channel: "default-channel") {\n id\n slug\n name\n items {\n id\n name\n url\n collection {\n slug\n }\n children {\n id\n collection {\n slug\n }\n }\n }\n }\n}'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: 'query GetPageBySlug($slug: String!) {\n page(slug: $slug) {\n id\n title\n slug\n content\n seoTitle\n seoDescription\n created\n }\n}'
): (typeof documents)['query GetPageBySlug($slug: String!) {\n page(slug: $slug) {\n id\n title\n slug\n content\n seoTitle\n seoDescription\n created\n }\n}'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: 'query GetProductBySlug($slug: String!) {\n product(slug: $slug) {\n id\n slug\n name\n isAvailableForPurchase\n description\n seoTitle\n seoDescription\n pricing {\n priceRange {\n start {\n gross {\n currency\n amount\n }\n }\n stop {\n gross {\n currency\n amount\n }\n }\n }\n }\n media {\n url(size: 2160)\n type\n alt\n }\n collections {\n name\n }\n updatedAt\n variants {\n id\n name\n pricing {\n price {\n gross {\n currency\n amount\n }\n }\n }\n }\n }\n}'
): (typeof documents)['query GetProductBySlug($slug: String!) {\n product(slug: $slug) {\n id\n slug\n name\n isAvailableForPurchase\n description\n seoTitle\n seoDescription\n pricing {\n priceRange {\n start {\n gross {\n currency\n amount\n }\n }\n stop {\n gross {\n currency\n amount\n }\n }\n }\n }\n media {\n url(size: 2160)\n type\n alt\n }\n collections {\n name\n }\n updatedAt\n variants {\n id\n name\n pricing {\n price {\n gross {\n currency\n amount\n }\n }\n }\n }\n }\n}'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: 'query SearchProducts($search: String!, $sortBy: ProductOrderField!, $sortDirection: OrderDirection!) {\n products(\n first: 100\n channel: "default-channel"\n sortBy: {field: $sortBy, direction: $sortDirection}\n filter: {search: $search}\n ) {\n edges {\n node {\n id\n slug\n name\n isAvailableForPurchase\n description\n seoTitle\n seoDescription\n pricing {\n priceRange {\n start {\n gross {\n currency\n amount\n }\n }\n stop {\n gross {\n currency\n amount\n }\n }\n }\n }\n media {\n url(size: 2160)\n type\n alt\n }\n collections {\n name\n }\n updatedAt\n variants {\n id\n name\n pricing {\n price {\n gross {\n currency\n amount\n }\n }\n }\n }\n }\n }\n }\n}'
): (typeof documents)['query SearchProducts($search: String!, $sortBy: ProductOrderField!, $sortDirection: OrderDirection!) {\n products(\n first: 100\n channel: "default-channel"\n sortBy: {field: $sortBy, direction: $sortDirection}\n filter: {search: $search}\n ) {\n edges {\n node {\n id\n slug\n name\n isAvailableForPurchase\n description\n seoTitle\n seoDescription\n pricing {\n priceRange {\n start {\n gross {\n currency\n amount\n }\n }\n stop {\n gross {\n currency\n amount\n }\n }\n }\n }\n media {\n url(size: 2160)\n type\n alt\n }\n collections {\n name\n }\n updatedAt\n variants {\n id\n name\n pricing {\n price {\n gross {\n currency\n amount\n }\n }\n }\n }\n }\n }\n }\n}'];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: 'query GetProducts {\n products(first: 10, channel: "default-channel") {\n edges {\n node {\n name\n }\n }\n }\n}'
): (typeof documents)['query GetProducts {\n products(first: 10, channel: "default-channel") {\n edges {\n node {\n name\n }\n }\n }\n}'];
export function graphql(source: string) {
return (documents as any)[source] ?? {};
}
export type DocumentType<TDocumentNode extends DocumentNode<any, any>> =
TDocumentNode extends DocumentNode<infer TType, any> ? TType : never;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
export * from './fragment-masking';
export * from './gql';

378
lib/saleor/index.ts Normal file
View File

@ -0,0 +1,378 @@
import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
import { print } from 'graphql';
import { Collection, Menu, Page, Product } from 'lib/types';
import {
GetCollectionBySlugDocument,
GetCollectionProductsBySlugDocument,
GetCollectionsDocument,
GetMenuBySlugDocument,
GetPageBySlugDocument,
GetProductBySlugDocument,
OrderDirection,
ProductOrderField,
SearchProductsDocument
} from './generated/graphql';
import { invariant } from './utils';
const endpoint = process.env.SALEOR_INSTANCE_URL;
invariant(endpoint, `Missing SALEOR_INSTANCE_URL!`);
type GraphQlError = {
message: string;
};
type GraphQlErrorRespone<T> = { data: T } | { errors: readonly GraphQlError[] };
export async function saleorFetch<Result, Variables>({
query,
variables,
headers,
cache = 'force-cache'
}: {
query: TypedDocumentNode<Result, Variables>;
variables: Variables;
headers?: HeadersInit;
cache?: RequestCache;
}): Promise<Result> {
invariant(endpoint, `Missing SALEOR_INSTANCE_URL!`);
const result = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers
},
body: JSON.stringify({
query: print(query),
...(variables && { variables })
}),
cache,
next: { revalidate: 900 } // 15 minutes
});
const body = (await result.json()) as GraphQlErrorRespone<Result>;
if ('errors' in body) {
throw body.errors[0];
}
return body.data;
}
export async function getCollections(): Promise<Collection[]> {
const saleorCollections = await saleorFetch({
query: GetCollectionsDocument,
variables: {}
});
return (
saleorCollections.collections?.edges.map((edge) => {
return {
handle: edge.node.slug,
title: edge.node.name,
description: edge.node.description as string,
seo: {
title: edge.node.seoTitle || edge.node.name,
description: edge.node.seoDescription || ''
},
updatedAt: '', // @todo ?
path: `/search/${edge.node.slug}`
};
}) ?? []
);
}
export async function getPage(handle: string): Promise<Page> {
const saleorPage = await saleorFetch({
query: GetPageBySlugDocument,
variables: {
slug: handle
}
});
if (!saleorPage.page) {
throw new Error(`Page not found: ${handle}`);
}
return {
id: saleorPage.page.id,
title: saleorPage.page.title,
handle: saleorPage.page.slug,
body: saleorPage.page.content || '',
bodySummary: saleorPage.page.seoDescription || '',
seo: {
title: saleorPage.page.seoTitle || saleorPage.page.title,
description: saleorPage.page.seoDescription || ''
},
createdAt: saleorPage.page.created,
updatedAt: saleorPage.page.created
};
}
export async function getProduct(handle: string): Promise<Product | undefined> {
const saleorProduct = await saleorFetch({
query: GetProductBySlugDocument,
variables: {
slug: handle
}
});
if (!saleorProduct.product) {
throw new Error(`Product not found: ${handle}`);
}
const images =
saleorProduct.product.media
?.filter((media) => media.type === 'IMAGE')
.map((media) => {
return {
url: media.url,
altText: media.alt,
width: 2048,
height: 2048
};
}) || [];
return {
id: saleorProduct.product.id,
handle: saleorProduct.product.slug,
availableForSale: saleorProduct.product.isAvailableForPurchase || true,
title: saleorProduct.product.name,
description: saleorProduct.product.description || '',
descriptionHtml: saleorProduct.product.description || '', // @todo
options: [], // @todo
priceRange: {
maxVariantPrice: {
amount: saleorProduct.product.pricing?.priceRange?.stop?.gross.amount.toString() || '0',
currencyCode: saleorProduct.product.pricing?.priceRange?.stop?.gross.currency || ''
},
minVariantPrice: {
amount: saleorProduct.product.pricing?.priceRange?.start?.gross.amount.toString() || '0',
currencyCode: saleorProduct.product.pricing?.priceRange?.start?.gross.currency || ''
}
},
variants:
saleorProduct.product.variants?.map((variant) => {
return {
id: variant.id,
title: variant.name,
availableForSale: saleorProduct.product?.isAvailableForPurchase || true,
selectedOptions: [], // @todo
price: {
amount: variant.pricing?.price?.gross.amount.toString() || '0',
currencyCode: variant.pricing?.price?.gross.currency || ''
}
};
}) || [],
images: images,
featuredImage: images[0]!,
seo: {
title: saleorProduct.product.seoTitle || saleorProduct.product.name,
description: saleorProduct.product.seoDescription || ''
},
tags: saleorProduct.product.collections?.map((c) => c.name) || [],
updatedAt: saleorProduct.product.updatedAt
};
}
export async function getCollection(handle: string): Promise<Collection | undefined> {
const saleorCollection = await saleorFetch({
query: GetCollectionBySlugDocument,
variables: {
slug: handle
}
});
if (!saleorCollection.collection) {
throw new Error(`Collection not found: ${handle}`);
}
return {
handle: saleorCollection.collection.slug,
title: saleorCollection.collection.name,
description: saleorCollection.collection.description as string,
seo: {
title: saleorCollection.collection.seoTitle || saleorCollection.collection.name,
description: saleorCollection.collection.seoDescription || ''
},
updatedAt: '', // @todo ?
path: `/search/${saleorCollection.collection.slug}`
};
}
export async function getCollectionProducts(handle: string): Promise<Product[]> {
const handleToSlug: Record<string, string> = {
'hidden-homepage-featured-items': 'featured',
'hidden-homepage-carousel': 'all-products'
};
const saleorCollectionProducts = await saleorFetch({
query: GetCollectionProductsBySlugDocument,
variables: {
slug: handleToSlug[handle] || handle
}
});
if (!saleorCollectionProducts.collection) {
throw new Error(`Collection not found: ${handle}`);
}
return (
saleorCollectionProducts.collection.products?.edges.map((product) => {
const images =
product.node.media
?.filter((media) => media.type === 'IMAGE')
.map((media) => {
return {
url: media.url,
altText: media.alt,
width: 2048,
height: 2048
};
}) || [];
return {
id: product.node.id,
handle: product.node.slug,
availableForSale: product.node.isAvailableForPurchase || true,
title: product.node.name,
description: product.node.description || '',
descriptionHtml: product.node.description || '', // @todo
options: [], // @todo
priceRange: {
maxVariantPrice: {
amount: product.node.pricing?.priceRange?.stop?.gross.amount.toString() || '0',
currencyCode: product.node.pricing?.priceRange?.stop?.gross.currency || ''
},
minVariantPrice: {
amount: product.node.pricing?.priceRange?.start?.gross.amount.toString() || '0',
currencyCode: product.node.pricing?.priceRange?.start?.gross.currency || ''
}
},
variants:
product.node.variants?.map((variant) => {
return {
id: variant.id,
title: variant.name,
availableForSale: product.node?.isAvailableForPurchase || true,
selectedOptions: [], // @todo
price: {
amount: variant.pricing?.price?.gross.amount.toString() || '0',
currencyCode: variant.pricing?.price?.gross.currency || ''
}
};
}) || [],
images: images,
featuredImage: images[0]!,
seo: {
title: product.node.seoTitle || product.node.name,
description: product.node.seoDescription || ''
},
tags: product.node.collections?.map((c) => c.name) || [],
updatedAt: product.node.updatedAt
};
}) || []
);
}
export async function getMenu(handle: string): Promise<Menu[]> {
const handleToSlug: Record<string, string> = {
'next-js-frontend-footer-menu': 'footer',
'next-js-frontend-header-menu': 'navbar'
};
const saleorMenu = await saleorFetch({
query: GetMenuBySlugDocument,
variables: {
slug: handleToSlug[handle] || handle
}
});
if (!saleorMenu.menu) {
throw new Error(`Menu not found: ${handle}`);
}
return (
saleorMenu.menu.items?.map((item) => {
return {
path: item.url || '', // @todo handle manus without url
title: item.name
};
}) || []
);
}
export async function getProducts({
query,
reverse,
sortKey
}: {
query?: string;
reverse?: boolean;
sortKey?: ProductOrderField;
}): Promise<Product[]> {
const saleorProducts = await saleorFetch({
query: SearchProductsDocument,
variables: {
search: query || '',
sortBy: sortKey || ProductOrderField.Rank,
sortDirection: reverse ? OrderDirection.Desc : OrderDirection.Asc
}
});
return (
saleorProducts.products?.edges.map((product) => {
const images =
product.node.media
?.filter((media) => media.type === 'IMAGE')
.map((media) => {
return {
url: media.url,
altText: media.alt,
width: 2048,
height: 2048
};
}) || [];
return {
id: product.node.id,
handle: product.node.slug,
availableForSale: product.node.isAvailableForPurchase || true,
title: product.node.name,
description: product.node.description || '',
descriptionHtml: product.node.description || '', // @todo
options: [], // @todo
priceRange: {
maxVariantPrice: {
amount: product.node.pricing?.priceRange?.stop?.gross.amount.toString() || '0',
currencyCode: product.node.pricing?.priceRange?.stop?.gross.currency || ''
},
minVariantPrice: {
amount: product.node.pricing?.priceRange?.start?.gross.amount.toString() || '0',
currencyCode: product.node.pricing?.priceRange?.start?.gross.currency || ''
}
},
variants:
product.node.variants?.map((variant) => {
return {
id: variant.id,
title: variant.name,
availableForSale: product.node?.isAvailableForPurchase || true,
selectedOptions: [], // @todo
price: {
amount: variant.pricing?.price?.gross.amount.toString() || '0',
currencyCode: variant.pricing?.price?.gross.currency || ''
}
};
}) || [],
images: images,
featuredImage: images[0]!,
seo: {
title: product.node.seoTitle || product.node.name,
description: product.node.seoDescription || ''
},
tags: product.node.collections?.map((c) => c.name) || [],
updatedAt: product.node.updatedAt
};
}) || []
);
}

View File

@ -0,0 +1,10 @@
query GetCollectionBySlug($slug: String!) {
collection(channel: "default-channel", slug: $slug) {
id
name
slug
description
seoTitle
seoDescription
}
}

View File

@ -0,0 +1,54 @@
query GetCollectionProductsBySlug($slug: String!) {
collection(channel: "default-channel", slug: $slug) {
products(first: 100) {
edges {
node {
id
slug
name
isAvailableForPurchase
description
seoTitle
seoDescription
pricing {
priceRange {
start {
gross {
currency
amount
}
}
stop {
gross {
currency
amount
}
}
}
}
media {
url(size: 2160)
type
alt
}
collections {
name
}
updatedAt
variants {
id
name
pricing {
price {
gross {
currency
amount
}
}
}
}
}
}
}
}
}

View File

@ -5,6 +5,9 @@ query GetCollections {
id
name
slug
description
seoTitle
seoDescription
}
}
}

View File

@ -1,5 +0,0 @@
query GetMenu($name: String!) {
menu(name: $name, channel: "default-channel") {
...Menu
}
}

View File

@ -0,0 +1,21 @@
query GetMenuBySlug($slug: String!) {
menu(slug: $slug, channel: "default-channel") {
id
slug
name
items {
id
name
url
collection {
slug
}
children {
id
collection {
slug
}
}
}
}
}

View File

@ -0,0 +1,11 @@
query GetPageBySlug($slug: String!) {
page(slug: $slug) {
id
title
slug
content
seoTitle
seoDescription
created
}
}

View File

@ -0,0 +1,48 @@
query GetProductBySlug($slug: String!) {
product(slug: $slug) {
id
slug
name
isAvailableForPurchase
description
seoTitle
seoDescription
pricing {
priceRange {
start {
gross {
currency
amount
}
}
stop {
gross {
currency
amount
}
}
}
}
media {
url(size: 2160)
type
alt
}
collections {
name
}
updatedAt
variants {
id
name
pricing {
price {
gross {
currency
amount
}
}
}
}
}
}

View File

@ -11,7 +11,50 @@ query SearchProducts(
) {
edges {
node {
...FeaturedProduct
id
slug
name
isAvailableForPurchase
description
seoTitle
seoDescription
pricing {
priceRange {
start {
gross {
currency
amount
}
}
stop {
gross {
currency
amount
}
}
}
}
media {
url(size: 2160)
type
alt
}
collections {
name
}
updatedAt
variants {
id
name
pricing {
price {
gross {
currency
amount
}
}
}
}
}
}
}

5
lib/saleor/utils.ts Normal file
View File

@ -0,0 +1,5 @@
export function invariant<T>(val: T | null | undefined, message: string): asserts val is T {
if (val === undefined || val === null) {
throw new Error(message);
}
}

View File

@ -12,8 +12,7 @@ module.exports = {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.shopify.com',
pathname: '/s/files/**'
hostname: 'vercel.saleor.cloud'
}
]
}

View File

@ -13,7 +13,8 @@
"prettier": "prettier --write --ignore-unknown .",
"prettier:check": "prettier --check --ignore-unknown .",
"test": "pnpm lint && pnpm prettier:check",
"test:e2e": "playwright test"
"test:e2e": "playwright test",
"codegen": "graphql-codegen -r dotenv/config --config .graphqlrc.yml"
},
"git": {
"pre-commit": "lint-staged"
@ -22,10 +23,12 @@
"*": "prettier --write --ignore-unknown"
},
"dependencies": {
"@graphql-typed-document-node/core": "3.2.0",
"@headlessui/react": "^1.7.10",
"@vercel/og": "^0.1.0",
"clsx": "^1.2.1",
"framer-motion": "^8.4.0",
"graphql": "16.6.0",
"is-empty-iterable": "^3.0.0",
"next": "13.3.1",
"react": "18.2.0",
@ -33,6 +36,8 @@
"react-dom": "18.2.0"
},
"devDependencies": {
"@graphql-codegen/cli": "3.3.1",
"@graphql-codegen/client-preset": "3.0.1",
"@playwright/test": "^1.31.2",
"@tailwindcss/typography": "^0.5.9",
"@types/node": "18.13.0",
@ -40,6 +45,7 @@
"@types/react-dom": "18.0.10",
"@vercel/git-hooks": "^1.0.0",
"autoprefixer": "^10.4.13",
"dotenv": "16.0.3",
"eslint": "^8.35.0",
"eslint-config-next": "^13.3.1",
"eslint-config-prettier": "^8.6.0",

5272
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff