"use server"; import { getAdminUser } from "@/lib/admin-permissions"; import { svcHeaders } from "@/lib/svc-headers"; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // ── Types ───────────────────────────────────────────────────────────────────── export type AbandonedCart = { id: string; brand_id: string; customer_id: string | null; contact_email: string; contact_name: string | null; cart_snapshot: { items: { name: string; quantity: number; unit_price: number }[]; subtotal: number; item_count: number; }; brand_name: string | null; locale: string; sequence_step: number; last_email_sent_at: string | null; next_email_at: string | null; status: string; recovered_order_id: string | null; recovered_at: string | null; created_at: string; }; export type GetAbandonedCartsResult = { success: true; carts: AbandonedCart[]; stats: { total: number; recovered: number; active: number; expired: number }; } | { success: false; error: string }; // ── Sequence email intervals ─────────────────────────────────────────────────── const EMAIL_INTERVALS_HOURS = [1, 24, 48] as const; const LOCALE_CART_SUBJECT: Record> = { en: { 1: { subject: "Your cart is waiting — complete your order", heading: "Don't forget your order", body: "You left items in your cart. Complete your order before the pickup date fills up.", }, 2: { subject: "Still thinking it over? Your cart is still here", heading: "A little reminder", body: "Your wholesale order is still waiting. Lock in your pickup date before it books up.", }, 3: { subject: "Last chance — your cart expires soon", heading: "One more day to order", body: "This is your final reminder. After this, your cart will no longer be available.", }, }, es: { 1: { subject: "Tu carrito te espera — completa tu pedido", heading: "No olvides tu pedido", body: "Dejaste artículos en tu carrito. Completa tu pedido antes de que se llene la fecha de recogida.", }, 2: { subject: "¿Aún lo estás pensando? Tu carrito sigue aquí", heading: "Un pequeño recordatorio", body: "Tu pedido al por mayor aún está esperando. Reserva tu fecha de recogida antes de que se llene.", }, 3: { subject: "Última oportunidad — tu carrito expira pronto", heading: "Un día más para ordenar", body: "Este es tu último recordatorio. Después de esto, tu carrito ya no estará disponible.", }, }, }; // ── Get all carts (admin view) ───────────────────────────────────────────────── export async function getAbandonedCarts(brandId: string): Promise { const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { return { success: false, error: "Not authorized" }; } const res = await fetch( `${supabaseUrl}/rest/v1/rpc/get_abandoned_carts`, { method: "POST", headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, body: JSON.stringify({ p_brand_id: brandId }), } ); if (!res.ok) return { success: false, error: "Failed to fetch abandoned carts" }; const data = await res.json(); // Supabase TABLE(func) returns [{carts: ...}] — extract from first row const row = Array.isArray(data) ? data[0] : data; const carts: AbandonedCart[] = row?.carts ?? []; return { success: true, carts, stats: { total: carts.length, recovered: carts.filter(c => c.status === "recovered").length, active: carts.filter(c => c.status === "active").length, expired: carts.filter(c => c.status === "expired").length, }, }; } // ── Manually close an abandoned cart ────────────────────────────────────────── export async function manuallyCloseAbandonedCart( cartId: string, brandId: string ): Promise<{ success: boolean; error?: string }> { const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { return { success: false, error: "Not authorized" }; } const res = await fetch( `${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`, { method: "POST", headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, body: JSON.stringify({ p_id: cartId, p_status: "manually_closed", p_manually_closed_by: adminUser.id, }), } ); if (!res.ok) return { success: false, error: "Failed to close cart" }; return { success: true }; } // ── Build email HTML ─────────────────────────────────────────────────────────── function buildCartRecoveryEmail(params: { brandName: string; contactName: string | null; locale: string; step: number; cartSnapshot: AbandonedCart["cart_snapshot"]; adminUrl: string; }): { subject: string; html: string; text: string } { const { brandName, contactName, locale, step, cartSnapshot, adminUrl } = params; const t = LOCALE_CART_SUBJECT[locale]?.[step] ?? LOCALE_CART_SUBJECT.en[step]; const greeting = locale === "es" ? (contactName ? `Hola ${contactName}` : `Hola`) : (contactName ? `Hi ${contactName}` : `Hi there`); const ctaText = locale === "es" ? "Completar mi pedido" : "Complete my order"; const footerText = locale === "es" ? "Este email fue enviado porque dejaste artículos en tu carrito." : "You received this email because you left items in your cart."; const itemsList = cartSnapshot.items .slice(0, 5) .map( item => `${item.name}${item.quantity} × $${Number(item.unit_price).toFixed(2)}` ) .join(""); const subject = t.subject; const html = `

${locale === "es" ? "Carrito abandonado" : "Abandoned Cart"}

${t.heading}

${brandName}

${greeting},

${t.body}

${itemsList}
Total$${Number(cartSnapshot.subtotal).toFixed(2)}
${ctaText}

${footerText}

`; const text = `[${brandName} - ${t.heading}]\n\n${greeting},\n\n${t.body}\n\nItems in cart (${cartSnapshot.item_count} items):\n${cartSnapshot.items.map(i => `- ${i.name}: ${i.quantity} × $${Number(i.unit_price).toFixed(2)}`).join("\n")}\nTotal: $${Number(cartSnapshot.subtotal).toFixed(2)}\n\nComplete your order: ${adminUrl}\n\n${footerText}`; return { subject, html, text }; } // ── Internal: send one recovery email ───────────────────────────────────────── export async function sendAbandonedCartEmail( cart: AbandonedCart, step: number, brandId: string ): Promise<{ success: boolean; error?: string }> { const adminUrl = `https://route-commerce-platform.vercel.app/admin/communications/abandoned-carts`; const { subject, html, text } = buildCartRecoveryEmail({ brandName: cart.brand_name ?? "Our Farm", contactName: cart.contact_name, locale: cart.locale ?? "en", step, cartSnapshot: cart.cart_snapshot, adminUrl, }); const RESEND_API_KEY = process.env.RESEND_API_KEY ?? ""; if (!RESEND_API_KEY) return { success: false, error: "Resend not configured" }; try { const res = await fetch("https://api.resend.com/emails", { method: "POST", headers: { Authorization: `Bearer ${RESEND_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ from: process.env.FROM_EMAIL ?? "Route Commerce ", to: [cart.contact_email], subject, html, text, }), }); if (!res.ok) { const err = await res.text(); return { success: false, error: err }; } const data = await res.json(); // Update cart record await fetch( `${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`, { method: "POST", headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, body: JSON.stringify({ p_id: cart.id, p_sequence_step: step, p_last_email_sent_at: new Date().toISOString(), p_next_email_at: step < 3 ? new Date(Date.now() + EMAIL_INTERVALS_HOURS[step] * 3600000).toISOString() : null, p_status: step >= 3 ? "expired" : "active", p_expired_at: step >= 3 ? new Date().toISOString() : null, }), } ); return { success: true }; } catch (e) { return { success: false, error: String(e) }; } } // ── Manually resend a specific email step ───────────────────────────────────── export async function resendAbandonedCartEmail( cartId: string, brandId: string ): Promise<{ success: boolean; error?: string }> { const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { return { success: false, error: "Not authorized" }; } const res = await fetch( `${supabaseUrl}/rest/v1/rpc/manual_resend_abandoned_cart_email`, { method: "POST", headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, body: JSON.stringify({ p_cart_id: cartId, p_step: 1 }), } ); if (!res.ok) return { success: false, error: "Failed to resend email" }; return { success: true }; } // ── Mark cart as recovered when order is placed ──────────────────────────────── export async function markCartRecovered(cartId: string, orderId: string): Promise { await fetch( `${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`, { method: "POST", headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, body: JSON.stringify({ p_id: cartId, p_status: "recovered", p_recovered_order_id: orderId, p_recovered_at: new Date().toISOString(), }), } ); }