import type { EmailContentSettings, ProductCardConfig, CouponDisplayConfig } from './sections'; /** Only these authored messages are safe to show as inline recovery guidance. */ export class EmailPersistenceError extends Error {} export interface EmailDraft { content: EmailContentSettings; productCard?: ProductCardConfig; couponDisplay?: CouponDisplayConfig; } type Path = Array; type ContentField = keyof EmailContentSettings; type Mapping = Partial>; interface EmailAdapter { endpoint: string; responseKey: 'settings' | 'data' | 'proSettings'; content: Mapping; product?: Partial>; coupon?: Partial>; table?: boolean; unsubscribe?: boolean; } export interface PersistedEmailDraft extends EmailDraft { emailTypeId: string; endpoint: string; patches: Array<{ path: Path; value: unknown }>; } const fields = (prefix: string, body = true): Mapping => ({ subject: [prefix + 'Subject'], heading: [prefix + 'Heading'], ...(body ? { body: [prefix + 'Body'] } : {}), additionalContent: [prefix + 'AdditionalContent'], preheaderText: [prefix + 'PreheaderText'], }); const cartProduct: EmailAdapter['product'] = { showImage: ['emailShowImage'], showTitle: ['emailShowTitle'], showPrice: ['emailShowPrice'], buttonText: ['emailButtonText'], }; const cartCoupon: EmailAdapter['coupon'] = { boxBgColor: ['couponBoxBgColor'], boxBorderColor: ['couponBoxBorderColor'], boxTextColor: ['couponBoxTextColor'], codeBgColor: ['couponCodeBgColor'], messageText: ['couponMessageText'], }; const verification: Pick = { endpoint: 'email-verification/settings', responseKey: 'data', }; const adapters: Record = { email_verification: { ...verification, content: fields('email') }, registration_welcome: { endpoint: 'registration-form/settings', responseKey: 'settings', content: fields('welcomeEmail') }, wishlist_on_sale: { endpoint: 'wishlist/pro-settings', responseKey: 'proSettings', content: fields('onSaleEmail') }, wishlist_back_in_stock: { endpoint: 'wishlist/pro-settings', responseKey: 'proSettings', content: fields('backInStockEmail') }, shipment_tracking: { endpoint: 'shipment-tracking/settings', responseKey: 'settings', content: { subject: ['email_subject'], heading: ['email_heading'], body: ['email_content'], additionalContent: ['email_additional_content'], preheaderText: ['email_preheader_text'] }, }, back_in_stock_notification: { endpoint: 'back-in-stock/settings', responseKey: 'settings', content: { subject: ['email', 'subject'], heading: ['email', 'heading'], body: ['email', 'bodyText'] }, product: { showImage: ['email', 'includeImage'], showTitle: ['email', 'includeTitle'], showPrice: ['email', 'includePrice'], buttonText: ['email', 'buttonText'] }, }, back_in_stock_confirmation: { endpoint: 'back-in-stock/settings', responseKey: 'settings', content: { subject: ['confirmEmail', 'subject'], heading: ['confirmEmail', 'heading'], body: ['confirmEmail', 'bodyText'] }, }, abandoned_cart_reminder: { endpoint: 'abandoned-cart/settings', responseKey: 'settings', content: { subject: ['emails', 0, 'subject'], heading: ['emailHeading'], body: ['emailBodyText'] }, product: cartProduct, coupon: cartCoupon, unsubscribe: true, }, unpaid_order_reminder: { endpoint: 'abandoned-cart/settings', responseKey: 'settings', content: { subject: ['unpaidOrderEmails', 0, 'subject'], heading: ['unpaidEmailHeading'], body: ['unpaidEmailBodyText'] }, coupon: cartCoupon, }, }; for (const [id, prefix] of Object.entries({ email_pending_approval: 'pendingApprovalEmail', email_account_approved: 'accountApprovedEmail', email_admin_notification: 'adminNotificationEmail', email_account_rejected: 'accountRejectedEmail', })) { adapters[id] = { ...verification, content: { subject: [prefix + 'Subject'], heading: [prefix + 'Heading'], body: [prefix + 'Body'] } }; } for (const id of ['new_subscription', 'subscription_activated', 'subscription_cancelled', 'subscription_expired', 'subscription_on_hold', 'renewal_reminder', 'trial_ending', 'payment_failed', 'payment_retry']) { adapters[id] = { endpoint: 'subscriptions/settings', responseKey: 'settings', table: true, content: { subject: ['emails', id, 'subject'], heading: ['emails', id, 'heading'], additionalContent: ['emails', id, 'additionalContent'], preheaderText: ['emails', id, 'preheaderText'] }, }; } export function getEmailCapabilities(emailTypeId: string) { const adapter = Object.prototype.hasOwnProperty.call(adapters, emailTypeId) ? adapters[emailTypeId] : undefined; return { body: !!adapter?.content.body, additionalContent: !!adapter?.content.additionalContent, preheaderText: !!adapter?.content.preheaderText, table: !!adapter?.table, unsubscribe: !!adapter?.unsubscribe, }; } function getAdapter(emailTypeId: string): EmailAdapter { if (!Object.prototype.hasOwnProperty.call(adapters, emailTypeId)) throw new EmailPersistenceError('This email type does not support saving.'); return adapters[emailTypeId]; } /** A stable comparison shape containing only fields supported by the real email sender. */ export function normalizeEmailDraft(emailTypeId: string, draft: EmailDraft): EmailDraft { const adapter = getAdapter(emailTypeId); const content: EmailContentSettings = { subject: draft.content.subject, heading: draft.content.heading, ...(adapter.content.body ? { body: draft.content.body } : {}), additionalContent: adapter.content.additionalContent ? draft.content.additionalContent : '', preheaderText: adapter.content.preheaderText ? draft.content.preheaderText : '', }; const productCard = draft.productCard && adapter.product ? { showImage: draft.productCard.showImage, showTitle: draft.productCard.showTitle, showPrice: draft.productCard.showPrice, buttonText: draft.productCard.buttonText, sample: draft.productCard.sample, } : undefined; const couponDisplay = draft.couponDisplay && adapter.coupon ? { boxBgColor: draft.couponDisplay.boxBgColor, boxBorderColor: draft.couponDisplay.boxBorderColor, boxTextColor: draft.couponDisplay.boxTextColor, codeBgColor: draft.couponDisplay.codeBgColor, messageText: draft.couponDisplay.messageText, } : undefined; return { content, ...(productCard ? { productCard } : {}), ...(couponDisplay ? { couponDisplay } : {}) }; } function readPath(value: unknown, path: Path): unknown { return path.reduce((current, key) => current != null && typeof current === 'object' ? (current as Record)[key] : undefined, value); } function writePath(value: unknown, [key, ...rest]: Path, next: unknown): unknown { if (key === undefined) return next; const copy: Record | unknown[] = Array.isArray(value) && typeof key === 'number' ? [...value] : { ...(value && typeof value === 'object' ? value : {}) }; (copy as Record)[key] = writePath(readPath(value, [key]), rest, next); return copy; } /** Update only saved email fields, retaining unrelated live drafts and their dirty state. */ export function reconcileEmailSaved(state: T, saved: Pick): T { return saved.patches.reduce((current, patch) => writePath(current, patch.path, patch.value) as T, state); } type ApiTransport = (path: string, method: 'GET' | 'POST', data?: unknown) => Promise>; const authenticatedTransport: ApiTransport = async (path, method, data) => { const config = window.swiftCommerceData; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 30000); try { const response = await fetch(`${config?.apiUrl || '/wp-json/swift-commerce/v1'}/${path}`, { method, credentials: 'same-origin', signal: controller.signal, headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config?.restNonce || '' }, ...(method === 'POST' ? { body: JSON.stringify(data) } : {}), }); if (!response.ok) throw new EmailPersistenceError(response.status === 401 || response.status === 403 ? 'Access could not be verified. Reload the dashboard and try again.' : 'Email settings could not be confirmed. Please try again.'); return await response.json(); } catch (error) { if (controller.signal.aborted) throw new EmailPersistenceError('The email settings request took too long. Please try again.'); throw error; } finally { clearTimeout(timeout); } }; function readSettings(response: Record, adapter: EmailAdapter, writing = false): Record { const value = response[adapter.responseKey]; // Tracking's existing GET response intentionally has no success member. if (response.success === false || (writing && response.success !== true) || !value || typeof value !== 'object' || Array.isArray(value)) { throw new EmailPersistenceError('Email settings could not be loaded or confirmed. Please try again.'); } return value as Record; } function draftFromSettings(emailTypeId: string, settings: Record, shape: EmailDraft): EmailDraft { const adapter = getAdapter(emailTypeId); const result = normalizeEmailDraft(emailTypeId, shape); const adopt = (mapping: Record | undefined, target: object | undefined) => { if (!mapping || !target) return; for (const [key, path] of Object.entries(mapping)) { const value = readPath(settings, path); const expectedType = key === 'showImage' || key === 'showTitle' || key === 'showPrice' ? 'boolean' : 'string'; if (typeof value !== expectedType) throw new EmailPersistenceError('The saved email content is incomplete. Please reload the email settings and try again.'); (target as Record)[key] = value; } }; adopt(adapter.content as Record, result.content); adopt(adapter.product as Record, result.productCard); adopt(adapter.coupon as Record, result.couponDisplay); return result; } /** Read a real persisted baseline without treating an outer form's unsaved draft as saved. */ export async function loadEmailDraft(emailTypeId: string, shape: EmailDraft, transport: ApiTransport = authenticatedTransport): Promise { const adapter = getAdapter(emailTypeId); const settings = readSettings(await transport(adapter.endpoint, 'GET'), adapter); return draftFromSettings(emailTypeId, settings, shape); } /** Persist through the feature's existing authenticated endpoint, never the outer form draft. */ export async function persistEmailDraft(emailTypeId: string, draft: EmailDraft, transport: ApiTransport = authenticatedTransport): Promise { const adapter = getAdapter(emailTypeId); const latest = readSettings(await transport(adapter.endpoint, 'GET'), adapter); const submitted: PersistedEmailDraft['patches'] = []; const collect = (mapping: Record | undefined, values: object | undefined) => { if (!mapping || !values) return; for (const [key, path] of Object.entries(mapping)) { const value = (values as Record)[key]; if (value !== undefined) submitted.push({ path, value }); } }; collect(adapter.content as Record, draft.content); collect(adapter.product as Record, draft.productCard); collect(adapter.coupon as Record, draft.couponDisplay); // Never create a sequence entry from a partially configured outer form. for (const sequence of ['emails', 'unpaidOrderEmails']) { if (submitted.some(({ path }) => path[0] === sequence && typeof path[1] === 'number') && !readPath(latest, [sequence, 0])) { throw new EmailPersistenceError('Save the email sequence before customizing its message.'); } } const payload = reconcileEmailSaved(latest, { patches: submitted }); const persisted = readSettings(await transport(adapter.endpoint, 'POST', payload), adapter, true); const patches = submitted.map(({ path }) => { const value = readPath(persisted, path); if (value === undefined) throw new EmailPersistenceError('The server did not confirm all email changes. Please reload the email settings.'); return { path, value }; }); return { emailTypeId, endpoint: adapter.endpoint, ...draftFromSettings(emailTypeId, persisted, draft), patches }; } const savedEvent = 'swift-commerce:email-saved'; /** Publish after local compatibility callbacks so normalized server values win. */ export function publishEmailSaved(saved: PersistedEmailDraft) { window.dispatchEvent(new CustomEvent(savedEvent, { detail: saved })); } export function subscribeEmailSaved(endpoint: string, onSaved: (saved: PersistedEmailDraft) => void) { const listener = (event: Event) => { const saved = (event as CustomEvent).detail; if (saved?.endpoint === endpoint) onSaved(saved); }; window.addEventListener(savedEvent, listener); return () => window.removeEventListener(savedEvent, listener); }