Skip to content
Merged
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
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,14 @@ CodFlow v1.1.0 — here's what works today:
- ✅ Partial returns with automatic inventory restock

### Growth Engine
- ✅ Meta Conversions API (CAPI) fires `Purchase` only at confirmed delivery
- ✅ 7-day attribution window compliance
- ✅ Advanced data hashing (phone, city, IP, User-Agent)
- ✅ Meta Pixel (browser) + Conversions API (server) dual setup with event deduplication
- ✅ Merchant-chosen conversion event: `Lead` at order placement or `Purchase` at confirmed delivery
- ✅ Test Mode toggle routes CAPI events to Meta's test stream (`test_event_code`)
- ✅ Graph API v26.0 with 7-day attribution window compliance
- ✅ PII hashed per Meta spec (phone, names, city, zip, country, external_id); IP, User-Agent, `fbp`, `fbc` sent unhashed as required
- ✅ `fbp` and `fbc` attribution preservation
- ✅ Durable retry with Cloudflare Workflows
- ✅ Durable retry with Cloudflare Workflows (network + Meta 5xx, exponential backoff)
- ✅ CAPI event audit log (`capi_event_log`) for every send attempt

### AI & Agentic (MCP)
- ✅ RFC 9728 OAuth Protected Resource Discovery with dynamic client registration
Expand Down
16 changes: 14 additions & 2 deletions cod-astro/theme01/src/core/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,19 @@ export const server = {
z.string().min(10).max(1024).optional()
),
}),
handler: async (input) => {
handler: async (input, context) => {
// Forward the shopper's attribution headers so cod-server records the
// visitor, not this worker — same mechanism as core/endpoints/abandoned.ts.
const forwardedHeaders: Record<string, string> = {};
const userAgent = context.request.headers.get("User-Agent");
if (userAgent) forwardedHeaders["User-Agent"] = userAgent;
const clientIp =
context.request.headers.get("CF-Connecting-IP") ??
context.request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim();
if (clientIp) forwardedHeaders["X-Forwarded-For"] = clientIp;
const referer = context.request.headers.get("Referer");
if (referer) forwardedHeaders["Referer"] = referer;

const result = await placeOrder({
customerName: input.customerName,
phone: input.phone,
Expand All @@ -86,7 +98,7 @@ export const server = {
fbc: input.fbc,
fbp: input.fbp,
otpToken: input.otpToken,
});
}, forwardedHeaders);

if (!result.success) {
throw new Error(result.error);
Expand Down
5 changes: 3 additions & 2 deletions cod-astro/theme01/src/core/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,13 @@ export async function submitReview(
}

export async function placeOrder(
body: Record<string, unknown>
body: Record<string, unknown>,
forwardedHeaders?: Record<string, string>
): Promise<{ success: true; data: { orderNumber: string; orderId: string; total: number } } | { success: false; error: string }> {
try {
const res = await fetch(`${COD_SERVER_URL}/store/orders`, {
method: "POST",
headers: storeHeaders(),
headers: { ...storeHeaders(), ...forwardedHeaders },
body: JSON.stringify(body),
});
const json = (await res.json()) as any;
Expand Down
5 changes: 4 additions & 1 deletion cod-astro/theme01/src/pages/thank-you.astro
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@ const steps = [
<script type="text/javascript" set:html={`
(function() {
if (typeof fbq !== 'function') return;
var eventId = ${JSON.stringify(orderId || orderNumber)};
var eventId = ${JSON.stringify(orderId)};
// No orderId means no server Lead mirror to deduplicate against — firing
// with a different id would double-count the conversion. Skip instead.
if (!eventId) return;
var total = ${JSON.stringify(totalNum)};
fbq('track', 'Lead', total ? { value: total, currency: 'DZD' } : {}, { eventID: eventId });
})();
Expand Down
43 changes: 16 additions & 27 deletions cod-astro/theme01/src/theme/components/layout/BaseHead.astro
Original file line number Diff line number Diff line change
Expand Up @@ -65,36 +65,25 @@ import { MEDIA_DOMAIN } from "astro:env/server";
<!-- R2 media domain — early connection for product images -->
{MEDIA_DOMAIN && <link rel="preconnect" href={`https://${MEDIA_DOMAIN}`} />}

<!-- Meta Pixel — deferred after window.load so fbevents.js (101 KiB) does
not compete with the LCP image for bandwidth. A lightweight queue stub
captures all fbq() calls made before the library arrives; they are
replayed in order once fbevents.js is ready.

Why delete window.fbq before injecting the script: fbevents.js opens
with `if(f.fbq)return;` — it bails if a fbq already exists. Clearing
our stub lets it initialise properly, then we replay the saved queue. -->
<!-- Meta Pixel — canonical base code, unmodified. fbevents.js ships its own
command queue: every fbq() call made before the library arrives (incl.
ViewContent/InitiateCheckout from product.ts) is buffered and flushed
automatically once it loads. Do not defer or custom-load it — a
hand-rolled loader delays or drops events whenever window.load or the
script fetch misbehaves. -->
{config.pixelId && (
<Fragment>
<script type="text/javascript" set:html={`
(function(w,d){
function q(){q.queue=q.queue||[];q.queue.push([].slice.call(arguments));}
q.queue=[];q.version='2.0';
if(!w.fbq){w.fbq=q;w._fbq=q;}
function loadPixel(){
var saved=w.fbq&&w.fbq.queue?w.fbq.queue.slice():[];
delete w.fbq;delete w._fbq;
var s=d.createElement('script');s.async=true;
s.src='https://connect.facebook.net/en_US/fbevents.js';
s.onload=function(){
for(var i=0;i<saved.length;i++){try{w.fbq.apply(null,saved[i]);}catch(e){}}
};
d.head.appendChild(s);
}
if(d.readyState==='complete'){loadPixel();}
else{w.addEventListener('load',loadPixel,{once:true});}
})(window,document);
fbq('init','${config.pixelId}');
fbq('track','PageView');
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '${config.pixelId}');
fbq('track', 'PageView');
`} />
<noscript>
<img height="1" width="1" style="display:none"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const jsonLd = getProductJsonLd(product, basePrice);
data-qty-max-stock={content.qtyMaxStock}
data-inventory={String(product.inventory)}
data-pixel-id={config.pixelId ?? ""}
data-phone-invalid={content.formPhoneInvalid}
data-product-id={product.id}
data-product-name={product.name}
/>
Expand Down
1 change: 1 addition & 0 deletions cod-astro/theme01/src/theme/content/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export const ar: StoreFrontContent = {
formNamePlaceholder: "مثال: أحمد بن علي",
formPhoneLabel: "رقم الهاتف",
formPhonePlaceholder: "0XX XX XX XX XX",
formPhoneInvalid: "رقم هاتف جزائري غير صحيح — يجب أن يبدأ بـ 05 أو 06 أو 07",
formWilayaLabel: "الولاية",
formWilayaPlaceholder: "اختر ولايتك",
formCommuneLabel: "البلدية",
Expand Down
1 change: 1 addition & 0 deletions cod-astro/theme01/src/theme/content/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export const en: StoreFrontContent = {
formNamePlaceholder: "e.g. Ahmed Ben Ali",
formPhoneLabel: "Phone number",
formPhonePlaceholder: "0XX XX XX XX XX",
formPhoneInvalid: "Invalid Algerian phone number — must start with 05, 06, or 07",
formWilayaLabel: "Wilaya",
formWilayaPlaceholder: "Select your wilaya",
formCommuneLabel: "Commune",
Expand Down
1 change: 1 addition & 0 deletions cod-astro/theme01/src/theme/content/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export const fr: StoreFrontContent = {
formNamePlaceholder: "Exemple: Ahmed Ben Ali",
formPhoneLabel: "Numéro de téléphone",
formPhonePlaceholder: "0XX XX XX XX XX",
formPhoneInvalid: "Numéro algérien invalide — doit commencer par 05, 06 ou 07",
formWilayaLabel: "Wilaya",
formWilayaPlaceholder: "Choisissez votre wilaya",
formCommuneLabel: "Commune",
Expand Down
1 change: 1 addition & 0 deletions cod-astro/theme01/src/theme/content/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export interface StoreFrontContent {
formNamePlaceholder: string;
formPhoneLabel: string;
formPhonePlaceholder: string;
formPhoneInvalid: string;
formWilayaLabel: string;
formWilayaPlaceholder: string;
formCommuneLabel: string;
Expand Down
74 changes: 57 additions & 17 deletions cod-astro/theme01/src/theme/scripts/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,42 @@ export function initProductPage() {
if (submitBtn) submitBtn.disabled = true;
});

// ── ALGERIAN PHONE VALIDATION ──────────────────────────────────────────────
// Normalizes to the canonical local form "05XXXXXXXX" on blur and blocks
// submit with a localized message for anything that is not an Algerian
// mobile. The server re-validates (storeOrderSchema) — this is UX, not the
// enforcement point.
const phoneInput = document.getElementById("f-phone") as HTMLInputElement | null;
const phoneInvalidMsg = el.dataset.phoneInvalid || "Invalid phone number";

function toLocalDzMobile(raw: string): string | null {
let digits = raw.replace(/\D/g, "");
if (digits.startsWith("00213")) digits = digits.slice(5);
else if (digits.startsWith("213")) digits = digits.slice(3);
const local = digits.startsWith("0") ? digits.slice(1) : digits;
return /^[567]\d{8}$/.test(local) ? "0" + local : null;
}

if (phoneInput) {
phoneInput.addEventListener("blur", () => {
if (!phoneInput.value.trim()) {
phoneInput.setCustomValidity("");
return;
}
const normalized = toLocalDzMobile(phoneInput.value);
if (normalized) {
phoneInput.value = normalized;
phoneInput.setCustomValidity("");
} else {
phoneInput.setCustomValidity(phoneInvalidMsg);
phoneInput.reportValidity();
}
});
phoneInput.addEventListener("input", () => {
phoneInput.setCustomValidity("");
});
}

// ── FINAL INITIALIZATION ───────────────────────────────────────────────────
// Show the default active tier's variant section (base tier is always default)
if (tierLabels.length > 0) {
Expand Down Expand Up @@ -676,25 +712,29 @@ export function initProductPage() {
});
}

// 2. InitiateCheckout — fires once when the order form scrolls into view
// 2. InitiateCheckout — fires once when the shopper STARTS the checkout:
// first focus, keystroke, or selection inside the order form. Seeing the
// form is not starting checkout — Meta's event means the process began.
if (pixelId) {
const orderSection = document.getElementById("order-section");
if (orderSection && "IntersectionObserver" in window) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
fbqSafe()?.("track", "InitiateCheckout", {
content_ids: [productId],
content_type: "product",
value: currentPrice,
currency: "DZD",
});
observer.disconnect();
}
},
{ threshold: 0.3 }
);
observer.observe(orderSection);
if (orderSection) {
let checkoutStarted = false;
const fireInitiateCheckout = () => {
if (checkoutStarted) return;
checkoutStarted = true;
fbqSafe()?.("track", "InitiateCheckout", {
content_ids: [productId],
content_type: "product",
value: currentPrice,
currency: "DZD",
});
orderSection.removeEventListener("focusin", fireInitiateCheckout);
orderSection.removeEventListener("input", fireInitiateCheckout);
orderSection.removeEventListener("change", fireInitiateCheckout);
};
orderSection.addEventListener("focusin", fireInitiateCheckout);
orderSection.addEventListener("input", fireInitiateCheckout);
orderSection.addEventListener("change", fireInitiateCheckout);
}
}

Expand Down
8 changes: 7 additions & 1 deletion cod-astro/theme01/src/theme/scripts/track-abandonment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ function initAbandonmentTracking() {
}

function isValidPhone(v: string) {
return /^[0-9+\s-]+$/.test(v) && v.replace(/\D/g, "").length >= 9;
// Algerian mobile, matching the order form + server schema: 05/06/07
// followed by 8 digits, in local or international form.
let digits = v.replace(/\D/g, "");
if (digits.startsWith("00213")) digits = digits.slice(5);
else if (digits.startsWith("213")) digits = digits.slice(3);
const local = digits.startsWith("0") ? digits.slice(1) : digits;
return /^[567]\d{8}$/.test(local);
}

function getWilayaName(wilayaId: number | undefined): string | undefined {
Expand Down
17 changes: 14 additions & 3 deletions cod-client-astro/locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,21 @@
"tracking_pixel_id_hint": "متوفر في مدير الأحداث Meta ← مصادر البيانات",
"tracking_pixel_id_placeholder": "مثال: 123456789012345",
"tracking_token_label": "رمز وصول API التحويلات",
"tracking_token_hint": "رمز من جانب الخادم — لا يُكشف أبداً للزوار",
"tracking_token_hint": "رمز من جانب الخادم — لا يُكشف أبداً للزوار. اتركه فارغاً للإبقاء على الرمز المخزّن.",
"tracking_token_placeholder": "EAAxxxxxxxxxxxxxxxx...",
"tracking_test_code_label": "رمز الحدث التجريبي (اختياري)",
"tracking_test_code_hint": "استخدمه للتحقق من الأحداث في مدير أحداث Meta",
"tracking_ad_account_label": "اسم حساب الإعلانات",
"tracking_ad_account_hint": "تسمية خاصة بك لحساب الإعلانات على Meta — للمرجعية فقط",
"tracking_ad_account_placeholder": "مثال: متجري — الحساب الرئيسي",
"tracking_event_label": "حدث التحويل",
"tracking_event_lead_label": "Lead — عند تسجيل الطلب",
"tracking_event_lead_hint": "يُطلق لحظة تقديم الطلب. إشارة أسرع لإعلاناتك، لكن الطلبات غير المؤكدة تُحتسب ضمنه.",
"tracking_event_purchase_label": "Purchase — عند التسليم المؤكد",
"tracking_event_purchase_hint": "يُطلق فقط عند تسليم الطلب ودفع قيمته. إشارة أبطأ، لكنها تطابق الإيرادات الحقيقية.",
"tracking_test_mode_label": "الوضع التجريبي",
"tracking_test_mode_hint": "يرسل أحداث API التحويلات إلى مسار الاختبار لدى Meta بدلاً من قياس الإنتاج",
"tracking_test_mode_warning": "الوضع التجريبي مفعّل — الأحداث لا تُحتسب في القياس الفعلي. عطّله قبل الانطلاق.",
"tracking_test_code_label": "رمز الحدث التجريبي",
"tracking_test_code_hint": "من مدير الأحداث ← أحداث الاختبار — مطلوب عند تفعيل الوضع التجريبي",
"tracking_test_code_placeholder": "مثال: TEST12345",
"tracking_last_saved": "آخر حفظ",
"otp_title": "التحقق",
Expand Down
17 changes: 14 additions & 3 deletions cod-client-astro/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,21 @@
"tracking_pixel_id_hint": "Found in Meta Events Manager → Data Sources",
"tracking_pixel_id_placeholder": "e.g. 123456789012345",
"tracking_token_label": "Conversions API Access Token",
"tracking_token_hint": "Server-side token — never exposed to visitors",
"tracking_token_hint": "Server-side token — never exposed to visitors. Leave empty to keep the stored token.",
"tracking_token_placeholder": "EAAxxxxxxxxxxxxxxxx...",
"tracking_test_code_label": "Test Event Code (optional)",
"tracking_test_code_hint": "Use during testing to verify events in Meta Events Manager",
"tracking_ad_account_label": "Ad Account Name",
"tracking_ad_account_hint": "Your own label for the Meta ad account — for reference only",
"tracking_ad_account_placeholder": "e.g. My Store — Main Account",
"tracking_event_label": "Conversion Event",
"tracking_event_lead_label": "Lead — at order placement",
"tracking_event_lead_hint": "Fires the moment an order is placed. Faster signal for your ads, but unconfirmed orders are included.",
"tracking_event_purchase_label": "Purchase — at confirmed delivery",
"tracking_event_purchase_hint": "Fires only when the order is delivered and paid. Slower signal, but matches real revenue.",
"tracking_test_mode_label": "Test Mode",
"tracking_test_mode_hint": "Sends Conversions API events to Meta's test stream instead of production measurement",
"tracking_test_mode_warning": "Test mode is on — events are not counted for real measurement. Turn it off before going live.",
"tracking_test_code_label": "Test Event Code",
"tracking_test_code_hint": "From Events Manager → Test Events — required when test mode is on",
"tracking_test_code_placeholder": "e.g. TEST12345",
"tracking_last_saved": "Last saved",
"otp_title": "Verification",
Expand Down
17 changes: 14 additions & 3 deletions cod-client-astro/locales/fr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,21 @@
"tracking_pixel_id_hint": "Disponible dans Meta Events Manager → Sources de données",
"tracking_pixel_id_placeholder": "ex: 123456789012345",
"tracking_token_label": "Token d'accès API Conversions",
"tracking_token_hint": "Token côté serveur — jamais exposé aux visiteurs",
"tracking_token_hint": "Token côté serveur — jamais exposé aux visiteurs. Laissez vide pour conserver le token enregistré.",
"tracking_token_placeholder": "EAAxxxxxxxxxxxxxxxx...",
"tracking_test_code_label": "Code d'événement test (optionnel)",
"tracking_test_code_hint": "Utilisez-le pour vérifier les événements dans Meta Events Manager",
"tracking_ad_account_label": "Nom du compte publicitaire",
"tracking_ad_account_hint": "Votre propre libellé pour le compte publicitaire Meta — à titre de référence",
"tracking_ad_account_placeholder": "ex: Ma Boutique — Compte principal",
"tracking_event_label": "Événement de conversion",
"tracking_event_lead_label": "Lead — dès la commande",
"tracking_event_lead_hint": "Déclenché dès qu'une commande est passée. Signal plus rapide pour vos publicités, mais les commandes non confirmées sont incluses.",
"tracking_event_purchase_label": "Purchase — à la livraison confirmée",
"tracking_event_purchase_hint": "Déclenché uniquement lorsque la commande est livrée et payée. Signal plus lent, mais conforme au revenu réel.",
"tracking_test_mode_label": "Mode test",
"tracking_test_mode_hint": "Envoie les événements de l'API Conversions vers le flux de test de Meta au lieu de la production",
"tracking_test_mode_warning": "Le mode test est activé — les événements ne comptent pas pour la mesure réelle. Désactivez-le avant de lancer.",
"tracking_test_code_label": "Code d'événement test",
"tracking_test_code_hint": "Depuis Events Manager → Événements de test — requis quand le mode test est activé",
"tracking_test_code_placeholder": "ex: TEST12345",
"tracking_last_saved": "Dernière sauvegarde",
"otp_title": "Vérification",
Expand Down
6 changes: 3 additions & 3 deletions cod-client-astro/src/features/settings/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ describe("settings API adapters", () => {
await expect(getPixelConfig()).resolves.toBeNull();
expect(seam.apiFetch).toHaveBeenCalledWith("/api/stores/pixel-config");

const config = { id: "px1", pixelId: "123", accessToken: "EAAG", testEventCode: null, enabled: true };
const config = { id: "px1", pixelId: "123", accessTokenMasked: "••••EAAG", testEventCode: null, conversionEvent: "Purchase" as const, testMode: false, enabled: true };
seam.apiFetch.mockResolvedValue({ success: true, data: config });
await expect(savePixelConfig({ pixelId: "123", accessToken: "EAAG", enabled: true })).resolves.toEqual(config);
expect(seam.apiFetch).toHaveBeenCalledWith("/api/stores/pixel-config", expect.objectContaining({ method: "POST", body: JSON.stringify({ pixelId: "123", accessToken: "EAAG", enabled: true }) }));
await expect(savePixelConfig({ pixelId: "123", accessToken: "EAAG", conversionEvent: "Purchase", enabled: true })).resolves.toEqual(config);
expect(seam.apiFetch).toHaveBeenCalledWith("/api/stores/pixel-config", expect.objectContaining({ method: "POST", body: JSON.stringify({ pixelId: "123", accessToken: "EAAG", conversionEvent: "Purchase", enabled: true }) }));
});

it("reads the email config (null when absent) and upserts it", async () => {
Expand Down
Loading