chore: transfer repo
This commit is contained in:
31
lib/constants.ts
Normal file
31
lib/constants.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type SortFilterItem = {
|
||||
title: string;
|
||||
slug: string | null;
|
||||
sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE';
|
||||
reverse: boolean;
|
||||
};
|
||||
|
||||
export const defaultSort: SortFilterItem = {
|
||||
title: 'Relevance',
|
||||
slug: null,
|
||||
sortKey: 'RELEVANCE',
|
||||
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 }
|
||||
];
|
||||
|
||||
export const TAGS = {
|
||||
collections: 'collections',
|
||||
products: 'products',
|
||||
cart: 'cart'
|
||||
};
|
||||
|
||||
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
||||
export const DEFAULT_OPTION = 'Default Title';
|
||||
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2025-01/graphql.json';
|
||||
65
lib/hooks/useTranslation.ts
Normal file
65
lib/hooks/useTranslation.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import enTranslations from '../../messages/en.json';
|
||||
import hrTranslations from '../../messages/hr.json';
|
||||
|
||||
type NestedMessages = {
|
||||
[key: string]: string | NestedMessages;
|
||||
};
|
||||
|
||||
// Dobavi vrijednost po path-u (npr. 'common.button.submit')
|
||||
function getNestedTranslation(obj: NestedMessages, path: string): string {
|
||||
const keys = path.split('.');
|
||||
let current: any = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (current[key] === undefined) {
|
||||
console.warn(`Translation key not found: ${path}`);
|
||||
return path;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
if (typeof current !== 'string') {
|
||||
console.warn(`Translation value for ${path} is not a string`);
|
||||
return path;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Koristimo useState za praćenje locale-a kako bi izbjegli hydration error
|
||||
const [locale, setLocale] = useState('hr'); // Default na 'hr' za prvo renderiranje
|
||||
|
||||
// Detekcija trenutnog jezika iz URL-a - ovo će biti u useEffect da bi radilo samo na klijentu
|
||||
useEffect(() => {
|
||||
const detectedLocale = pathname.startsWith('/en') ? 'en' : 'hr';
|
||||
setLocale(detectedLocale);
|
||||
}, [pathname]);
|
||||
|
||||
// Odabir odgovarajućeg prijevoda
|
||||
const translations = useMemo(() => {
|
||||
return locale === 'en' ? enTranslations : hrTranslations;
|
||||
}, [locale]);
|
||||
|
||||
// Funkcija za dohvaćanje prevedenog stringa
|
||||
const t = (key: string, variables?: Record<string, string | number>) => {
|
||||
let text = getNestedTranslation(translations as unknown as NestedMessages, key);
|
||||
|
||||
// Replace variables if they exist
|
||||
if (variables) {
|
||||
Object.entries(variables).forEach(([varName, value]) => {
|
||||
text = text.replace(new RegExp(`{${varName}}`, 'g'), String(value));
|
||||
});
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
return { t, locale };
|
||||
}
|
||||
53
lib/i18n/pathnames.ts
Normal file
53
lib/i18n/pathnames.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Ova datoteka definira putanje za različite lokalizacije
|
||||
// Format: { path: { locale: localized_path } }
|
||||
|
||||
export const pathnames = {
|
||||
// Za root putanju (početnu stranicu)
|
||||
'/': {
|
||||
en: '/en',
|
||||
hr: '/',
|
||||
},
|
||||
// Za build-box stranicu i njene podstranice
|
||||
'/build-box': {
|
||||
en: '/en/build-box',
|
||||
hr: '/build-box',
|
||||
},
|
||||
'/build-box/customize': {
|
||||
en: '/en/build-box/customize',
|
||||
hr: '/build-box/customize',
|
||||
},
|
||||
// Za proizvode
|
||||
'/products': {
|
||||
en: '/en/products',
|
||||
hr: '/products',
|
||||
},
|
||||
// Za search
|
||||
'/search': {
|
||||
en: '/en/search',
|
||||
hr: '/search',
|
||||
},
|
||||
'/search/:collection': {
|
||||
en: '/en/search/:collection',
|
||||
hr: '/search/:collection',
|
||||
},
|
||||
// Za about stranicu
|
||||
'/about': {
|
||||
en: '/en/about',
|
||||
hr: '/about',
|
||||
},
|
||||
// Za cart
|
||||
'/cart': {
|
||||
en: '/en/cart',
|
||||
hr: '/cart',
|
||||
},
|
||||
// Za proizvod
|
||||
'/product/:product': {
|
||||
en: '/en/product/:product',
|
||||
hr: '/product/:product',
|
||||
},
|
||||
// Za dinamičke stranice
|
||||
'/:page': {
|
||||
en: '/en/:page',
|
||||
hr: '/:page',
|
||||
},
|
||||
};
|
||||
6
lib/redux/hooks.ts
Normal file
6
lib/redux/hooks.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||
import type { AppDispatch, RootState } from './store';
|
||||
|
||||
// Use throughout your app instead of plain `useDispatch` and `useSelector`
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
15
lib/redux/provider.tsx
Normal file
15
lib/redux/provider.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { initializeState } from './slices/boxSlice';
|
||||
import { store } from './store';
|
||||
|
||||
export function ReduxProvider({ children }: { children: React.ReactNode }) {
|
||||
// Inicijaliziraj stanje iz localStorage nakon što se komponenta montira
|
||||
useEffect(() => {
|
||||
store.dispatch(initializeState());
|
||||
}, []);
|
||||
|
||||
return <Provider store={store}>{children}</Provider>;
|
||||
}
|
||||
234
lib/redux/slices/boxSlice.ts
Normal file
234
lib/redux/slices/boxSlice.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { RootState } from '../store';
|
||||
|
||||
// Define the type for a box item
|
||||
export interface BoxItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
image: string;
|
||||
quantity: number;
|
||||
variantId?: string;
|
||||
color?: string;
|
||||
compositeKey?: string;
|
||||
}
|
||||
|
||||
// Define the type for the box state
|
||||
interface BoxState {
|
||||
items: BoxItem[];
|
||||
total: number;
|
||||
editingBoxGroupId?: string;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
|
||||
// Helper function to load state from localStorage
|
||||
const loadState = (): BoxState | undefined => {
|
||||
try {
|
||||
// Check if we're in a browser environment
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const serializedState = localStorage.getItem('boxState');
|
||||
if (serializedState === null) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(serializedState);
|
||||
} catch (err) {
|
||||
console.error('Error loading box state from localStorage', err);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to save state to localStorage
|
||||
const saveState = (state: BoxState) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const serializedState = JSON.stringify(state);
|
||||
localStorage.setItem('boxState', serializedState);
|
||||
} catch (err) {
|
||||
console.error('Error saving box state to localStorage', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial state without loading from localStorage to avoid hydration mismatch
|
||||
const initialState: BoxState = {
|
||||
items: [],
|
||||
total: 0,
|
||||
editingBoxGroupId: undefined,
|
||||
isInitialized: false
|
||||
};
|
||||
|
||||
// Helper to generate a unique key for an item based on ID and color
|
||||
const generateItemKey = (item: { id: string; color?: string }): string => {
|
||||
return `${item.id}-${item.color || 'no-color'}`;
|
||||
};
|
||||
|
||||
export const boxSlice = createSlice({
|
||||
name: 'box',
|
||||
initialState,
|
||||
reducers: {
|
||||
// Initialize state from localStorage (to be called in useEffect)
|
||||
initializeState: (state) => {
|
||||
// Skip if already initialized
|
||||
if (state.isInitialized) return;
|
||||
|
||||
try {
|
||||
const savedState = loadState();
|
||||
if (savedState) {
|
||||
state.items = savedState.items || [];
|
||||
state.total = savedState.total || 0;
|
||||
state.editingBoxGroupId = savedState.editingBoxGroupId;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing state from localStorage', error);
|
||||
}
|
||||
|
||||
state.isInitialized = true;
|
||||
},
|
||||
|
||||
// Add an item to the box
|
||||
addToBox: (state, action: PayloadAction<BoxItem>) => {
|
||||
const newItem = { ...action.payload };
|
||||
|
||||
// Check if the variantId should be used as a color
|
||||
if (newItem.variantId && !newItem.color) {
|
||||
newItem.color = newItem.variantId;
|
||||
}
|
||||
|
||||
// Generate composite key
|
||||
const compositeKey = generateItemKey(newItem);
|
||||
newItem.compositeKey = compositeKey;
|
||||
|
||||
const existingItemIndex = state.items.findIndex(
|
||||
item => generateItemKey(item) === compositeKey
|
||||
);
|
||||
|
||||
if (existingItemIndex >= 0 && state.items[existingItemIndex]) {
|
||||
// If item with the same key exists, increment quantity
|
||||
const quantity = newItem.quantity || 1;
|
||||
state.items[existingItemIndex].quantity += quantity;
|
||||
} else {
|
||||
// Otherwise add the new item (or item with a different color)
|
||||
state.items.push({
|
||||
...newItem,
|
||||
quantity: newItem.quantity || 1,
|
||||
compositeKey
|
||||
});
|
||||
}
|
||||
|
||||
// Recalculate total
|
||||
state.total = state.items.reduce(
|
||||
(sum, item) => sum + item.price * item.quantity, 0
|
||||
);
|
||||
|
||||
// Save to localStorage
|
||||
saveState(state);
|
||||
},
|
||||
|
||||
// Update item quantity
|
||||
updateQuantity: (state, action: PayloadAction<{ id: string; color?: string; quantity: number }>) => {
|
||||
const { id, color, quantity } = action.payload;
|
||||
const itemKey = generateItemKey({ id, color });
|
||||
const item = state.items.find(item => generateItemKey(item) === itemKey);
|
||||
|
||||
if (item) {
|
||||
item.quantity = quantity;
|
||||
|
||||
// Recalculate total
|
||||
state.total = state.items.reduce(
|
||||
(sum, item) => sum + item.price * item.quantity, 0
|
||||
);
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
saveState(state);
|
||||
},
|
||||
|
||||
// Remove an item from the box
|
||||
removeFromBox: (state, action: PayloadAction<{ id: string; color?: string }>) => {
|
||||
const { id, color } = action.payload;
|
||||
const itemKeyToRemove = generateItemKey({ id, color });
|
||||
state.items = state.items.filter(item => generateItemKey(item) !== itemKeyToRemove);
|
||||
|
||||
// Recalculate total
|
||||
state.total = state.items.reduce(
|
||||
(sum, item) => sum + item.price * item.quantity, 0
|
||||
);
|
||||
|
||||
// Save to localStorage
|
||||
saveState(state);
|
||||
},
|
||||
|
||||
// Clear the entire box
|
||||
clearBox: (state) => {
|
||||
state.items = [];
|
||||
state.total = 0;
|
||||
state.editingBoxGroupId = undefined;
|
||||
|
||||
// Save to localStorage
|
||||
saveState(state);
|
||||
},
|
||||
|
||||
// Load a box for editing
|
||||
loadBoxForEditing: (state) => {
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const boxStateString = localStorage.getItem('lastBoxState');
|
||||
if (!boxStateString) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boxState = JSON.parse(boxStateString);
|
||||
|
||||
// Spremi originalni boxGroupId ako postoji
|
||||
if (boxState && boxState.originalBoxGroupId) {
|
||||
state.editingBoxGroupId = boxState.originalBoxGroupId;
|
||||
}
|
||||
|
||||
if (boxState && boxState.boxItems && boxState.productItems) {
|
||||
// Reset current state
|
||||
state.items = [];
|
||||
|
||||
// Add box items
|
||||
boxState.boxItems.forEach((item: BoxItem) => {
|
||||
state.items.push(item);
|
||||
});
|
||||
|
||||
// Add product items
|
||||
boxState.productItems.forEach((item: BoxItem) => {
|
||||
state.items.push(item);
|
||||
});
|
||||
|
||||
// Recalculate total
|
||||
state.total = state.items.reduce(
|
||||
(sum, item) => sum + item.price * item.quantity, 0
|
||||
);
|
||||
|
||||
// Save to localStorage
|
||||
saveState(state);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading box for editing', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Export actions
|
||||
export const { initializeState, addToBox, updateQuantity, removeFromBox, clearBox, loadBoxForEditing } = boxSlice.actions;
|
||||
|
||||
// Selectors
|
||||
export const selectBoxItems = (state: RootState) => state.box.items;
|
||||
export const selectBoxTotal = (state: RootState) => state.box.total;
|
||||
export const selectBoxItemsCount = (state: RootState) =>
|
||||
state.box.items.reduce((total, item) => total + item.quantity, 0);
|
||||
export const selectEditingBoxGroupId = (state: RootState) => state.box.editingBoxGroupId;
|
||||
|
||||
export default boxSlice.reducer;
|
||||
11
lib/redux/store.ts
Normal file
11
lib/redux/store.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import boxReducer from './slices/boxSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
box: boxReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
57
lib/shopify/fragments/cart.ts
Normal file
57
lib/shopify/fragments/cart.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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
|
||||
attributes {
|
||||
key
|
||||
value
|
||||
}
|
||||
cost {
|
||||
totalAmount {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
merchandise {
|
||||
... on ProductVariant {
|
||||
id
|
||||
title
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
product {
|
||||
...product
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
totalQuantity
|
||||
}
|
||||
${productFragment}
|
||||
`;
|
||||
|
||||
export default cartFragment;
|
||||
10
lib/shopify/fragments/image.ts
Normal file
10
lib/shopify/fragments/image.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const imageFragment = /* GraphQL */ `
|
||||
fragment image on Image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
`;
|
||||
|
||||
export default imageFragment;
|
||||
69
lib/shopify/fragments/product.ts
Normal file
69
lib/shopify/fragments/product.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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
|
||||
metafields(identifiers: [{ namespace: "custom", key: "is_box_product" }]) {
|
||||
id
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
${imageFragment}
|
||||
${seoFragment}
|
||||
`;
|
||||
|
||||
export default productFragment;
|
||||
8
lib/shopify/fragments/seo.ts
Normal file
8
lib/shopify/fragments/seo.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
const seoFragment = /* GraphQL */ `
|
||||
fragment seo on SEO {
|
||||
description
|
||||
title
|
||||
}
|
||||
`;
|
||||
|
||||
export default seoFragment;
|
||||
495
lib/shopify/index.ts
Normal file
495
lib/shopify/index.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
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; attributes?: { key: string; value: string }[] }[]
|
||||
): 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) {
|
||||
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));
|
||||
}
|
||||
|
||||
export async function getRegularProducts({
|
||||
query,
|
||||
reverse,
|
||||
sortKey
|
||||
}: {
|
||||
query?: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
}): Promise<Product[]> {
|
||||
const allProducts = await getProducts({
|
||||
query,
|
||||
reverse,
|
||||
sortKey
|
||||
});
|
||||
|
||||
return allProducts.filter(product => {
|
||||
const hasBoxTag = product.tags.some(tag => tag.toLowerCase() === 'box');
|
||||
const hasBoxInTitle = product.title.toLowerCase().includes('box');
|
||||
return !hasBoxTag && !hasBoxInTitle;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBoxProducts({
|
||||
query,
|
||||
reverse,
|
||||
sortKey
|
||||
}: {
|
||||
query?: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
}): Promise<Product[]> {
|
||||
const allProducts = await getProducts({ query, reverse, sortKey });
|
||||
|
||||
// Keep only box products based on tags or title
|
||||
return allProducts.filter(product => {
|
||||
const hasBoxTag = product.tags.some(tag => tag.toLowerCase() === 'box');
|
||||
const hasBoxInTitle = product.title.toLowerCase().includes('box');
|
||||
return hasBoxTag || hasBoxInTitle;
|
||||
});
|
||||
}
|
||||
|
||||
// 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() });
|
||||
}
|
||||
45
lib/shopify/mutations/cart.ts
Normal file
45
lib/shopify/mutations/cart.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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}
|
||||
`;
|
||||
10
lib/shopify/queries/cart.ts
Normal file
10
lib/shopify/queries/cart.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import cartFragment from '../fragments/cart';
|
||||
|
||||
export const getCartQuery = /* GraphQL */ `
|
||||
query getCart($cartId: ID!) {
|
||||
cart(id: $cartId) {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
||||
56
lib/shopify/queries/collection.ts
Normal file
56
lib/shopify/queries/collection.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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}
|
||||
`;
|
||||
10
lib/shopify/queries/menu.ts
Normal file
10
lib/shopify/queries/menu.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const getMenuQuery = /* GraphQL */ `
|
||||
query getMenu($handle: String!) {
|
||||
menu(handle: $handle) {
|
||||
items {
|
||||
title
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
41
lib/shopify/queries/page.ts
Normal file
41
lib/shopify/queries/page.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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}
|
||||
`;
|
||||
32
lib/shopify/queries/product.ts
Normal file
32
lib/shopify/queries/product.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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}
|
||||
`;
|
||||
287
lib/shopify/types.ts
Normal file
287
lib/shopify/types.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
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;
|
||||
attributes?: {
|
||||
key: string;
|
||||
value: string;
|
||||
}[];
|
||||
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 Metafield = {
|
||||
id: string;
|
||||
key: string;
|
||||
value: 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;
|
||||
metafields: Metafield[];
|
||||
};
|
||||
|
||||
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;
|
||||
attributes?: {
|
||||
key: string;
|
||||
value: string;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
};
|
||||
27
lib/type-guards.ts
Normal file
27
lib/type-guards.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface ShopifyErrorLike {
|
||||
status: number;
|
||||
message: Error;
|
||||
cause?: Error;
|
||||
}
|
||||
|
||||
export const isObject = (object: unknown): object is Record<string, unknown> => {
|
||||
return typeof object === 'object' && object !== null && !Array.isArray(object);
|
||||
};
|
||||
|
||||
export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
|
||||
if (!isObject(error)) return false;
|
||||
|
||||
if (error instanceof Error) return true;
|
||||
|
||||
return findError(error);
|
||||
};
|
||||
|
||||
function findError<T extends object>(error: T): boolean {
|
||||
if (Object.prototype.toString.call(error) === '[object Error]') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(error) as T | null;
|
||||
|
||||
return prototype === null ? false : findError(prototype);
|
||||
}
|
||||
10
lib/types/carousel.ts
Normal file
10
lib/types/carousel.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface CarouselSlide {
|
||||
id: number;
|
||||
title: string;
|
||||
titleColored: string;
|
||||
titleEnd: string;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
buttonLink: string;
|
||||
imageSrc: string;
|
||||
}
|
||||
60
lib/utils.ts
Normal file
60
lib/utils.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { ReadonlyURLSearchParams } from 'next/navigation';
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
|
||||
const paramsString = params.toString();
|
||||
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
|
||||
|
||||
return `${pathname}${queryString}`;
|
||||
};
|
||||
|
||||
export const ensureStartsWith = (stringToCheck: string, startsWith: string) => {
|
||||
if (typeof stringToCheck !== 'string') {
|
||||
return stringToCheck;
|
||||
}
|
||||
|
||||
if (startsWith.length === 0) {
|
||||
return stringToCheck;
|
||||
}
|
||||
|
||||
if (stringToCheck.startsWith(startsWith)) {
|
||||
return stringToCheck;
|
||||
}
|
||||
|
||||
return `${startsWith}${stringToCheck}`;
|
||||
};
|
||||
|
||||
export const validateEnvironmentVariables = () => {
|
||||
const requiredEnvironmentVariables = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_STOREFRONT_ACCESS_TOKEN'];
|
||||
const missingEnvironmentVariables = [] as string[];
|
||||
|
||||
requiredEnvironmentVariables.forEach((envVar) => {
|
||||
if (!process.env[envVar]) {
|
||||
missingEnvironmentVariables.push(envVar);
|
||||
}
|
||||
});
|
||||
|
||||
if (missingEnvironmentVariables.length) {
|
||||
throw new Error(
|
||||
`The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/shopify#configure-environment-variables\n\n${missingEnvironmentVariables.join(
|
||||
'\n'
|
||||
)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.SHOPIFY_STORE_DOMAIN?.includes('[') ||
|
||||
process.env.SHOPIFY_STORE_DOMAIN?.includes(']')
|
||||
) {
|
||||
throw new Error(
|
||||
'Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export const container = 'container mx-auto max-w-[1400px] px-4 sm:px-6 lg:px-4';
|
||||
Reference in New Issue
Block a user