129 lines
3.5 KiB
TypeScript
129 lines
3.5 KiB
TypeScript
/**
|
|
* Creates a Shopify customer (newsletter / Glow Club) via Admin GraphQL.
|
|
* Requires a Custom App admin token with `write_customers` on the same shop as PUBLIC_STORE_DOMAIN.
|
|
* @see https://shopify.dev/docs/api/admin-graphql/latest/mutations/customerCreate
|
|
*/
|
|
const SHOPIFY_ADMIN_API_VERSION = '2026-04';
|
|
|
|
/**
|
|
* Admin API (not Storefront) — do not use `#graphql` here or Hydrogen codegen
|
|
* will validate this against the Storefront schema and fail.
|
|
*/
|
|
const CUSTOMER_CREATE = `
|
|
mutation NewsletterCustomerCreate($input: CustomerInput!) {
|
|
customerCreate(input: $input) {
|
|
customer {
|
|
id
|
|
email
|
|
}
|
|
userErrors {
|
|
field
|
|
message
|
|
}
|
|
}
|
|
}
|
|
` as const;
|
|
|
|
export type NewsletterCustomerResult =
|
|
| {ok: true; customerId?: string; alreadyExists?: boolean}
|
|
| {ok: false; error: NewsletterCustomerErrorCode};
|
|
|
|
export type NewsletterCustomerErrorCode =
|
|
| 'invalid_email'
|
|
| 'not_configured'
|
|
| 'service_unavailable'
|
|
| 'shopify_error';
|
|
|
|
function normalizeShopDomain(domain: string): string {
|
|
return domain
|
|
.trim()
|
|
.replace(/^https?:\/\//i, '')
|
|
.replace(/\/.*$/, '');
|
|
}
|
|
|
|
function isProbablyDuplicateError(message: string): boolean {
|
|
const lower = message.toLowerCase();
|
|
return (
|
|
lower.includes('already been taken') ||
|
|
lower.includes('already exists') ||
|
|
(lower.includes('taken') && lower.includes('email'))
|
|
);
|
|
}
|
|
|
|
export async function createNewsletterCustomerAdmin(options: {
|
|
shopDomain: string;
|
|
accessToken: string;
|
|
email: string;
|
|
}): Promise<NewsletterCustomerResult> {
|
|
const shop = normalizeShopDomain(options.shopDomain);
|
|
if (!shop) {
|
|
return {ok: false, error: 'not_configured'};
|
|
}
|
|
|
|
const url = `https://${shop}/admin/api/${SHOPIFY_ADMIN_API_VERSION}/graphql.json`;
|
|
const consentUpdatedAt = new Date().toISOString();
|
|
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Shopify-Access-Token': options.accessToken,
|
|
},
|
|
body: JSON.stringify({
|
|
query: CUSTOMER_CREATE,
|
|
variables: {
|
|
input: {
|
|
email: options.email,
|
|
tags: ['newsletter', 'glow-club'],
|
|
emailMarketingConsent: {
|
|
marketingState: 'SUBSCRIBED',
|
|
marketingOptInLevel: 'SINGLE_OPT_IN',
|
|
consentUpdatedAt,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
console.error(
|
|
'[newsletter] Admin API HTTP error',
|
|
res.status,
|
|
text.slice(0, 500),
|
|
);
|
|
return {ok: false, error: 'service_unavailable'};
|
|
}
|
|
|
|
const json = (await res.json()) as {
|
|
data?: {
|
|
customerCreate?: {
|
|
customer?: {id: string; email: string} | null;
|
|
userErrors?: Array<{field?: string[]; message: string}>;
|
|
};
|
|
};
|
|
errors?: Array<{message: string}>;
|
|
};
|
|
|
|
if (json.errors?.length) {
|
|
console.error('[newsletter] Admin GraphQL top-level errors', json.errors);
|
|
return {ok: false, error: 'service_unavailable'};
|
|
}
|
|
|
|
const payload = json.data?.customerCreate;
|
|
if (!payload) {
|
|
return {ok: false, error: 'service_unavailable'};
|
|
}
|
|
|
|
if (payload.userErrors?.length) {
|
|
const combined = payload.userErrors.map((e) => e.message).join('; ');
|
|
if (isProbablyDuplicateError(combined)) {
|
|
return {ok: true, alreadyExists: true};
|
|
}
|
|
console.error('[newsletter] customerCreate userErrors', payload.userErrors);
|
|
return {ok: false, error: 'shopify_error'};
|
|
}
|
|
|
|
return {ok: true, customerId: payload.customer?.id ?? undefined};
|
|
}
|