Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,5 @@ apps/backend/dist/
zip/


claude --resume 4d70ff1e-c37e-4e4d-ba08-121ee0b8450f
claude --resume 4d70ff1e-c37e-4e4d-ba08-121ee0b8450f
.ec2.env
54 changes: 54 additions & 0 deletions META_RETURN_PRIME.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions apps/admin-rp/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Return Prime — Ratio App</title>
</head>
<body class="bg-background text-foreground">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
38 changes: 38 additions & 0 deletions apps/admin-rp/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
272 changes: 272 additions & 0 deletions apps/admin-rp/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fafafa',
}}
>
{children}
</div>
);
}

function RegisterScreen() {
const [loading, setLoading] = useState(false);
const [domainLoading, setDomainLoading] = useState(true);
const [registered, setRegistered] = useState(false);
const [merchantDomain, setMerchantDomain] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 = [
`<!-- Add this one script tag to your storefront layout (once, site-wide). -->`,
`<!-- It auto-detects order pages and your /apps/return_prime page and injects -->`,
`<!-- everything itself — no other markup needed. -->`,
`<script type="module" async src="${sdkSrc}"></script>`,
].join('\n');

return centered(
<div style={{ maxWidth: 600, width: '100%' }}>
<Result
status="success"
title="Return Prime configured!"
subTitle="Your store is connected. Copy the snippet below into your storefront, then open the Return Prime dashboard to configure policies."
extra={
RP_ADMIN_URL ? (
<PrimaryButton onClick={() => window.open(rpUrl, '_blank')}>
Open Return Prime Dashboard
</PrimaryButton>
) : null
}
/>
<div style={{ marginTop: 24 }}>
<Typography.Text strong>Storefront SDK snippet</Typography.Text>
<pre
style={{
background: '#f5f5f5',
border: '1px solid #d9d9d9',
borderRadius: 6,
padding: '12px 16px',
marginTop: 8,
fontSize: 12,
overflowX: 'auto',
whiteSpace: 'pre',
userSelect: 'all',
}}
>
{scriptSnippet}
</pre>
</div>
</div>,
);
}

if (domainLoading) return centered(<Spin size="large" />);

return centered(
<div className="container">
<Card title="Connect Return Prime">
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 20 }}>
Create your Return Prime admin account to start managing returns.
</Typography.Text>
{error && (
<Typography.Text type="danger" style={{ display: 'block', marginBottom: 16 }}>
{error}
</Typography.Text>
)}
<Form form={form} layout="vertical" onFinish={handleRegister}>
<Form.Item
name="store_domain"
label="Store Domain"
rules={[
{ required: true, message: 'Store domain is required' },
{
pattern: /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
message: 'Enter a valid domain (e.g. your-store.gokwik.co)',
},
]}
>
<Input
placeholder="your-store.gokwik.co"
onChange={(e) => {
const domain = e.target.value.trim();
if (domain) {
form.setFieldValue('admin_email', `admin@${domain}`);
}
}}
/>
</Form.Item>
<Form.Item
name="admin_email"
label="Admin Email"
rules={[
{ required: true, message: 'Email is required' },
{ type: 'email', message: 'Enter a valid email' },
]}
>
<Input placeholder="admin@your-store.com" />
</Form.Item>
<Form.Item
name="admin_password"
label="Password"
rules={[
{ required: true, message: 'Password is required' },
{ min: 8, message: 'Minimum 8 characters' },
]}
>
<Input.Password placeholder="Password" />
</Form.Item>
<Form.Item
name="confirm_password"
label="Confirm Password"
dependencies={['admin_password']}
rules={[
{ required: true, message: 'Please confirm your password' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('admin_password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('Passwords do not match'));
},
}),
]}
>
<Input.Password placeholder="Confirm password" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<PrimaryButton htmlType="submit" loading={loading} style={{ width: '100%' }}>
Register in Return Prime
</PrimaryButton>
</Form.Item>
</Form>
</Card>
</div>,
);
}

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(<Spin size="large" />);

if (!isAuthorized) {
return centered(
<Result
status="403"
title="Access restricted"
subTitle={
parentOrigin
? `This app can only be opened from the Ratio dashboard. Detected parent: ${parentOrigin}`
: 'This app can only be opened from the Ratio dashboard.'
}
/>,
);
}

if (!sessionChecked) return centered(<Spin size="large" />);

if (!token) {
return centered(
<Result
status="403"
title="No merchant session"
subTitle="Open this admin from your Ratio dashboard — a merchant context is required."
/>,
);
}

return <RegisterScreen />;
}

export function Root() {
return (
<OrionProvider>
<App />
</OrionProvider>
);
}
Loading