diff --git a/.gitignore b/.gitignore index e4b7401..c908d28 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,5 @@ apps/backend/dist/ zip/ -claude --resume 4d70ff1e-c37e-4e4d-ba08-121ee0b8450f \ No newline at end of file +claude --resume 4d70ff1e-c37e-4e4d-ba08-121ee0b8450f +.ec2.env diff --git a/META_RETURN_PRIME.md b/META_RETURN_PRIME.md new file mode 100644 index 0000000..035f9a7 --- /dev/null +++ b/META_RETURN_PRIME.md @@ -0,0 +1,54 @@ +# Meta & Return Prime Integrations + +Two merchant-facing apps built on the Ratio App Ecosystem, each solving a problem created by running on a custom (non-native-Shopify) commerce stack: merchants lose out-of-the-box access to standard Shopify-app integrations, so we rebuilt the bridge ourselves. + +--- + +## 1. Meta (Facebook Pixel + Conversions API + Catalog Sync) + +**Status:** built, local-tested (not yet deployed) + +### What we built +- A self-serve admin app where a merchant connects their Facebook Pixel + Conversions API (CAPI) access token — no manual dev work per merchant. +- **Client-side tracking**: a per-merchant Pixel script (`/meta/sdk/:merchantId.js`) that the storefront loads, config-driven from the DB (pixel ID, event settings) — no hardcoded merchant values. +- **Server-side tracking (CAPI)**: events are forwarded directly to Meta's Graph API from our backend, not just the browser. Personal data (email, phone) is SHA-256-hashed before it ever leaves our servers. +- **Reliability layer**: CAPI events are queued (AWS SQS) and dispatched in batches by a background worker, so a Meta API hiccup doesn't drop events or block checkout. +- **Catalog Sync (Phase 2)**: product feed automatically kept in sync with Meta's Commerce Manager (create/update/delete webhooks + a pull-based feed endpoint), so merchants don't have to hand-manage their product catalog for ads. +- **Security**: access tokens are encrypted at rest and never exposed to the browser or returned by the API — only a "connected" flag. +- A stats view showing each merchant their own event delivery numbers. + +### Why it matters +- **Better ad performance**: server-side CAPI events aren't blocked by ad-blockers or iOS tracking restrictions the way browser-only Pixel tracking is — merchants get more complete, more accurate conversion data, which directly improves Meta's ad-targeting/optimization and lowers cost-per-acquisition. +- **Retargeting without manual work**: catalog sync means dynamic product ads and retargeting "stay fresh" automatically as merchants add/change/remove products. +- **Zero engineering lift per merchant**: any merchant on the platform can connect their own Pixel/catalog in minutes, self-serve — this used to require a bespoke integration per store. +- **Safe by default**: hashing + encryption means we can offer this without creating a PII liability. + +--- + +## 2. Return Prime (Returns & Exchange Management) + +**Status:** built (backend module `rp` + `admin-rp` config app) + +### The problem +Return Prime is a returns/exchange-automation product built to talk to **Shopify's Admin API**. Our merchants run on GoKwik's custom order/checkout backend ("OS") instead of native Shopify, so Return Prime can't talk to them directly — there's no orders/refunds/discounts API in the shape it expects. + +### What we built +A full **Shopify-Admin-API-compatible adapter layer** that sits between Return Prime and our real backend (GoKwik OS Order Service + Ratio App Ecosystem), so Return Prime works exactly as it would on a native Shopify store: +- **Orders, refunds, customers, products, discounts** — each proxied and reshaped from our backend's format into the exact JSON shape Return Prime/Shopify expects (and back). +- **OAuth install flow** — merchants install Return Prime like any other app; we issue and store encrypted access/refresh tokens per merchant. +- **Webhooks bridge** — order/product events from our backend are translated into the Shopify-style webhook payloads Return Prime listens for. +- **Self-serve customer return portal** — a hosted portal (`/rp/customer/portal`) customers land on to request a return or exchange, no support-ticket needed. +- **Exchange discount codes** — when a customer exchanges instead of refunds, we auto-generate a discount code via the Ratio API so they can complete the swap at checkout. +- **Merchant admin app (`admin-rp`)** — a small config UI merchants use to confirm their store is registered/connected. + +### Why it matters +- **Unlocks an off-the-shelf app for a non-standard stack**: without this adapter, Return Prime — and by extension automated, self-serve returns — simply isn't possible on our checkout infrastructure. This closes that gap without asking Return Prime to change anything on their end. +- **Less manual support work**: customers can request/track returns and exchanges themselves through the portal instead of emailing support. +- **Faster exchanges, less refund leakage**: auto-generated exchange discount codes nudge customers toward exchanges over refunds. +- **Reusable pattern**: this "adapter layer" approach (transform + proxy to Shopify-shaped API) is now a template for onboarding *any* other Shopify-app-ecosystem tool onto our custom backend in the future. + +--- + +## Common thread + +Both integrations exist because our merchants run on a custom commerce backend (GoKwik/Ratio) instead of vanilla Shopify, which normally cuts them off from the standard app ecosystem (Meta's own Shopify app, Return Prime's Shopify app, etc.). In both cases we rebuilt the missing bridge ourselves — one as a first-party tracking pipeline (Meta), the other as a protocol-compatibility shim (Return Prime) — so merchants get the same app-store experience as a standard Shopify merchant would. diff --git a/apps/admin-rp/index.html b/apps/admin-rp/index.html new file mode 100644 index 0000000..45156e0 --- /dev/null +++ b/apps/admin-rp/index.html @@ -0,0 +1,12 @@ + + + + + + Return Prime — Ratio App + + +
+ + + diff --git a/apps/admin-rp/package.json b/apps/admin-rp/package.json new file mode 100644 index 0000000..54f1681 --- /dev/null +++ b/apps/admin-rp/package.json @@ -0,0 +1,38 @@ +{ + "name": "@ratio-app/admin-rp", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:cov": "vitest run --coverage", + "lint": "biome check src" + }, + "dependencies": { + "@primathonos/orion": "^0.4.2", + "@ratio-app/shared": "workspace:*", + "@tanstack/react-query": "^5.59.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@biomejs/biome": "2.1.0", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "@vitest/coverage-v8": "^2.1.0", + "happy-dom": "^15.0.0", + "typescript": "^5.6.0", + "vite": "^6.0.0", + "vitest": "^2.1.0" + } +} diff --git a/apps/admin-rp/src/App.tsx b/apps/admin-rp/src/App.tsx new file mode 100644 index 0000000..0010d41 --- /dev/null +++ b/apps/admin-rp/src/App.tsx @@ -0,0 +1,272 @@ +import { + Card, + Form, + Input, + OrionProvider, + PrimaryButton, + Result, + Spin, + Typography, +} from '@primathonos/orion'; +import { useEffect, useState } from 'react'; +import { useIframeAuth } from '@/hooks/useIframeAuth'; +import { ApiException, api } from '@/lib/api'; +import { installPostMessageListener, readSession } from '@/lib/session'; +import { useMerchantStore } from '@/stores/useMerchantStore'; +import './index.css'; + +const RP_ADMIN_URL = (import.meta.env.VITE_RP_ADMIN_URL as string | undefined) ?? ''; + +function centered(children: React.ReactNode) { + return ( +
+ {children} +
+ ); +} + +function RegisterScreen() { + const [loading, setLoading] = useState(false); + const [domainLoading, setDomainLoading] = useState(true); + const [registered, setRegistered] = useState(false); + const [merchantDomain, setMerchantDomain] = useState(null); + const [error, setError] = useState(null); + const [form] = Form.useForm<{ + store_domain: string; + admin_email: string; + admin_password: string; + confirm_password: string; + }>(); + + useEffect(() => { + api<{ domain: string; registered: boolean }>('GET', '/api/admin/merchants/me') + .then((me) => { + setMerchantDomain(me.domain); + if (me.registered) { + setRegistered(true); + } else { + form.setFieldsValue({ + store_domain: me.domain, + admin_email: `admin@${me.domain}`, + }); + } + }) + .catch(() => {}) + .finally(() => setDomainLoading(false)); + }, [form]); + + async function handleRegister(values: { + store_domain: string; + admin_email: string; + admin_password: string; + }) { + setLoading(true); + setError(null); + try { + const res = await api<{ domain: string }>('POST', '/api/admin/register', { + store_domain: values.store_domain, + admin_email: values.admin_email, + admin_password: values.admin_password, + }); + setMerchantDomain(res.domain ?? values.store_domain); + setRegistered(true); + } catch (err) { + setError( + err instanceof ApiException ? err.message : 'Registration failed. Please try again.', + ); + } finally { + setLoading(false); + } + } + + if (registered) { + const rpUrl = merchantDomain + ? `${RP_ADMIN_URL}/user/login?store=${merchantDomain}` + : RP_ADMIN_URL; + const adapterUrl = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? ''; + const sdkSrc = `${adapterUrl}/rp/sdk/rp-portal.js?store=${encodeURIComponent(merchantDomain ?? '')}&redirectTo=/apps/return_prime`; + const scriptSnippet = [ + ``, + ``, + ``, + ``, + ].join('\n'); + + return centered( +
+ window.open(rpUrl, '_blank')}> + Open Return Prime Dashboard + + ) : null + } + /> +
+ Storefront SDK snippet +
+            {scriptSnippet}
+          
+
+
, + ); + } + + if (domainLoading) return centered(); + + return centered( +
+ + + Create your Return Prime admin account to start managing returns. + + {error && ( + + {error} + + )} +
+ + { + const domain = e.target.value.trim(); + if (domain) { + form.setFieldValue('admin_email', `admin@${domain}`); + } + }} + /> + + + + + + + + ({ + validator(_, value) { + if (!value || getFieldValue('admin_password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('Passwords do not match')); + }, + }), + ]} + > + + + + + Register in Return Prime + + +
+
+
, + ); +} + +export function App() { + const { isAuthorized, parentOrigin } = useIframeAuth(); + const token = useMerchantStore((s) => s.token); + const setToken = useMerchantStore((s) => s.setToken); + const [sessionChecked, setSessionChecked] = useState(false); + + useEffect(() => { + setToken(readSession()); + setSessionChecked(true); + return installPostMessageListener((id) => setToken(id)); + }, [setToken]); + + if (isAuthorized === null) return centered(); + + if (!isAuthorized) { + return centered( + , + ); + } + + if (!sessionChecked) return centered(); + + if (!token) { + return centered( + , + ); + } + + return ; +} + +export function Root() { + return ( + + + + ); +} diff --git a/apps/admin-rp/src/hooks/useIframeAuth.ts b/apps/admin-rp/src/hooks/useIframeAuth.ts new file mode 100644 index 0000000..d0c3530 --- /dev/null +++ b/apps/admin-rp/src/hooks/useIframeAuth.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; + +export interface IframeAuthState { + isAuthorized: boolean | null; + parentOrigin: string | null; +} + +const ALLOWED_HOST_SUFFIXES = ['.gokwik.co', '.gokwik.in']; + +const REQUIRE_IFRAME = + ((import.meta.env.VITE_REQUIRE_IFRAME as string | undefined) ?? 'true').toLowerCase() !== 'false'; + +function isLocalhost(host: string): boolean { + return host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0'; +} + +function isAllowedOrigin(origin: string): boolean { + try { + const url = new URL(origin); + if (url.protocol !== 'https:') return false; + return ALLOWED_HOST_SUFFIXES.some( + (suffix) => url.hostname === suffix.slice(1) || url.hostname.endsWith(suffix), + ); + } catch { + return false; + } +} + +function detectParentOrigin(): string | null { + const ancestors = (window.location as Location & { ancestorOrigins?: DOMStringList }) + .ancestorOrigins; + if (ancestors && ancestors.length > 0) return ancestors[0] ?? null; + return document.referrer || null; +} + +export function useIframeAuth(): IframeAuthState { + const [state, setState] = useState({ isAuthorized: null, parentOrigin: null }); + + useEffect(() => { + if (typeof window === 'undefined') return; + if (!REQUIRE_IFRAME) { + setState({ isAuthorized: true, parentOrigin: null }); + return; + } + if (isLocalhost(window.location.hostname)) { + setState({ isAuthorized: true, parentOrigin: null }); + return; + } + if (window.self === window.top) { + setState({ isAuthorized: false, parentOrigin: null }); + return; + } + const parent = detectParentOrigin(); + setState({ + isAuthorized: parent ? isAllowedOrigin(parent) : false, + parentOrigin: parent, + }); + }, []); + + return state; +} diff --git a/apps/admin-rp/src/index.css b/apps/admin-rp/src/index.css new file mode 100644 index 0000000..9cc8987 --- /dev/null +++ b/apps/admin-rp/src/index.css @@ -0,0 +1,27 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + padding: 0; + height: 100%; + font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; + background: #fafafa; +} + +.container { + max-width: 600px; + margin: 0 auto; + padding: 1.5rem 1rem; +} + +@media (max-width: 600px) { + .container { + padding: 1rem 0.75rem; + } +} diff --git a/apps/admin-rp/src/lib/api.ts b/apps/admin-rp/src/lib/api.ts new file mode 100644 index 0000000..763e0f9 --- /dev/null +++ b/apps/admin-rp/src/lib/api.ts @@ -0,0 +1,42 @@ +import { useMerchantStore } from '@/stores/useMerchantStore'; + +export class ApiException extends Error { + constructor( + message: string, + public readonly status: number, + public readonly errorCode?: string, + ) { + super(message); + this.name = 'ApiException'; + } +} + +// In dev: Vite proxy routes /rp/* → :3100 (relative path works). +// In production (marketplace ZIP): must use an absolute VITE_API_BASE_URL so +// the CDN-served SPA can reach the backend. +const RAW_BASE = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? ''; +const BASE = RAW_BASE + ? RAW_BASE.endsWith('/') + ? `${RAW_BASE.slice(0, -1)}/rp` + : `${RAW_BASE}/rp` + : '/rp'; + +export async function api(method: string, path: string, body?: unknown): Promise { + const headers: Record = { accept: 'application/json' }; + if (body !== undefined) headers['content-type'] = 'application/json'; + const token = useMerchantStore.getState().token; + if (token) headers.authorization = `Bearer ${token}`; + const init: RequestInit = { method, headers, credentials: 'include' }; + if (body !== undefined) init.body = JSON.stringify(body); + const res = await fetch(`${BASE}${path}`, init); + const text = await res.text(); + const json = text ? (JSON.parse(text) as Record) : {}; + if (!res.ok) { + throw new ApiException( + (json.message as string) ?? res.statusText, + res.status, + json.error_code as string | undefined, + ); + } + return (json.data ?? json) as T; +} diff --git a/apps/admin-rp/src/lib/session.ts b/apps/admin-rp/src/lib/session.ts new file mode 100644 index 0000000..01521dd --- /dev/null +++ b/apps/admin-rp/src/lib/session.ts @@ -0,0 +1,31 @@ +const STORAGE_KEY = 'rp-ratio:merchant-id'; + +export function readSession(): string | null { + const url = new URL(window.location.href); + const fromUrl = url.searchParams.get('merchant-id'); + if (fromUrl) { + window.localStorage.setItem(STORAGE_KEY, fromUrl); + url.searchParams.delete('merchant-id'); + window.history.replaceState({}, '', url.toString()); + return fromUrl; + } + return window.localStorage.getItem(STORAGE_KEY); +} + +export function clearSession(): void { + window.localStorage.removeItem(STORAGE_KEY); +} + +export function installPostMessageListener(onSession: (id: string) => void): () => void { + const allowed = (import.meta.env.VITE_RATIO_DASHBOARD_ORIGIN as string | undefined) ?? ''; + const handler = (ev: MessageEvent): void => { + if (allowed && ev.origin !== allowed) return; + const data = ev.data as { type?: string; merchantId?: string } | null; + if (data?.type === 'ratio:session' && typeof data.merchantId === 'string') { + window.localStorage.setItem(STORAGE_KEY, data.merchantId); + onSession(data.merchantId); + } + }; + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); +} diff --git a/apps/admin-rp/src/main.tsx b/apps/admin-rp/src/main.tsx new file mode 100644 index 0000000..5591530 --- /dev/null +++ b/apps/admin-rp/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Root } from './App'; + +const root = document.getElementById('root'); +if (!root) throw new Error('#root element not found'); + +createRoot(root).render( + + + , +); diff --git a/apps/admin-rp/src/stores/useMerchantStore.ts b/apps/admin-rp/src/stores/useMerchantStore.ts new file mode 100644 index 0000000..8e43d9b --- /dev/null +++ b/apps/admin-rp/src/stores/useMerchantStore.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +interface MerchantState { + token: string | null; + setToken: (token: string | null) => void; + clear: () => void; +} + +export const useMerchantStore = create((set) => ({ + token: null, + setToken: (token) => set({ token }), + clear: () => set({ token: null }), +})); diff --git a/apps/admin-rp/src/test-setup.ts b/apps/admin-rp/src/test-setup.ts new file mode 100644 index 0000000..bb02c60 --- /dev/null +++ b/apps/admin-rp/src/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; diff --git a/apps/admin-rp/tsconfig.json b/apps/admin-rp/tsconfig.json new file mode 100644 index 0000000..05eba2a --- /dev/null +++ b/apps/admin-rp/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "rootDir": "../..", + "outDir": "dist", + "noEmit": true, + "types": ["node", "vite/client"], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/admin-rp/vite.config.ts b/apps/admin-rp/vite.config.ts new file mode 100644 index 0000000..1514983 --- /dev/null +++ b/apps/admin-rp/vite.config.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +const ENV_FILES: Array = [['../../.env', false]]; +if (process.env.NODE_ENV === 'production') { + ENV_FILES.push(['../../.env.production', true]); +} +for (const [relPath, override] of ENV_FILES) { + const filePath = resolve(__dirname, relPath); + try { + for (const line of readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 1) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!key.startsWith('VITE_')) continue; + if (override || !(key in process.env)) { + process.env[key] = value; + } + } + console.log(`[vite] loaded env from ${filePath}${override ? ' (override)' : ''}`); + } catch { + // file not present — fine for CI + } +} + +export default defineConfig({ + base: './', + plugins: [react()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 5000, + proxy: { + '/rp/api/admin': { target: 'http://localhost:3100', changeOrigin: true }, + }, + }, + test: { + environment: 'happy-dom', + setupFiles: ['./src/test-setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'json-summary'], + reportsDirectory: './coverage', + include: ['src/**/*.{ts,tsx}'], + exclude: ['src/**/*.test.{ts,tsx}', 'src/test-setup.ts', 'src/main.tsx'], + }, + }, +}); diff --git a/apps/backend/FLOW.md b/apps/backend/FLOW.md new file mode 100644 index 0000000..1751a6d --- /dev/null +++ b/apps/backend/FLOW.md @@ -0,0 +1,352 @@ +# RP Adapter — Full End-to-End Flow + +> **Format:** vertical box-and-arrow (top-to-bottom). +> Actor labels sit above each box. Arrows carry the call / payload. +> Real function names, env vars, and header names used throughout. + +--- + +## Legend + +| Actor | Symbol | +|-------|--------| +| Merchant browser | `[ MERCHANT ]` | +| Customer browser | `[ CUSTOMER ]` | +| RP Adapter :3100 | `[ ADAPTER ]` | +| RP Backend :4000 | `[ RP BE ]` | +| OS Order Service | `[ OS-ORDER ]` | +| OS Item Service | `[ OS-ITEM ]` | +| MySQL (rp_app) | `[ MYSQL ]` | +| MongoDB (return_prime_local) | `[ MONGO ]` | + +--- + +## Phase 1 — Merchant installs app from Ratio App Store + +``` +[ MERCHANT ] +┌──────────────────────────────────────────────────────────────────────┐ +│ Opens Ratio App Store → finds "Return Prime" → clicks Install │ +└────────────────────────────┬─────────────────────────────────────────┘ + │ Ratio constructs OAuth URL, redirects + ▼ +[ ADAPTER ] GET /rp/auth/callback?code= +┌──────────────────────────────────────────────────────────────────────┐ +│ RpAuthController.callback() │ +│ │ +│ POST RATIO_API_BASE_URL/api/v1/oauth/token │ +│ { grant_type: "authorization_code", code, │ +│ clientId: RATIO_RP_CLIENT_ID, │ +│ clientSecret: RATIO_RP_CLIENT_SECRET } │ +│ ← { access_token, refresh_token, expires_in, merchant_id } │ +│ │ +│ merchants.upsert({ merchantId, domain, accessTokenEnc, … }) │ +└────────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +[ MYSQL ] INSERT INTO rp_merchants (merchant_id, domain, …) + + │ 302 → RATIO_RP_ADMIN_BASE_URL + ▼ +[ MERCHANT ] Admin SPA opens +┌──────────────────────────────────────────────────────────────────────┐ +│ GET /rp/api/admin/merchants/me │ +│ Authorization: Bearer │ +│ ← { id, domain, registered: false } │ +│ │ +│ SPA shows Registration Form: │ +│ store_domain · admin_email · admin_password · admin_name │ +└────────────────────────────┬─────────────────────────────────────────┘ + │ Merchant fills form → Submit + ▼ +[ ADAPTER ] POST /rp/api/admin/register +┌──────────────────────────────────────────────────────────────────────┐ +│ RpAdminController.register() │ +│ body: { store_domain, admin_email, admin_password, admin_name } │ +│ │ +│ merchants.updateDomain(merchantId, storeDomain) → MySQL UPDATE │ +│ │ +│ POST RP_BASE_URL/shopify-webhook/v1/os-install ← (1) │ +│ X-OS-Internal-Token: RP_INTERNAL_API_TOKEN │ +│ X-OS-Store: sandbox-bblunt-v2.dev.gokwik.io │ +│ { merchant_id, gokwik_merchant_id, access_token, │ +│ admin_email, admin_password, platform: "os" } │ +└────────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +[ RP BE ] POST /shopify-webhook/v1/os-install +┌──────────────────────────────────────────────────────────────────────┐ +│ Creates store record in RP's own MongoDB stores collection │ +│ ← 200 { status: true, messageCode: "OS_INSTALL_S1", │ +│ message: "Store registered" } │ +└────────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +[ MERCHANT ] Admin SPA shows success +┌──────────────────────────────────────────────────────────────────────┐ +│ Script snippet to paste into BBLunt storefront theme: │ +│ │ +│ (served by adapter: GET /rp/sdk/rp-portal.js) │ +│ │ +│ Button → "Open RP Dashboard" → http://localhost:3000 │ +│ OS platform mode: no billing screen, free tier by default │ +│ Merchant configures: return policy · reasons · locations │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +> **(1)** `os-install` is a standard RP webhook handler — same shape as the +> Shopify `app/installed` webhook. It's the hook Shopify itself would call; +> for the OS platform we post it explicitly from the adapter on registration. + +--- + +## Phase 2 — Order placed on BBLunt storefront → synced to RP + +``` +[ CUSTOMER ] +┌──────────────────────────────────────────────────────────────────────┐ +│ Completes checkout on sandbox-bblunt-v2.dev.gokwik.io │ +│ OS creates order: id = "ordr_17834274629567154" │ +│ line_items: [{ sku: "8904417307375", price: 48900 }] ← paise │ +└────────────────────────────┬─────────────────────────────────────────┘ + │ OS Order Service fires webhook + ▼ +[ OS-ORDER ] POST https://…devtunnels.ms/rp/webhooks/orders + x-merchant-id: + x-webhook-topic: orders/create + body: { event_type: "orders/create", + merchant_id: , + order: { id: "ordr_…", line_items: […] } } + │ + ▼ +[ ADAPTER ] POST /rp/webhooks/orders +┌──────────────────────────────────────────────────────────────────────┐ +│ RpWebhooksController.orderEvent() │ +│ Guard: RpWebhookSignatureGuard │ +│ no x-ratio-hmac-sha256 header present │ +│ NODE_ENV=development → guard passes (logs warning, continues) │ +│ │ +│ merchantId ← x-merchant-id header │ +│ ← body.merchant_id (fallback) │ +│ orderPayload ← body.order ?? body.data ?? body │ +│ │ +│ RpWebhooksService.handleOrderEvent(merchantId, payload, topic) │ +│ merchants.findByMerchantId(merchantId) │ +└────────────────┬───────────────────────────────────────┬─────────────┘ + │ merchant found │ not found + ▼ ▼ +┌────────────────────────────────┐ ┌────────────────────────┐ +│ orderSync.upsertOrder( │ │ logger.warn → drop │ +│ payload, merchant.domain ) │ │ return 200 { ok:true } │ +└───────────────┬────────────────┘ └────────────────────────┘ + │ + ▼ +[ ADAPTER ] RpOrderSyncService.upsertOrder(rawOrder, domain) +┌──────────────────────────────────────────────────────────────────────┐ +│ normalizeOrder({ ...rawOrder, store: domain }) │ +│ numericIdFromString("ordr_17834274629567154") │ +│ strip prefix → BigInt hex → 1114642164347947 │ +│ paiseToRupee(48900) → 489.00 ← all price fields ÷ 100 │ +│ build price_set, discount_allocations, tax_lines (Shopify shape) │ +└────────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +[ MONGO ] shopifyorders.updateOne( + { id: 1114642164347947, store: "sandbox-bblunt-v2…" }, + { $set: { id, name, store, financial_status, + line_items: [{ price: "489.00", … }], + updated_at: now() } }, + { upsert: true } + ) + │ + ▼ 200 { ok: true } back to OS-ORDER + +────────────────────────────────────────────────────────────────────── + Verify received: + mongosh …/return_prime_local --eval 'db.shopifyorders.findOne( + {}, {id:1,name:1,store:1,updated_at:1,_id:0})' +────────────────────────────────────────────────────────────────────── +``` + +--- + +## Phase 3 — Customer opens /apps/return_prime and submits return + +``` +[ CUSTOMER ] +┌──────────────────────────────────────────────────────────────────────┐ +│ Visits /apps/return_prime on BBLunt storefront │ +│ `; +} + +function htmlNoExternalAssets(): string { + return ''; +} + +function makeResponse(status: number, body = ''): Response { + return new Response(body, { status }); +} + +describe('RpPortalHealthService.checkHealthy', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('is healthy on a 2xx shell with no external asset references', async () => { + const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, htmlNoExternalAssets())); + vi.stubGlobal('fetch', fetchMock); + const service = new RpPortalHealthService(); + + await expect(service.checkHealthy('https://rp.example/os/v1/customer-portal')).resolves.toBe(true); + // Only the shell itself should be fetched — no external asset to probe. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('is healthy on a 2xx shell whose external asset is also reachable', async () => { + const html = htmlWithAsset('https://rp-web-assets.returnprime.co/proxyV2/prod/v1/assets/app.js'); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeResponse(200, html)) + .mockResolvedValueOnce(makeResponse(200)); + vi.stubGlobal('fetch', fetchMock); + const service = new RpPortalHealthService(); + + await expect(service.checkHealthy('https://rp.example/os/v1/customer-portal')).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://rp-web-assets.returnprime.co/proxyV2/prod/v1/assets/app.js', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('is unhealthy when the shell itself returns a non-2xx status (e.g. a 503 from an unhealthy ELB)', async () => { + const fetchMock = vi.fn().mockResolvedValue(makeResponse(503)); + vi.stubGlobal('fetch', fetchMock); + const service = new RpPortalHealthService(); + + await expect(service.checkHealthy('https://rp.example/os/v1/customer-portal')).resolves.toBe(false); + }); + + it('is healthy on a 404 shell that still returns the real app body with a reachable external asset (SPA host tags deep links 404 but serves the working bundle anyway)', async () => { + const html = htmlWithAsset('https://rp-web-assets.returnprime.co/proxyV2/prod/v1/assets/app.js'); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeResponse(404, html)) + .mockResolvedValueOnce(makeResponse(200)); + vi.stubGlobal('fetch', fetchMock); + const service = new RpPortalHealthService(); + + await expect(service.checkHealthy('https://rp.example/sandbox-momsco.dev.gokwik.io')).resolves.toBe(true); + }); + + it('is unhealthy on a 404 shell whose asset also fails (the 404-tagged body was not actually a working app)', async () => { + const html = htmlWithAsset('https://rp-web-assets.returnprime.co/proxyV2/prod/v1/assets/app.js'); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeResponse(404, html)) + .mockResolvedValueOnce(makeResponse(403)); + vi.stubGlobal('fetch', fetchMock); + const service = new RpPortalHealthService(); + + await expect(service.checkHealthy('https://rp.example/sandbox-momsco.dev.gokwik.io')).resolves.toBe(false); + }); + + it('is unhealthy on a 404 shell with no discoverable app content at all (a genuine not-found page)', async () => { + const fetchMock = vi.fn().mockResolvedValue(makeResponse(404, 'Not Found')); + vi.stubGlobal('fetch', fetchMock); + const service = new RpPortalHealthService(); + + await expect(service.checkHealthy('https://rp.example/sandbox-momsco.dev.gokwik.io')).resolves.toBe(false); + }); + + it('is unhealthy when the shell is 2xx but its external asset 403s (the real production bug)', async () => { + const html = htmlWithAsset('https://rp-web-assets.returnprime.co/proxyV2/prod/v1/assets/app.js'); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeResponse(200, html)) + .mockResolvedValueOnce(makeResponse(403)); + vi.stubGlobal('fetch', fetchMock); + const service = new RpPortalHealthService(); + + await expect(service.checkHealthy('https://rp.example/os/v1/customer-portal')).resolves.toBe(false); + }); + + it('fails open (healthy) when the shell fetch throws a network error', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('network down')); + vi.stubGlobal('fetch', fetchMock); + const service = new RpPortalHealthService(); + + await expect(service.checkHealthy('https://rp.example/os/v1/customer-portal')).resolves.toBe(true); + }); + + it('fails open (healthy) when the shell fetch is aborted by the timeout', async () => { + const fetchMock = vi.fn().mockImplementation((_url: string, init?: { signal?: AbortSignal }) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + }); + }); + vi.stubGlobal('fetch', fetchMock); + const service = new RpPortalHealthService(); + + const resultPromise = service.checkHealthy('https://rp.example/os/v1/customer-portal'); + await vi.runAllTimersAsync(); + + await expect(resultPromise).resolves.toBe(true); + }); + + it('fails open (healthy) when the asset probe itself throws/times out', async () => { + const html = htmlWithAsset('https://rp-web-assets.returnprime.co/proxyV2/prod/v1/assets/app.js'); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeResponse(200, html)) + .mockRejectedValueOnce(new Error('asset probe network error')); + vi.stubGlobal('fetch', fetchMock); + const service = new RpPortalHealthService(); + + await expect(service.checkHealthy('https://rp.example/os/v1/customer-portal')).resolves.toBe(true); + }); + + it('caches the result and does not re-fetch within the TTL', async () => { + const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, htmlNoExternalAssets())); + vi.stubGlobal('fetch', fetchMock); + const service = new RpPortalHealthService(); + + await service.checkHealthy('https://rp.example/os/v1/customer-portal'); + await service.checkHealthy('https://rp.example/os/v1/customer-portal'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/backend/src/modules/rp/portal/portal-health.service.ts b/apps/backend/src/modules/rp/portal/portal-health.service.ts new file mode 100644 index 0000000..0d6a8d0 --- /dev/null +++ b/apps/backend/src/modules/rp/portal/portal-health.service.ts @@ -0,0 +1,150 @@ +import { Injectable, Logger } from '@nestjs/common'; + +/** How long we're willing to block the customer's own portal request while probing RP's + * health. This is a synchronous gate on a real request (unlike the client-side SDK's + * 8s iframe-load budget, which doesn't block a response) so it must stay tight. */ +const HEALTH_CHECK_TIMEOUT_MS = 3000; + +/** Best-effort per-process cache so a burst of customers hitting the same broken (or + * healthy) shop doesn't re-probe RP on every single request. Not distributed/Redis — + * a per-instance cache is intentional and sufficient here. */ +const CACHE_TTL_MS = 60_000; + +interface CacheEntry { + healthy: boolean; + expiresAt: number; +} + +// Best-effort scan for + + diff --git a/packages/rp-sdk/demo-floating.html b/packages/rp-sdk/demo-floating.html new file mode 100644 index 0000000..9ca7ae6 --- /dev/null +++ b/packages/rp-sdk/demo-floating.html @@ -0,0 +1,20 @@ + + + + + + Return Prime Floating Button — Demo + + + +

Zero-markup floating mode: no elements on this page — the script injects a + fixed bottom-right button itself (no order/email prefill).

+ + + + diff --git a/packages/rp-sdk/demo.html b/packages/rp-sdk/demo.html new file mode 100644 index 0000000..d0d757c --- /dev/null +++ b/packages/rp-sdk/demo.html @@ -0,0 +1,20 @@ + + + + + + Return Prime Portal — Demo + + + + + + + + diff --git a/packages/rp-sdk/package.json b/packages/rp-sdk/package.json new file mode 100644 index 0000000..98689f7 --- /dev/null +++ b/packages/rp-sdk/package.json @@ -0,0 +1,27 @@ +{ + "name": "@ratio-app/rp-sdk", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/rp-portal.js", + "exports": { + ".": "./dist/rp-portal.js" + }, + "scripts": { + "build": "vite build", + "typecheck": "tsc --noEmit", + "dev": "vite build --watch", + "lint": "biome check src", + "test": "vitest run" + }, + "dependencies": { + "lit": "^3.2.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "jsdom": "^25.0.0", + "typescript": "^5.6.0", + "vite": "^6.0.0", + "vitest": "^2.1.0" + } +} diff --git a/packages/rp-sdk/src/auto-inject.ts b/packages/rp-sdk/src/auto-inject.ts new file mode 100644 index 0000000..7ee0e00 --- /dev/null +++ b/packages/rp-sdk/src/auto-inject.ts @@ -0,0 +1,99 @@ +import { compilePathPattern, scriptConfig } from './loader'; +import './return-button'; + +/** + * Zero-configuration button placement for storefronts on the shared GoKwik + * storefront-builder convention (bblunt/momsco/plixkids): order id lives in the + * URL/DOM, never in app state the SDK can't reach. So instead of requiring the + * merchant to wire order/email props into their React tree, this scans the page: + * - order-detail page (path matches orderDetailPath) → floating button, order id + * from the URL itself. + * - order-list page (path matches orderListPath) → a button next to every row's + * "View"-style link, order id from that link's own href. + * No merchant code beyond the single script tag. Overridable via script-tag + * data-order-detail-path / data-order-list-path if a storefront's routes differ. + */ + +const detailPattern = compilePathPattern(scriptConfig.orderDetailPath); +// The list pattern doubles as the href-prefix for list-page row links (same route, +// sans the trailing id), so both checks share one derivation. +const listBasePath = scriptConfig.orderListPath.replace(/\/$/, ''); +const listPattern = new RegExp(`^${listBasePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/?$`); + +const AUTO_DETAIL_MARK = 'rp-auto-detail'; + +function currentDetailOrderId(): string | null { + const m = location.pathname.match(detailPattern); + return m?.[1] ?? null; +} + +function syncDetailButton(): void { + const orderId = currentDetailOrderId(); + const existing = document.querySelector(`rp-return-button[data-${AUTO_DETAIL_MARK}]`); + if (!orderId) { + existing?.remove(); + return; + } + if (existing?.getAttribute('order-id') === orderId) return; + existing?.remove(); + const el = document.createElement('rp-return-button'); + el.setAttribute(`data-${AUTO_DETAIL_MARK}`, ''); + el.setAttribute('floating', ''); + el.setAttribute('order-id', orderId); + document.body.appendChild(el); +} + +function syncListButtons(): void { + if (!listPattern.test(location.pathname)) return; + const links = document.querySelectorAll(`a[href^="${listBasePath}/"]`); + links.forEach((link) => { + // Only immediate row "view" links (one path segment past the list base) — not + // any other in-page link that happens to share the prefix. Resolve through URL + // first: matching the raw href fails silently when it carries a query string + // (tracking/pagination params) since detailPattern is end-anchored. + const href = link.getAttribute('href'); + const orderId = href + ? new URL(href, location.origin).pathname.match(detailPattern)?.[1] + : undefined; + if (!orderId) return; + if (link.nextElementSibling?.tagName === 'RP-RETURN-BUTTON') return; + const el = document.createElement('rp-return-button'); + el.setAttribute('order-id', orderId); + link.insertAdjacentElement('afterend', el); + }); +} + +function sync(): void { + syncDetailButton(); + syncListButtons(); +} + +function debounce(fn: () => void, ms: number): () => void { + let handle: ReturnType | null = null; + return () => { + if (handle) clearTimeout(handle); + handle = setTimeout(fn, ms); + }; +} + +const debouncedSync = debounce(sync, 120); + +/** Caller (index.ts) already waits for DOMContentLoaded before invoking this. */ +export function startAutoInject(): void { + sync(); + + // Next.js/SPA client-side navigation doesn't reload the page — re-check on both + // history mutation (list → detail) and DOM mutation (client-side pagination, + // which can re-render the list without a URL change). + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method]; + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args); + debouncedSync(); + return result; + }; + } + window.addEventListener('popstate', debouncedSync); + + new MutationObserver(debouncedSync).observe(document.body, { childList: true, subtree: true }); +} diff --git a/packages/rp-sdk/src/client.ts b/packages/rp-sdk/src/client.ts new file mode 100644 index 0000000..869b22d --- /dev/null +++ b/packages/rp-sdk/src/client.ts @@ -0,0 +1,166 @@ +import type { RpConfig, RpExchangeProduct, RpLineItem, RpOrder, RpReason } from './types'; + +function uid(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`; +} + +async function post(url: string, body: unknown, headers?: Record): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); + const json = (await res.json()) as { + status: boolean; + messageCode?: string; + data: T; + message?: string; + }; + if (!json.status) { + throw new Error(json.message ?? json.messageCode ?? 'Request failed'); + } + return json.data; +} + +export async function createSession( + config: RpConfig, + order: string, + identifier: string, +): Promise { + const token = await post(`${config.apiUrl}/customer/v1/session`, { + store: config.store, + order, + identifier, + }); + return token; +} + +export async function findOrder( + config: RpConfig, + session: string, +): Promise<{ order: RpOrder; lineItems: RpLineItem[]; currency: string }> { + const data = await post<{ + order: RpOrder; + orders: RpLineItem[]; + currency: string; + }>(`${config.apiUrl}/customer/v1/find-order`, { session }); + + return { + order: data.order, + lineItems: (data.orders ?? []).filter((li) => li.returnable), + currency: data.currency ?? 'INR', + }; +} + +export async function getReasons( + config: RpConfig, + session: string, + orderId: number, + lineItemIds: number[], +): Promise { + try { + const data = await post<{ + order: unknown; + line_items: Array<{ id: number; reasons?: RpReason[] }>; + }>(`${config.apiUrl}/customer/v1/reasons`, { + session, + order: orderId, + line_items: lineItemIds, + type: 'return', + }); + const all = (data.line_items ?? []).flatMap((li) => li.reasons ?? []); + const seen = new Set(); + return all.filter((r) => !seen.has(r._id) && !!seen.add(r._id)); + } catch { + return []; + } +} + +/** + * Fetch exchange-eligible products for an item (RP's exchange picker). RP filters by + * the store's exchange rule + a price cap relative to the returned item — for OS stores + * this is same-or-lower price (they can't collect a difference). + */ +export async function searchExchangeProducts( + config: RpConfig, + session: string, + originalProductId: number, + cappedPrice: number, + page = 1, + limit = 30, +): Promise { + const qs = new URLSearchParams({ + session, + original_product_id: String(originalProductId), + capped_price: String(cappedPrice), + page: String(page), + limit: String(limit), + }).toString(); + try { + const data = await post<{ docs?: RpExchangeProduct[]; products?: RpExchangeProduct[] }>( + `${config.apiUrl}/customer/v1/products?${qs}`, + {}, + ); + return data.docs ?? data.products ?? []; + } catch { + return []; + } +} + +export interface CreateRequestItem { + id: number; + quantity: number; + reasonId: string; + reasonText: string; + comment: string; + type: 'return' | 'exchange'; + refundMode: string; + originalProductId?: number | undefined; + originalVariantId?: number | undefined; + exchangeProductId?: number | undefined; + exchangeVariantId?: number | undefined; +} + +export async function createRequest( + config: RpConfig, + session: string, + orderId: number, + items: CreateRequestItem[], +): Promise<{ serialNumber: string }> { + const results = await post>( + `${config.apiUrl}/return-exchange/v1/create`, + { + session, + order: orderId, + client_details: { source: 'rp-sdk' }, + channel: config.channel ?? 1, + line_items: items.map((item) => + item.type === 'exchange' + ? { + id: item.id, + quantity: item.quantity, + reason: item.reasonId, + reason_text: item.reasonText, + comment: item.comment || undefined, + type: 'exchange', + original_product_id: item.originalProductId, + original_variant_id: item.originalVariantId, + exchange_product_id: item.exchangeProductId, + exchange_variant_id: item.exchangeVariantId, + } + : { + id: item.id, + quantity: item.quantity, + reason: item.reasonId, + reason_text: item.reasonText, + comment: item.comment || undefined, + type: 'return', + requested_refund_mode: item.refundMode, + }, + ), + }, + { 'idempotency-key': uid() }, + ); + const first = results[0]; + return { serialNumber: first?.serial_number ?? '' }; +} diff --git a/packages/rp-sdk/src/enabled-check.ts b/packages/rp-sdk/src/enabled-check.ts new file mode 100644 index 0000000..4272896 --- /dev/null +++ b/packages/rp-sdk/src/enabled-check.ts @@ -0,0 +1,40 @@ +// One config fetch per (adapter, store) page-load, shared by every caller — an order-history +// page can render dozens of buttons, and now also the /apps/return_prime page itself, none of +// which should fire their own separate request. +const enabledCache = new Map>(); + +/** + * Merchant enable/disable toggle (RP admin → adapter /rp/config). Fails CLOSED — resolves + * false on any error, so an adapter outage hides the entry point rather than surfacing a + * dead one. + */ +export function fetchEnabled(adapterUrl: string, store: string): Promise { + const key = `${adapterUrl}|${store}`; + let cached = enabledCache.get(key); + if (!cached) { + cached = (async () => { + try { + const res = await fetch( + `${adapterUrl.replace(/\/$/, '')}/rp/config?shop=${encodeURIComponent(store)}`, + ); + // A transient failure (network blip, adapter briefly down) isn't the same as the + // merchant genuinely disabling Return/Exchange — don't let it stick as "false" for + // the rest of the page's life once the adapter recovers. + if (!res.ok) { + enabledCache.delete(key); + return false; + } + const json = (await res.json()) as { + data?: { returnExchangeEnabled?: boolean }; + returnExchangeEnabled?: boolean; + }; + return (json.data?.returnExchangeEnabled ?? json.returnExchangeEnabled) === true; + } catch { + enabledCache.delete(key); + return false; + } + })(); + enabledCache.set(key, cached); + } + return cached; +} diff --git a/packages/rp-sdk/src/index.ts b/packages/rp-sdk/src/index.ts new file mode 100644 index 0000000..da24cd7 --- /dev/null +++ b/packages/rp-sdk/src/index.ts @@ -0,0 +1,60 @@ +import { startAutoInject } from './auto-inject'; +import { scriptConfig } from './loader'; +import { syncReturnPrimePage } from './return-prime-page'; + +export { scriptConfig } from './loader'; +export { RpReturnPortal } from './portal'; +export { RpReturnButton } from './return-button'; +export type { RpConfig, RpLineItem, RpOrder, RpReason, SelectedItem } from './types'; + +// Zero-markup global mode: `?floating=1` on the script src injects a single fixed +// bottom-right button on every page (no order context — RP's own lookup screen +// collects order/email). For storefronts that don't want per-page auto-detection. +function injectFloatingButton(): void { + const el = document.createElement('rp-return-button'); + el.setAttribute('floating', ''); + document.body.appendChild(el); +} + +function bootstrap(): void { + // Mutually exclusive with the button placements below — a page is either the returns + // page or an order page, never both — so this always runs alongside whichever mode. + void syncReturnPrimePage(); + if (scriptConfig.floating) { + injectFloatingButton(); + } else { + startAutoInject(); + } +} + +if (typeof document !== 'undefined' && scriptConfig.store && scriptConfig.adapterUrl) { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bootstrap, { once: true }); + } else { + bootstrap(); + } +} + +export function initReturnPortal(config: { + store: string; + apiUrl: string; + targetSelector?: string; + channel?: number; + primaryColor?: string; +}): void { + const target = document.querySelector(config.targetSelector ?? '#rp-return-portal'); + if (!target) { + console.warn( + '[rp-sdk] No target element found. Add
to your page.', + ); + return; + } + + const el = document.createElement('rp-return-portal'); + el.setAttribute('store', config.store); + el.setAttribute('api-url', config.apiUrl); + if (config.channel !== undefined) el.setAttribute('channel', String(config.channel)); + if (config.primaryColor) el.setAttribute('primary-color', config.primaryColor); + + target.replaceWith(el); +} diff --git a/packages/rp-sdk/src/loader.ts b/packages/rp-sdk/src/loader.ts new file mode 100644 index 0000000..832b901 --- /dev/null +++ b/packages/rp-sdk/src/loader.ts @@ -0,0 +1,90 @@ +/** + * Script-tag config for the drop-in SDK. Merchants load one tag: + * + * + * - adapterUrl derives from where the script itself was fetched (import.meta.url + * origin) — never configured by the merchant. + * - store / floating come from the script URL's query params (primary, since they + * survive any loader), with `data-store` / `data-floating` attributes on the + * matching