import { FileType, ProjectPlugin, ProjectPluginStructure, UIDLEcommerceSettings, UIDLInvoiceSettings, } from '@teleporthq/teleport-types' import { generateEcommerceContextFileContent } from './ecommerce-context-generator' import { generateStockCheckApiRoute, generateStoreLocationsApiRoute, generateProductVariantsApiRoute, generateDeliveryPriceApiRoute, generateOrderNotificationApiRoute, generateLowStockAlertApiRoute, generateEcommerceSettingsApiRoute, generatePaypalCaptureApiRoute, generatePaymentConfirmApiRoute, } from './ecommerce-api-routes-generator' import { generateDownloadApiRoute } from './ecommerce-download-route-generator' import { generateEmailSenderModule } from './email-sender-generator' import { declarePaymentCredentialEnv, ensureEmailLocaleModule, ensurePaymentDriversModule, ensureSentEmailLogModule, findPaymentWebhookRouteUrl, resolveStorePaymentDriverIds, } from '@teleporthq/teleport-plugin-next-workflows' import { generateCartApiRoute } from './cart-api-routes-generator' import { generatePaddlePayPage, generateRazorpayPayPage } from './payment-pages-generator' import { generateAssetsApiRoute, generateAssetUrlsModule } from './asset-urls-generator' import { buildCartPricingSettings, generateCartPricingSettingsModule, } from './cart-pricing-settings' export class NextEcommerceProjectPlugin implements ProjectPlugin { async runBefore(structure: ProjectPluginStructure): Promise { return structure } async runAfter(structure: ProjectPluginStructure): Promise { const { uidl, files, dependencies } = structure const ecommerceSettings = uidl.ecommerceSettings if (!ecommerceSettings) { // A component can reference the ecommerce/cart hook — the i18n locale // mapper injects `import { useEcommerce } from '@/ecommerce-context'` for // cart-bound nodes (e.g. a nav cart counter) — even when the project // carries no `ecommerceSettings`. Without the context file those imports // dangle and `next build` fails with "Module not found: Can't resolve // '@/ecommerce-context'". Emit a localStorage-only context (no payment // providers / delivery) and wrap _app, so the references resolve and a // basic cart still works. Projects that never reference it are untouched. if (projectReferencesEcommerceContext(files)) { this.generateContextFile( {} as UIDLEcommerceSettings, undefined, files, null, false, false, false, null ) this.injectProviderIntoApp(files) } return structure } const { dataSourceType, dataSourceConfig, dataSourceId } = this.resolveDataSource(structure) // The DB-backed cart route only generates for Postgres datasources; // when it does, the provider layers DB sync on top of localStorage. const cartRoute = generateCartApiRoute(dataSourceType, dataSourceConfig) const cartDbEnabled = cartRoute !== null // Product media stored as a PROJECT-ASSET ID only becomes a URL if the // project also ships the `teleport_assets` reader — the shared // `utils/ecommerce/asset-urls` module and the `/api/ecommerce/assets` route // below. Both come from the same generator, so this ONE flag decides // whether the cart provider resolves ids or uses the stored value verbatim // (which is all a non-Postgres store ever needed: its media is direct URLs). const assetsRoute = generateAssetsApiRoute(dataSourceType, dataSourceConfig) const assetLookupEnabled = !!dataSourceId && assetsRoute !== null this.generateContextFile( ecommerceSettings, uidl.invoiceSettings, files, dataSourceId, cartDbEnabled, // Publish the workflow settings global — this branch also emits the // /api/ecommerce/settings route with the identical payload. true, assetLookupEnabled, dataSourceType ) this.generateApiRoutes( ecommerceSettings, uidl.invoiceSettings, dataSourceType, dataSourceConfig, structure ) if (assetsRoute) { this.generateAssetUrlFiles(assetsRoute, files) } if (cartRoute) { // The route validates the language it stamps on a cart through the // shared locale module. ensureEmailLocaleModule(structure) files.set('ecommerce-api-cart', { path: ['pages', 'api', 'cart'], files: [ { name: '[op]', fileType: FileType.JS, content: cartRoute, }, ], }) } this.addDependencies(ecommerceSettings, dependencies) this.addEnvVars(ecommerceSettings, uidl) this.injectProviderIntoApp(files) return structure } private resolveDataSource(structure: ProjectPluginStructure): { dataSourceType: string | null dataSourceConfig: Record | null dataSourceId: string | null } { const { uidl } = structure if (uidl.authentication?.dataSourceId && uidl.dataSources) { const ds = uidl.dataSources[uidl.authentication.dataSourceId] if (ds) { return { dataSourceType: ds.type, dataSourceConfig: ds.config, dataSourceId: uidl.authentication.dataSourceId, } } } if (uidl.dataSources) { const ids = Object.keys(uidl.dataSources) if (ids.length > 0) { return { dataSourceType: uidl.dataSources[ids[0]].type, dataSourceConfig: uidl.dataSources[ids[0]].config, dataSourceId: ids[0], } } } return { dataSourceType: null, dataSourceConfig: null, dataSourceId: null } } private generateContextFile( ecommerceSettings: UIDLEcommerceSettings, invoiceSettings: UIDLInvoiceSettings | undefined, files: Map, dataSourceId: string | null, cartDbEnabled: boolean, emitWorkflowSettingsGlobal: boolean, assetLookupEnabled: boolean, dataSourceType: string | null ): void { const content = generateEcommerceContextFileContent( ecommerceSettings, invoiceSettings, dataSourceId, cartDbEnabled, emitWorkflowSettingsGlobal, assetLookupEnabled, dataSourceType ) files.set('ecommerce-context', { path: [], files: [ { name: 'ecommerce-context', fileType: FileType.JS, content, }, ], }) // The same pricing snapshot for the checkout's server step. Only with the // merchant's real settings: the settings-less fallback context prices // nothing, and a server step must refuse rather than price from defaults. if (emitWorkflowSettingsGlobal) { files.set('ecommerce-cart-pricing-settings', { path: ['utils', 'ecommerce'], files: [ { name: 'cart-pricing-settings', fileType: FileType.JS, content: generateCartPricingSettingsModule( buildCartPricingSettings( ecommerceSettings, invoiceSettings, dataSourceId, dataSourceType ) ), }, ], }) } } /** * The two halves of project-asset resolution: the shared * `utils/ecommerce/asset-urls` module (id → URL contract, plus the browser's * batched lookup) and the `/api/ecommerce/assets` route that reads the * `teleport_assets` mirror for it. * * Emitted together, and only when the datasource can answer the route — * `generateEcommerceContextFileContent` gates its `import` on exactly the * same condition, so the module is never imported without being written and * never written without a caller. */ private generateAssetUrlFiles(assetsRoute: string, files: Map): void { files.set('ecommerce-asset-urls', { path: ['utils', 'ecommerce'], files: [ { name: 'asset-urls', fileType: FileType.JS, content: generateAssetUrlsModule(), }, ], }) files.set('ecommerce-api-assets', { path: ['pages', 'api', 'ecommerce'], files: [ { name: 'assets', fileType: FileType.JS, content: assetsRoute, }, ], }) } private generateApiRoutes( settings: UIDLEcommerceSettings, // Only the order-notification route needs these: the item lines it reads // from `teleport_order_items` are NET, and the merchant's copy of the order // has to quote what the buyer paid. invoiceSettings: UIDLInvoiceSettings | undefined, dataSourceType: string | null, dataSourceConfig: Record | null, structure: ProjectPluginStructure ): void { const { files } = structure files.set('ecommerce-api-settings', { path: ['pages', 'api', 'ecommerce'], files: [ { name: 'settings', fileType: FileType.JS, content: generateEcommerceSettingsApiRoute(settings, invoiceSettings), }, ], }) if (settings.stockManagement) { files.set('ecommerce-api-stock-check', { path: ['pages', 'api', 'ecommerce'], files: [ { name: 'stock-check', fileType: FileType.JS, content: generateStockCheckApiRoute(settings, dataSourceType, dataSourceConfig), }, ], }) } // The door to a store's private digital files — only when the checkout // and the order page were built to sell and deliver them. if (settings.digitalProducts?.enabled) { files.set('ecommerce-api-downloads', { path: ['pages', 'api', 'downloads'], files: [ { name: '[fileId]', fileType: FileType.JS, content: generateDownloadApiRoute(settings, dataSourceType, dataSourceConfig), }, ], }) } if (settings.storePickupEnabled) { files.set('ecommerce-api-store-locations', { path: ['pages', 'api', 'ecommerce'], files: [ { name: 'store-locations', fileType: FileType.JS, content: generateStoreLocationsApiRoute(dataSourceType, dataSourceConfig), }, ], }) } // Product-variants endpoint — always emitted when e-commerce is on so the // variant-picker widget can fetch a product's combinations. Inert (returns // []) for products/stores without variants, so it's harmless when unused. files.set('ecommerce-api-variants', { path: ['pages', 'api', 'ecommerce'], files: [ { name: 'variants', fileType: FileType.JS, content: generateProductVariantsApiRoute(dataSourceType, dataSourceConfig), }, ], }) // PayPal capture endpoint — only emitted when the project actually // offers PayPal. PayPal Orders v2 with `intent: 'CAPTURE'` requires the // merchant to explicitly capture the order after the buyer approves; // skipping this call leaves orders in `APPROVED` state and PayPal // never fires the corresponding `PAYMENT.CAPTURE.*` webhooks. const hasPaypal = (settings.paymentProviders || []).some( (p) => p && (p.type === 'paypal' || p.name === 'PayPal') ) if (hasPaypal) { ensurePaymentDriversModule(structure, resolveStorePaymentDriverIds(structure.uidl, [])) files.set('ecommerce-api-paypal-capture', { path: ['pages', 'api', 'ecommerce', 'paypal'], files: [ { name: 'capture', fileType: FileType.JS, content: generatePaypalCaptureApiRoute( findPaymentWebhookRouteUrl(structure.uidl.workflows, 'paypal') ), }, ], }) } // The confirmation the order page asks while a buyer back from any // provider waits: emitted when some provider's webhook route can settle. const settleUrls: Record = {} for (const providerId of resolveStorePaymentDriverIds(structure.uidl, [])) { const settleUrl = findPaymentWebhookRouteUrl(structure.uidl.workflows, providerId) if (settleUrl) { settleUrls[providerId] = settleUrl } } if (Object.keys(settleUrls).length > 0) { ensurePaymentDriversModule(structure, resolveStorePaymentDriverIds(structure.uidl, [])) files.set('ecommerce-api-payment-confirm', { path: ['pages', 'api', 'ecommerce', 'payments'], files: [ { name: 'confirm', fileType: FileType.JS, content: generatePaymentConfirmApiRoute(settleUrls), }, ], }) } // The store's own pages that open a provider's checkout in the browser. const browserCheckoutPages: Array<[string, () => string]> = [ ['paddle', generatePaddlePayPage], ['razorpay', generateRazorpayPayPage], ] for (const [providerId, generatePage] of browserCheckoutPages) { if ((settings.paymentProviders || []).some((p) => p && p.type === providerId)) { ensurePaymentDriversModule(structure, resolveStorePaymentDriverIds(structure.uidl, [])) files.set(`payment-page-${providerId}`, { path: ['pages', 'pay'], files: [{ name: providerId, fileType: FileType.JS, content: generatePage() }], }) } } if (settings.deliveryEnabled && settings.deliveryConfig) { files.set('ecommerce-api-delivery-price', { path: ['pages', 'api', 'ecommerce'], files: [ { name: 'delivery-price', fileType: FileType.JS, content: generateDeliveryPriceApiRoute(settings), }, ], }) } if (settings.orderNotifications && settings.orderNotificationConfig) { files.set('ecommerce-api-order-notification', { path: ['pages', 'api', 'ecommerce'], files: [ { name: 'order-notification', fileType: FileType.JS, // The datasource is passed so the route can load the order's // persisted lines when a caller (the Stripe / PayPal webhooks) // knows the orderId but has no cart to send. content: generateOrderNotificationApiRoute( settings, dataSourceType, dataSourceConfig, invoiceSettings ), }, ], }) } // Low-stock alerts: only emit the endpoint AND the shared sender // when the merchant turned the feature on AND configured templates. // Skipping the route entirely (rather than emitting a 200-no-op // version) keeps the data-api's fire-and-forget POST cheap — it // short-circuits on LOW_STOCK_ALERTS_ENABLED=false before touching // the network at all. const stockAlertsActive = !!( settings.stockManagement && settings.stockManagementConfig?.lowStockAlerts && settings.stockManagementConfig?.lowStockAlertConfig?.provider ) if (stockAlertsActive) { files.set('ecommerce-api-low-stock-alert', { path: ['pages', 'api', 'ecommerce'], files: [ { name: 'low-stock-alert', fileType: FileType.JS, content: generateLowStockAlertApiRoute(settings), }, ], }) } // Shared email-sender module — consumed by order-notification and // low-stock-alert endpoints. Emitted whenever EITHER consumer is // active so the require() in those endpoints resolves at runtime. const orderNotifActive = !!( settings.orderNotifications && settings.orderNotificationConfig?.provider ) if (orderNotifActive || stockAlertsActive) { // The sender records every attempt in the sent-email ledger. ensureSentEmailLogModule(structure) files.set('ecommerce-email-sender', { path: ['utils', 'ecommerce'], files: [ { name: 'email-sender', fileType: FileType.JS, content: generateEmailSenderModule(settings, { logTag: 'email-sender' }), }, ], }) } } private addDependencies( settings: UIDLEcommerceSettings, dependencies: Record ): void { // Walk EVERY consumer of the shared email-sender — order // notifications today, low-stock alerts today, anything else // tomorrow. The first non-empty provider wins (the sender // module collapses them into a single provider switch). This // matches the merchant's typical "one transactional provider // per project" setup and keeps the dependency list minimal. const emailProviders = [ settings.orderNotifications && settings.orderNotificationConfig?.provider, settings.stockManagement && settings.stockManagementConfig?.lowStockAlerts && settings.stockManagementConfig?.lowStockAlertConfig?.provider, ].filter(Boolean) as string[] if (emailProviders.length > 0) { const provider = emailProviders[0] switch (provider) { case 'sendgrid': dependencies['@sendgrid/mail'] = '^8.0.0' break case 'resend': dependencies.resend = '^2.0.0' break case 'postmark': // Postmark goes through the email-sender's direct HTTPS // implementation — no npm dependency. The fetch polyfill // pulls node-fetch only on Node runtimes that lack a // native fetch (Node < 18); production typically runs on // newer Node so this is rarely loaded. dependencies['node-fetch'] = '^2.7.0' break case 'mailgun': case 'mailersend': default: // SMTP fallback covers anything we don't explicitly // recognise. Same nodemailer version as before. dependencies.nodemailer = '^7.0.7' break } } } private addEnvVars(settings: UIDLEcommerceSettings, uidl: ProjectPluginStructure['uidl']): void { if (!uidl.globals.env) { uidl.globals.env = {} } const paymentProviders = settings.paymentProviders || [] const providerTypes = paymentProviders.map((p) => p.type) // Every saved provider credential as a placeholder the deploy resolves to // its stored secret. A store exported before the UIDL carried the secret // names gets the canonical Stripe / PayPal lines empty, as before: the // deploy fills those from the project's secrets by name. declarePaymentCredentialEnv(uidl) const emptyCanonical: Record = { stripe: ['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'], paypal: ['PAYPAL_CLIENT_ID', 'PAYPAL_CLIENT_SECRET', 'PAYPAL_WEBHOOK_ID'], } for (const [provider, names] of Object.entries(emptyCanonical)) { if (providerTypes.includes(provider)) { for (const name of names) { if (!uidl.globals.env[name]) { uidl.globals.env[name] = '' } } } } // Same provider-aggregation as addDependencies — see the // comment there. The env var only matters when SOMEONE is // going to dispatch an email; both consumers funnel through // the same env vars because they share the sender module. const emailProvider = (settings.orderNotifications && settings.orderNotificationConfig?.provider) || (settings.stockManagement && settings.stockManagementConfig?.lowStockAlerts && settings.stockManagementConfig?.lowStockAlertConfig?.provider) || null if (emailProvider) { switch (emailProvider) { case 'sendgrid': if (!uidl.globals.env.SENDGRID_API_KEY) { uidl.globals.env.SENDGRID_API_KEY = '' } break case 'resend': if (!uidl.globals.env.RESEND_API_KEY) { uidl.globals.env.RESEND_API_KEY = '' } break case 'postmark': if (!uidl.globals.env.POSTMARK_SERVER_TOKEN) { uidl.globals.env.POSTMARK_SERVER_TOKEN = '' } // MessageStream defaults to 'outbound' in the sender; the // env var lets a merchant pin a different stream without // re-exporting the project. if (!uidl.globals.env.POSTMARK_MESSAGE_STREAM) { uidl.globals.env.POSTMARK_MESSAGE_STREAM = 'outbound' } break default: if (!uidl.globals.env.SMTP_HOST) { uidl.globals.env.SMTP_HOST = '' } if (!uidl.globals.env.SMTP_PORT) { uidl.globals.env.SMTP_PORT = '587' } if (!uidl.globals.env.SMTP_USER) { uidl.globals.env.SMTP_USER = '' } if (!uidl.globals.env.SMTP_PASS) { uidl.globals.env.SMTP_PASS = '' } break } const fromCandidate = settings.orderNotificationConfig?.fromEmail || settings.stockManagementConfig?.lowStockAlertConfig?.fromEmail || '' if (!uidl.globals.env.ORDER_NOTIFICATION_FROM_EMAIL) { uidl.globals.env.ORDER_NOTIFICATION_FROM_EMAIL = fromCandidate } } } private injectProviderIntoApp(files: Map): void { let appFile: any = null for (const [key, record] of Array.from(files.entries())) { if (key === '_app' || key.includes('_app')) { appFile = record.files?.find( (f: any) => f.name === '_app' && (f.fileType === 'js' || f.fileType === 'tsx') ) if (appFile) { break } } } if (!appFile || typeof appFile.content !== 'string') { return } if (appFile.content.includes('EcommerceProvider')) { return } let content = appFile.content const importStatement = `import { EcommerceProvider } from '../ecommerce-context';\n` const firstImportIdx = content.indexOf('import ') if (firstImportIdx >= 0) { content = content.slice(0, firstImportIdx) + importStatement + content.slice(firstImportIdx) } else { content = importStatement + content } const returnMatch = content.match(/return\s*\(\s*/) if (returnMatch && returnMatch.index !== undefined) { const afterReturn = returnMatch.index + returnMatch[0].length const restContent = content.slice(afterReturn) const closingParenIdx = findMatchingClosingParen(restContent) if (closingParenIdx >= 0) { const innerJSX = restContent.slice(0, closingParenIdx) const afterClosing = restContent.slice(closingParenIdx) content = content.slice(0, afterReturn) + `${innerJSX}` + afterClosing } } appFile.content = content } } // True when any already-generated file imports the local `@/ecommerce-context` // module. Used to decide whether a settings-less project still needs the // context file emitted (so injected cart-hook imports don't dangle). function projectReferencesEcommerceContext(files: Map): boolean { for (const [, record] of Array.from(files.entries())) { for (const file of record.files || []) { if (typeof file.content === 'string' && file.content.includes('@/ecommerce-context')) { return true } } } return false } function findMatchingClosingParen(str: string): number { let depth = 0 for (let i = 0; i < str.length; i++) { const ch = str[i] if (ch === '(') { depth++ } if (ch === ')') { if (depth === 0) { return i } depth-- } } return -1 }