Initial commit

This commit is contained in:
Rami Bitar
2026-08-08 14:21:24 -04:00
commit 58742d5d00
127 changed files with 18859 additions and 0 deletions
+446
View File
@@ -0,0 +1,446 @@
// Server-safe Shopify catalogue access.
//
// These are plain async functions with no React imports, so they can be called
// from Route Handlers (see app/api/chat/route.ts) as well as from the client
// hooks in hooks/use-shopify-*.ts, which re-export them.
import type { StorefrontApi } from '@shopify/hydrogen';
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
import {
GET_PRODUCTS_QUERY,
GET_PRODUCT_QUERY,
QUERY_PRODUCT_RECOMMENDATIONS,
} from '@/graphql/products';
import {
GET_COLLECTIONS_QUERY,
GET_COLLECTION_PRODUCTS_QUERY,
} from '@/graphql/collections';
import {
SEARCH_PRODUCTS_QUERY,
SEARCH_SUGGESTIONS_QUERY,
} from '@/graphql/search';
// Optional fields are `| null` rather than just optional: the Storefront API
// returns explicit nulls, and the typed `gql()` documents now surface that.
interface ProductImage {
url: string;
altText?: string | null;
}
interface ProductPrice {
amount: string;
currencyCode: string;
}
interface ProductVariant {
id: string;
title: string;
price: ProductPrice;
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
image?: ProductImage | null;
}
export interface ProductOptionValue {
id: string;
name: string;
swatch?: {
color?: string | null;
image?: {
previewImage?: {
url: string;
} | null;
} | null;
} | null;
firstSelectableVariant?: {
id: string;
image?: ProductImage | null;
} | null;
}
export interface ProductOption {
id: string;
name: string;
values: string[];
optionValues?: ProductOptionValue[];
}
/**
* Single-variant products still carry one synthetic option — `Title` with the
* lone value `Default Title`. It isn't a real choice, so keep it out of the UI.
*/
export const isDefaultTitleOption = (option: {
name: string;
values: string[];
}): boolean =>
option.name === 'Title' &&
option.values.length === 1 &&
option.values[0] === 'Default Title';
/** Same synthetic option, as it appears on a variant's `selectedOptions`. */
export const isDefaultTitleSelection = (selection: {
name: string;
value: string;
}): boolean =>
selection.name === 'Title' && selection.value === 'Default Title';
export interface Product {
id: string;
title: string;
description?: string;
descriptionHtml?: string;
handle: string;
images: {
edges: Array<{
node: ProductImage;
}>;
};
priceRange: {
minVariantPrice: ProductPrice;
};
compareAtPriceRange?: {
minVariantPrice: ProductPrice;
};
variants: {
edges: Array<{
node: ProductVariant;
}>;
};
options: ProductOption[];
}
interface UseProductsOptions {
first?: number;
/** Cursor from a previous page's `endCursor`; omit for the first page. */
after?: string | null;
query?: string;
sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE';
reverse?: boolean;
}
export interface ProductsPage {
products: Product[];
hasNextPage: boolean;
endCursor: string | null;
}
interface UseProductsReturn {
products: Product[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
// Fetch multiple products
export async function getProducts(
options: UseProductsOptions = {}
): Promise<Product[]> {
const { products } = await getProductsPage(options);
return products;
}
// Same fetch, but keeps the cursor so callers can page through the catalogue.
export async function getProductsPage({
first = 20,
after = null,
query = '',
sortKey = 'BEST_SELLING',
reverse = false,
}: UseProductsOptions = {}): Promise<ProductsPage> {
const data = unwrapStorefrontResult(
await storefront.graphql(GET_PRODUCTS_QUERY, {
variables: { first, after, query, sortKey, reverse },
}),
'GetProducts'
);
const { edges, pageInfo } = data.products;
return {
products: edges.map((edge: { node: Product }) => edge.node),
hasNextPage: Boolean(pageInfo?.hasNextPage),
endCursor: pageInfo?.endCursor ?? null,
};
}
// Fetch a single product by handle
export async function getProduct(handle: string): Promise<Product | null> {
const data = unwrapStorefrontResult(
await storefront.graphql(GET_PRODUCT_QUERY, { variables: { handle } }),
'GetProduct'
);
return data.product;
}
// Fetch product recommendations
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const data = unwrapStorefrontResult(
await storefront.graphql(QUERY_PRODUCT_RECOMMENDATIONS, {
variables: { productId },
}),
'GetProductRecommendations'
);
return data.productRecommendations ?? [];
}
interface CollectionImage {
url: string;
altText?: string | null;
}
export interface Collection {
id: string;
title: string;
handle: string;
description?: string;
descriptionHtml?: string;
image?: CollectionImage | null;
}
export interface CollectionWithProducts extends Collection {
products: Product[];
}
export type CollectionSortKey =
| 'COLLECTION_DEFAULT'
| 'BEST_SELLING'
| 'CREATED'
| 'PRICE'
| 'TITLE';
interface UseCollectionProductsOptions {
first?: number;
after?: string | null;
sortKey?: CollectionSortKey;
reverse?: boolean;
/** Raw `input` strings from the connection's `filters` facets. */
filterInputs?: string[];
}
export interface CollectionProductsPage {
collection: Collection | null;
products: Product[];
filters: ProductFilterFacet[];
hasNextPage: boolean;
endCursor: string | null;
}
export interface ProductFilterFacet {
id: string;
label: string;
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
values: Array<{
id: string;
label: string;
count: number;
input: string;
}>;
}
// Fetch all collections
export async function getCollections(first = 50): Promise<Collection[]> {
const data = unwrapStorefrontResult(
await storefront.graphql(GET_COLLECTIONS_QUERY, { variables: { first } }),
'GetCollections'
);
return data.collections.edges.map((edge) => edge.node);
}
// Fetch products in a collection by handle
export async function getCollectionProducts(
handle: string,
options: UseCollectionProductsOptions = {}
): Promise<CollectionWithProducts | null> {
const page = await getCollectionProductsPage(handle, options);
if (!page.collection) return null;
return { ...page.collection, products: page.products };
}
// Same fetch, but keeps the cursor and facet list for filtering and paging.
export async function getCollectionProductsPage(
handle: string,
{
first = 50,
after = null,
sortKey = 'COLLECTION_DEFAULT',
reverse = false,
filterInputs = [],
}: UseCollectionProductsOptions = {}
): Promise<CollectionProductsPage> {
const filters = parseFilterInputs(filterInputs);
const data = unwrapStorefrontResult(
await storefront.graphql(GET_COLLECTION_PRODUCTS_QUERY, {
variables: {
handle,
first,
after,
sortKey,
reverse,
filters: filters.length ? filters : null,
},
}),
'GetCollectionProducts'
);
const collection = data.collection;
if (!collection) {
return {
collection: null,
products: [],
filters: [],
hasNextPage: false,
endCursor: null,
};
}
const { edges, pageInfo, filters: facets } = collection.products;
return {
collection,
products: edges.map((edge: { node: Product }) => edge.node),
filters: facets ?? [],
hasNextPage: Boolean(pageInfo?.hasNextPage),
endCursor: pageInfo?.endCursor ?? null,
};
}
export type SearchSortKey = 'RELEVANCE' | 'PRICE';
export interface SearchFilterValue {
id: string;
label: string;
count: number;
/** JSON string accepted back as a `ProductFilter` input. */
input: string;
}
export interface SearchFilter {
id: string;
label: string;
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
values: SearchFilterValue[];
}
export interface SearchProductsResult {
products: Product[];
totalCount: number;
filters: SearchFilter[];
hasNextPage: boolean;
endCursor: string | null;
}
export interface SearchSuggestion {
id: string;
title: string;
handle: string;
featuredImage?: {
url: string;
altText?: string | null;
} | null;
priceRange: {
minVariantPrice: {
amount: string;
currencyCode: string;
};
};
}
interface SearchProductsOptions {
query: string;
first?: number;
after?: string | null;
sortKey?: SearchSortKey;
reverse?: boolean;
/** Raw `input` strings from the facets, parsed back into filter objects. */
filterInputs?: string[];
}
// The `ProductFilter` input shape, taken from the query that consumes it so it
// tracks the schema rather than being restated here.
type ProductFilterInput = NonNullable<
StorefrontApi.VariablesOf<typeof GET_COLLECTION_PRODUCTS_QUERY>['filters']
>[number];
// Facet `input` values are opaque JSON strings produced by Shopify and handed
// straight back as filter inputs, so they are parsed, not constructed.
function parseFilterInputs(inputs: string[]): ProductFilterInput[] {
return inputs.flatMap((input) => {
try {
return [JSON.parse(input) as ProductFilterInput];
} catch {
console.warn('Ignoring malformed product filter input:', input);
return [];
}
});
}
export async function searchProducts({
query,
first = 24,
after = null,
sortKey = 'RELEVANCE',
reverse = false,
filterInputs = [],
}: SearchProductsOptions): Promise<SearchProductsResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(SEARCH_PRODUCTS_QUERY, {
variables: {
query,
first,
after,
sortKey,
reverse,
productFilters: filterInputs.length
? parseFilterInputs(filterInputs)
: null,
},
}),
'SearchProducts'
);
const search = data.search;
return {
products: search.edges
.map((edge) => edge.node)
.filter((node): node is Extract<typeof node, { __typename: 'Product' }> =>
node.__typename === 'Product'
),
totalCount: search.totalCount ?? 0,
filters: search.productFilters ?? [],
hasNextPage: Boolean(search.pageInfo?.hasNextPage),
endCursor: search.pageInfo?.endCursor ?? null,
};
}
export async function searchSuggestions(
query: string,
first = 3
): Promise<{ products: SearchSuggestion[]; totalCount: number }> {
const data = unwrapStorefrontResult(
await storefront.graphql(SEARCH_SUGGESTIONS_QUERY, {
variables: { query, first },
}),
'SearchSuggestions'
);
const search = data.search;
return {
products: search.edges
.map((edge) => edge.node)
.filter((node): node is Extract<typeof node, { __typename: 'Product' }> =>
node.__typename === 'Product'
),
totalCount: search.totalCount ?? 0,
};
}
+105
View File
@@ -0,0 +1,105 @@
// Storefront API clients, built on `@shopify/hydrogen`.
//
// Call sites use the package's own API — `storefront.graphql(DOCUMENT, {
// variables })` — and pass the result through `unwrapStorefrontResult`, which
// applies this app's error policy: fail loudly. Hydrogen deliberately does not
// do that itself, because a 200 carrying partial data and GraphQL errors is a
// valid response that some callers want to render.
import {
createShopifyRequestContext,
createStorefrontClient,
type GraphQLFormattedError,
} from '@shopify/hydrogen';
import {
SHOPIFY_API_VERSION,
SHOPIFY_PUBLIC_ACCESS_TOKEN,
SHOPIFY_STORE_DOMAIN,
} from '@/services/shopify/config';
// No incoming request and no buyer context: these clients are module-scoped and
// serve both server rendering and the browser-side hooks, so they must not close
// over per-request state. `$country`/`$language` are injected from this i18n.
const requestContext = createShopifyRequestContext({
request: { headers: new Headers() },
i18n: { country: 'US', language: 'EN' },
});
/**
* Workaround for a bug in this preview build of `@shopify/hydrogen`.
*
* The client tags every request with `X-Hydrogen-Version`, but the Storefront
* API does not list that header in its CORS `access-control-allow-headers`.
* Browsers therefore reject the preflight and `fetch` throws, which hydrogen
* reports as the generic "SFAPI request failed". It only bites against real
* stores — `mock.shop` answers `access-control-allow-headers: *`.
*
* Stripped in the browser only: server-side requests are not subject to CORS,
* so they keep sending the header. Remove this once the API allows it (or once
* these queries move server-side, which is the better long-term fix).
*/
const CORS_BLOCKED_HEADERS = ['X-Hydrogen-Version'];
// Hydrogen calls `fetch(url, init, cacheOptions)`; Next's caching hints ride
// along on `init`, which is how the two ways of caching get to coexist.
const fetchWith = (overrides: RequestInit): typeof globalThis.fetch =>
((url, init) => {
const headers = new Headers(init?.headers);
if (typeof document !== 'undefined') {
for (const header of CORS_BLOCKED_HEADERS) headers.delete(header);
}
return globalThis.fetch(url, { ...init, ...overrides, headers });
}) as typeof globalThis.fetch;
const config = {
storeDomain: SHOPIFY_STORE_DOMAIN!,
apiVersion: SHOPIFY_API_VERSION,
publicStorefrontToken: SHOPIFY_PUBLIC_ACCESS_TOKEN,
};
/**
* Default client. Uncached, because carts and customer reads must never serve a
* stale response.
*/
export const storefront = createStorefrontClient({
type: 'public',
requestContext,
config: { ...config, fetch: fetchWith({ cache: 'no-store' }) },
});
/**
* Client for data that changes rarely (shop policies, and anything else safe to
* serve from Next's data cache for an hour).
*/
export const cachedStorefront = createStorefrontClient({
type: 'public',
requestContext,
config: { ...config, fetch: fetchWith({ next: { revalidate: 3600 } }) },
});
/**
* Returns the data from a `graphql()` result, throwing if Shopify reported any
* GraphQL errors. Transport failures — non-200, timeouts, unparseable bodies —
* have already thrown as `StorefrontApiError` by this point.
*
* `operation` names the query in the log and the thrown message, so a failure
* points at the call site rather than just at "Shopify".
*/
export function unwrapStorefrontResult<TData>(
result: { data: TData | null; errors?: GraphQLFormattedError[] },
operation: string
): TData {
if (result.errors?.length) {
console.error(`Shopify GraphQL errors (${operation}):`, result.errors);
throw new Error(
`Shopify GraphQL errors (${operation}): ${JSON.stringify(result.errors)}`
);
}
if (result.data == null) {
throw new Error(`Shopify returned no data for ${operation}.`);
}
return result.data;
}
+16
View File
@@ -0,0 +1,16 @@
// Storefront configuration, read from the environment in one place.
//
// These are all `NEXT_PUBLIC_*`, so Next inlines them at build time and they are
// safe to read from client components as well as server code.
export const SHOPIFY_STORE_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN;
/**
* Public Storefront API access token. Safe to expose to the browser — that is
* what "public" means here. Omitted for tokenless storefronts such as
* `mock.shop`. Never put a *private* token behind a `NEXT_PUBLIC_` name.
*/
export const SHOPIFY_PUBLIC_ACCESS_TOKEN =
process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN;
export const SHOPIFY_API_VERSION =
process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION || '2026-07';
+248
View File
@@ -0,0 +1,248 @@
// Server-safe customer account access.
//
// The customer access token is a credential: it is only ever handled here and
// in the /api/account route handlers, and is stored in an httpOnly cookie so
// client JavaScript can never read it.
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
import {
CUSTOMER_QUERY,
CUSTOMER_CREATE_MUTATION,
CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION,
CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION,
CUSTOMER_RECOVER_MUTATION,
CUSTOMER_RESET_MUTATION,
CUSTOMER_ACTIVATE_MUTATION,
CUSTOMER_UPDATE_MUTATION,
} from '@/graphql/customer';
export const CUSTOMER_TOKEN_COOKIE = 'customerAccessToken';
export interface CustomerUserError {
/** One of `CustomerErrorCode`; kept as a string so new codes don't break. */
code?: string | null;
field?: string[] | null;
message: string;
}
export interface CustomerAddress {
id: string;
firstName?: string | null;
lastName?: string | null;
address1?: string | null;
address2?: string | null;
city?: string | null;
province?: string | null;
zip?: string | null;
country?: string | null;
phone?: string | null;
}
export interface CustomerOrder {
id: string;
orderNumber: number;
processedAt: string;
financialStatus?: string | null;
fulfillmentStatus?: string | null;
statusUrl?: string | null;
currentTotalPrice: { amount: string; currencyCode: string };
lineItems: {
edges: Array<{
node: {
title: string;
quantity: number;
variant?: { image?: { url: string; altText?: string | null } | null } | null;
};
}>;
};
}
export interface Customer {
id: string;
email: string | null;
firstName?: string | null;
lastName?: string | null;
phone?: string | null;
displayName: string;
acceptsMarketing?: boolean;
createdAt?: string;
defaultAddress?: CustomerAddress | null;
orders?: { edges: Array<{ node: CustomerOrder }> };
}
export interface AccessToken {
accessToken: string;
expiresAt: string;
}
/** Either a token (success) or the errors Shopify reported. */
export interface AuthResult {
token: AccessToken | null;
errors: CustomerUserError[];
}
const firstMessage = (errors: CustomerUserError[]) =>
errors[0]?.message ?? 'Something went wrong. Please try again.';
export { firstMessage as customerErrorMessage };
// Shopify returns a null mutation payload when the mutation could not run at
// all. That is not a success, so it surfaces as a generic error rather than an
// empty error list, which callers read as "it worked".
const MUTATION_FAILED: CustomerUserError[] = [
{ message: 'Something went wrong. Please try again.' },
];
export async function createCustomer(input: {
email: string;
password: string;
firstName?: string;
lastName?: string;
acceptsMarketing?: boolean;
}): Promise<{ errors: CustomerUserError[] }> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_CREATE_MUTATION, {
variables: { input },
}),
'CustomerCreate'
);
const result = data.customerCreate;
if (!result) return { errors: MUTATION_FAILED };
return { errors: result.customerUserErrors ?? [] };
}
export async function login(
email: string,
password: string
): Promise<AuthResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION, {
variables: { input: { email, password } },
}),
'CustomerAccessTokenCreate'
);
const result = data.customerAccessTokenCreate;
if (!result) return { token: null, errors: MUTATION_FAILED };
return {
token: result.customerAccessToken ?? null,
errors: result.customerUserErrors ?? [],
};
}
export async function logout(accessToken: string): Promise<void> {
try {
await storefront.graphql(CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION, {
variables: { customerAccessToken: accessToken },
});
} catch (err) {
// The cookie is cleared regardless; a failed revoke shouldn't block logout.
console.error('Failed to revoke customer access token:', err);
}
}
// Always resolves without error detail: revealing whether an address exists
// would leak account membership.
export async function recoverPassword(email: string): Promise<void> {
try {
await storefront.graphql(CUSTOMER_RECOVER_MUTATION, {
variables: { email },
});
} catch (err) {
console.error('Password recovery request failed:', err);
}
}
export async function resetPassword(
id: string,
resetToken: string,
password: string
): Promise<AuthResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_RESET_MUTATION, {
variables: { id, input: { resetToken, password } },
}),
'CustomerReset'
);
const result = data.customerReset;
if (!result) return { token: null, errors: MUTATION_FAILED };
return {
token: result.customerAccessToken ?? null,
errors: result.customerUserErrors ?? [],
};
}
export async function activateAccount(
id: string,
activationToken: string,
password: string
): Promise<AuthResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_ACTIVATE_MUTATION, {
variables: { id, input: { activationToken, password } },
}),
'CustomerActivate'
);
const result = data.customerActivate;
if (!result) return { token: null, errors: MUTATION_FAILED };
return {
token: result.customerAccessToken ?? null,
errors: result.customerUserErrors ?? [],
};
}
export async function getCustomer(
accessToken: string,
orderCount = 10
): Promise<Customer | null> {
try {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_QUERY, {
variables: { customerAccessToken: accessToken, orderCount },
}),
'GetCustomer'
);
return data.customer ?? null;
} catch (err) {
// An expired or revoked token reads as "not signed in".
console.error('Failed to load customer:', err);
return null;
}
}
export async function updateCustomer(
accessToken: string,
customer: {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
}
): Promise<{ customer: Customer | null; errors: CustomerUserError[] }> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_UPDATE_MUTATION, {
variables: { customerAccessToken: accessToken, customer },
}),
'CustomerUpdate'
);
const result = data.customerUpdate;
if (!result) return { customer: null, errors: MUTATION_FAILED };
return {
customer: result.customer ?? null,
errors: result.customerUserErrors ?? [],
};
}
// Shopify's emailed links carry a numeric id; the mutations want a GID.
export function toCustomerGid(id: string): string {
return id.startsWith('gid://') ? id : `gid://shopify/Customer/${id}`;
}
+31
View File
@@ -0,0 +1,31 @@
// Customer session, stored in an httpOnly cookie. Server-only: importing this
// from a client component will fail, which is deliberate — the access token
// must never reach the browser's JavaScript.
import { cookies } from 'next/headers';
import { CUSTOMER_TOKEN_COOKIE } from '@/services/shopify/customer';
export async function getSessionToken(): Promise<string | null> {
const store = await cookies();
return store.get(CUSTOMER_TOKEN_COOKIE)?.value ?? null;
}
export async function setSessionToken(
accessToken: string,
expiresAt: string
): Promise<void> {
const store = await cookies();
const expires = new Date(expiresAt);
store.set(CUSTOMER_TOKEN_COOKIE, accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
expires: Number.isNaN(expires.getTime()) ? undefined : expires,
});
}
export async function clearSessionToken(): Promise<void> {
const store = await cookies();
store.delete(CUSTOMER_TOKEN_COOKIE);
}