Add React editor project
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
+93
-51
@@ -1,63 +1,105 @@
|
||||
export const SHOPIFY_API_VERSION = '2026-04';
|
||||
// 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';
|
||||
|
||||
type Credentials = {
|
||||
domain: string;
|
||||
token: string;
|
||||
// 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,
|
||||
};
|
||||
|
||||
let currentCreds: Partial<Credentials> = {};
|
||||
/**
|
||||
* 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' }) },
|
||||
});
|
||||
|
||||
export function setShopifyCredentials(creds: Credentials) {
|
||||
currentCreds = { domain: creds.domain, token: creds.token };
|
||||
}
|
||||
/**
|
||||
* 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 } }) },
|
||||
});
|
||||
|
||||
export function getShopifyCredentials(): Partial<Credentials> {
|
||||
return currentCreds;
|
||||
}
|
||||
|
||||
export const SHOPIFY_STORE_DOMAIN = currentCreds.domain ?? '';
|
||||
|
||||
export async function shopifyFetch<T = any>({
|
||||
query,
|
||||
variables = {},
|
||||
credentials,
|
||||
}: {
|
||||
query: string;
|
||||
variables?: Record<string, any>;
|
||||
credentials?: Partial<Credentials>;
|
||||
}): Promise<{ data: T; errors?: any[] }> {
|
||||
const domain = credentials?.domain ?? currentCreds.domain;
|
||||
const token = credentials?.token ?? currentCreds.token;
|
||||
const apiVersion = SHOPIFY_API_VERSION;
|
||||
|
||||
if (!domain) {
|
||||
/**
|
||||
* 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(
|
||||
'[shopifyFetch] missing domain. Wrap your tree in <ShopifyProvider domain="..."> or call setShopifyCredentials() before rendering.',
|
||||
`Shopify GraphQL errors (${operation}): ${JSON.stringify(result.errors)}`
|
||||
);
|
||||
}
|
||||
|
||||
const url = `https://${domain}/api/${apiVersion}/graphql.json`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (token) {
|
||||
headers['X-Shopify-Storefront-Access-Token'] = token;
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ query, variables }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Shopify HTTP ${response.status}: ${body}`);
|
||||
if (result.data == null) {
|
||||
throw new Error(`Shopify returned no data for ${operation}.`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
if (json.errors) {
|
||||
throw new Error(`Shopify GraphQL errors: ${JSON.stringify(json.errors)}`);
|
||||
}
|
||||
return json;
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user