AbiirSkincare/Hydrogen/web/app/lib/createNewsletterCustomer.se...

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};
}