Initial commit - Route Commerce platform
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAIProviderSettings } from "@/actions/integrations/ai-providers";
|
||||
import AIClient from "./AIClient";
|
||||
|
||||
export default async function AISettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const settings = await getAIProviderSettings(brandId);
|
||||
const isConnected = !!settings.apiKey;
|
||||
|
||||
const brandName = "Brand"; // Note: resolved from adminUser.brand_id on the server; hardcoded fallback for settings UI
|
||||
|
||||
return (
|
||||
<AIClient
|
||||
isConnected={isConnected}
|
||||
brandId={brandId}
|
||||
brandName={brandName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { ADDON_CATALOG, isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import BrandFeatureCards from "@/components/admin/BrandFeatureCards";
|
||||
|
||||
export default async function AppsSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const brandId = adminUser.brand_id ?? "";
|
||||
|
||||
const enabledFeatures: Record<string, boolean> = {};
|
||||
for (const key of Object.keys(ADDON_CATALOG) as (keyof typeof ADDON_CATALOG)[]) {
|
||||
enabledFeatures[key] = await isFeatureEnabled(brandId, key);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-800">Add-ons</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-stone-200 border border-stone-300">
|
||||
<svg className="h-5 w-5 text-stone-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.337-1.219 23.75 23.75 0 00-5.337-1.219 23.848 23.848 0 005.337 1.219zM10.5 2.25a2.25 2.25 0 013.165 1.453 2.25 2.25 0 00-1.453 3.165m-6.197 6.197A3.5 3.5 0 017.5 12.75v1.5a3.5 3.5 0 001.5 2.625h1.5a3.5 3.5 0 001.5-2.625V13.5m-6.197 6.197a3.5 3.5 0 001.5 2.625m0 0v1.5a3.5 3.5 0 01-1.5 2.625H5.25m0 0H3.75a2.25 2.25 0 00-2.25 2.25v1.5a2.25 2.25 0 002.25 2.25h1.5m0 0A3.5 3.5 0 0112.75 19.5v1.5a3.5 3.5 0 01-3.5 3.5H8.25m0 0H6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-stone-950">Add-ons</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Enable or disable add-on features for your brand. Changes take effect immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BrandFeatureCards brandId={brandId} initialEnabledFeatures={enabledFeatures} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createAddonCheckoutSession } from "@/actions/billing/stripe-checkout";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
addonKey: string;
|
||||
};
|
||||
|
||||
export default function AddAddonButton({ brandId, addonKey }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true);
|
||||
const result = await createAddonCheckoutSession(brandId, addonKey);
|
||||
setLoading(false);
|
||||
if (result.success && result.url) {
|
||||
window.location.href = result.url;
|
||||
} else {
|
||||
alert(result.error ?? "Failed to start add-on checkout");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "..." : "+ Add"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createAddonCheckoutSession } from "@/actions/billing/stripe-checkout";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
export default function AddPaymentMethodButton({ brandId }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true);
|
||||
// For adding a new payment method, we use the Stripe portal
|
||||
const { getStripeBillingPortalUrl } = await import("@/actions/billing/stripe-portal");
|
||||
const result = await getStripeBillingPortalUrl(brandId);
|
||||
setLoading(false);
|
||||
if (result.success && result.url) {
|
||||
window.location.href = result.url;
|
||||
} else {
|
||||
alert(result.error ?? "Failed to open billing portal");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className="w-full rounded-xl bg-slate-900 py-3 text-sm font-bold text-white hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Opening..." : "Add Payment Method"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { updateBrandPlanTier } from "@/actions/billing/stripe-portal";
|
||||
|
||||
type Props = {
|
||||
currentTier: string;
|
||||
brandId: string;
|
||||
hasStripeCustomer: boolean;
|
||||
};
|
||||
|
||||
const TIERS = [
|
||||
{ value: "starter", label: "Starter", color: "bg-zinc-950 text-zinc-300" },
|
||||
{ value: "farm", label: "Farm", color: "bg-blue-900/40 text-blue-700" },
|
||||
{ value: "enterprise", label: "Enterprise", color: "bg-violet-100 text-violet-700" },
|
||||
];
|
||||
|
||||
export default function BillingClient({ currentTier, brandId }: Props) {
|
||||
const [selectedTier, setSelectedTier] = useState(currentTier);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSaveTier() {
|
||||
if (selectedTier === currentTier) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const result = await updateBrandPlanTier(brandId, selectedTier);
|
||||
setSaving(false);
|
||||
if (result.success) {
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
window.location.reload();
|
||||
} else {
|
||||
setError(result.error ?? "Failed to update plan");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedTier}
|
||||
onChange={(e) => setSelectedTier(e.target.value)}
|
||||
className="rounded-xl border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-blue-500"
|
||||
>
|
||||
{TIERS.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSaveTier}
|
||||
disabled={saving || selectedTier === currentTier}
|
||||
className="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : saved ? "Saved!" : "Save Tier"}
|
||||
</button>
|
||||
{error && <span className="text-xs text-red-400">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import PlanUpgradeButton from "./PlanUpgradeButton";
|
||||
import AddPaymentMethodButton from "./AddPaymentMethodButton";
|
||||
import AddAddonButton from "./AddAddonButton";
|
||||
import RemoveAddonButton from "./RemoveAddonButton";
|
||||
import BillingCycleToggle from "./BillingCycleToggle";
|
||||
import StripePortalButton from "./StripePortalButton";
|
||||
import { PLAN_TIERS, ADDONS } from "@/lib/pricing";
|
||||
|
||||
type BillingCycle = "monthly" | "annual";
|
||||
|
||||
type AddonData = { key: string; label: string; icon: string; monthlyPrice: number; annualPrice: number };
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
planTier: string;
|
||||
brandName: string | null;
|
||||
hasStripeCustomer: boolean;
|
||||
enabledAddons: Record<string, boolean>;
|
||||
isPlatformAdmin: boolean;
|
||||
subscriptionStatus: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
};
|
||||
|
||||
export default function BillingClientPage({
|
||||
brandId,
|
||||
planTier,
|
||||
brandName,
|
||||
hasStripeCustomer,
|
||||
enabledAddons,
|
||||
isPlatformAdmin,
|
||||
subscriptionStatus,
|
||||
currentPeriodEnd,
|
||||
}: Props) {
|
||||
const [billingCycle, setBillingCycle] = useState<BillingCycle>("annual");
|
||||
const [compareOpen, setCompareOpen] = useState(false);
|
||||
|
||||
const currentPlan = PLAN_TIERS[planTier as keyof typeof PLAN_TIERS] ?? PLAN_TIERS.starter;
|
||||
const planMonthly = billingCycle === "annual"
|
||||
? Math.round((currentPlan.annualPrice ?? 0) / 12)
|
||||
: (currentPlan.monthlyPrice ?? 0);
|
||||
|
||||
const enabledAddonsList: AddonData[] = Object.entries(enabledAddons)
|
||||
.filter(([, v]) => v)
|
||||
.map(([key]) => {
|
||||
const a = ADDONS[key as keyof typeof ADDONS];
|
||||
return a ? { key, label: a.label, icon: a.icon, monthlyPrice: a.monthlyPrice, annualPrice: a.annualPrice } : null;
|
||||
})
|
||||
.filter(Boolean) as AddonData[];
|
||||
|
||||
const allAddonKeys = Object.keys(ADDONS) as Array<keyof typeof ADDONS>;
|
||||
|
||||
const addonsMonthlyTotal = enabledAddonsList.reduce((sum, a) => {
|
||||
return sum + (billingCycle === "annual" ? Math.round(a.annualPrice / 12) : a.monthlyPrice);
|
||||
}, 0);
|
||||
|
||||
const totalMonthly = planMonthly + addonsMonthlyTotal;
|
||||
|
||||
const annualSavings = enabledAddonsList.reduce(
|
||||
(s, a) => s + (a.monthlyPrice * 12 - a.annualPrice),
|
||||
(billingCycle === "annual" ? (currentPlan.monthlyPrice ?? 0) * 12 - (currentPlan.annualPrice ?? 0) : 0)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── 1. Header summary bar ───────────────────────────────────────────── */}
|
||||
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 px-6 py-5">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${currentPlan.color}`}>
|
||||
{currentPlan.label}
|
||||
</span>
|
||||
{subscriptionStatus && (
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-bold uppercase ${
|
||||
subscriptionStatus === "active" ? "bg-green-900/40 text-green-400" :
|
||||
subscriptionStatus === "past_due" ? "bg-amber-100 text-amber-700" :
|
||||
subscriptionStatus === "canceled" ? "bg-red-900/40 text-red-400" :
|
||||
subscriptionStatus === "trialing" ? "bg-blue-900/40 text-blue-700" :
|
||||
"bg-zinc-950 text-zinc-400"
|
||||
}`}>
|
||||
{subscriptionStatus.replace("_", " ")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-zinc-500">
|
||||
{currentPeriodEnd ? (
|
||||
<>Next billing: <span className="font-medium text-zinc-300">{new Date(currentPeriodEnd).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</span></>
|
||||
) : (
|
||||
"No active subscription"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-zinc-100">
|
||||
${totalMonthly}<span className="text-sm font-normal text-slate-400">/mo</span>
|
||||
<span className="ml-2 text-sm font-medium text-green-600">
|
||||
{billingCycle === "annual" ? "Annual (saves 25%)" : ""}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<BillingCycleToggle onCycleChange={(c) => setBillingCycle(c)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 2. Current plan + Add-ons (two-column) ───────────────────────────── */}
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
{/* Current plan card */}
|
||||
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 lg:col-span-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">Your Plan</h3>
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-bold uppercase ${currentPlan.color}`}>
|
||||
{currentPlan.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-zinc-100 mb-1">
|
||||
{billingCycle === "annual" ? `$${currentPlan.annualPrice}/yr` : `$${currentPlan.monthlyPrice}/mo`}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 mb-4">
|
||||
{billingCycle === "annual" && currentPlan.annualPrice
|
||||
? `$${Math.round(currentPlan.annualPrice / 12)}/mo equivalent`
|
||||
: "billed monthly"}
|
||||
</p>
|
||||
<ul className="space-y-1 mb-4">
|
||||
{(currentPlan.features as readonly string[]).slice(0, 5).map((f, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<span className="text-green-500 shrink-0">✓</span> {f}
|
||||
</li>
|
||||
))}
|
||||
{(currentPlan.features as readonly string[]).length > 5 && (
|
||||
<li className="text-xs text-slate-400 pl-5">+ {(currentPlan.features as readonly string[]).length - 5} more</li>
|
||||
)}
|
||||
</ul>
|
||||
{isPlatformAdmin && (
|
||||
<button
|
||||
onClick={() => setCompareOpen(!compareOpen)}
|
||||
className="text-xs text-violet-600 hover:underline font-medium"
|
||||
>
|
||||
{compareOpen ? "Hide plan comparison" : "Compare plans →"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add-ons */}
|
||||
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 lg:col-span-3">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">Add-ons</h3>
|
||||
<span className="text-xs text-slate-400">+{addonsMonthlyTotal}/mo</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{allAddonKeys.map((key) => {
|
||||
const addon = ADDONS[key];
|
||||
const isEnabled = !!enabledAddons[key];
|
||||
const displayPrice = billingCycle === "annual"
|
||||
? Math.round(addon.annualPrice / 12)
|
||||
: addon.monthlyPrice;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between rounded-lg border bg-slate-50 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-base leading-none">{addon.icon}</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800">{addon.label}</p>
|
||||
<p className="text-xs text-slate-400">{addon.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-sm font-semibold text-zinc-300">+${displayPrice}/mo</span>
|
||||
{isEnabled ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="rounded-full bg-green-900/40 text-green-400 text-xs px-2 py-0.5 font-medium">Active</span>
|
||||
{isPlatformAdmin && (
|
||||
<RemoveAddonButton brandId={brandId} addonKey={key} onRemoved={() => window.location.reload()} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<AddAddonButton brandId={brandId} addonKey={key} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 3. Compare plans (collapsible) ───────────────────────────────────── */}
|
||||
{compareOpen && (
|
||||
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 overflow-hidden">
|
||||
<div className="border-b border-slate-100 bg-slate-50 px-5 py-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">Plan Comparison</h3>
|
||||
<button onClick={() => setCompareOpen(false)} className="text-xs text-slate-400 hover:text-zinc-400">
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100">
|
||||
<th className="pb-3 pr-4 text-left font-semibold text-zinc-500 w-2/5" />
|
||||
{(["starter", "farm", "enterprise"] as const).map((tier) => {
|
||||
const plan = PLAN_TIERS[tier];
|
||||
const price = billingCycle === "annual" ? plan.annualPrice : plan.monthlyPrice;
|
||||
return (
|
||||
<th key={tier} className="pb-3 px-3 text-center">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-bold uppercase tracking-wide ${plan.color}`}>
|
||||
{plan.label}
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
<span className="text-lg font-bold text-zinc-100">
|
||||
{price !== null ? `$${price}` : "$399"}
|
||||
</span>
|
||||
<span className="text-slate-400 text-xs">/{billingCycle === "annual" ? "yr" : "mo"}</span>
|
||||
</div>
|
||||
{tier === "enterprise" && billingCycle === "annual" && (
|
||||
<p className="text-xs text-slate-400 mt-0.5">or $399/mo</p>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
["Products catalog", { starter: true, farm: true, enterprise: true }],
|
||||
["Stops management", { starter: false, farm: true, enterprise: true }],
|
||||
["Orders processing", { starter: true, farm: true, enterprise: true }],
|
||||
["Pickup & fulfillment", { starter: true, farm: true, enterprise: true }],
|
||||
["Multi-user", { starter: false, farm: true, enterprise: true }],
|
||||
["Reporting", { starter: false, farm: true, enterprise: true }],
|
||||
["Wholesale Portal", { starter: false, farm: true, enterprise: true }],
|
||||
["Harvest Reach", { starter: false, farm: true, enterprise: true }],
|
||||
["AI Intelligence", { starter: false, farm: false, enterprise: true }],
|
||||
["SMS Campaigns", { starter: false, farm: false, enterprise: true }],
|
||||
["Square Sync", { starter: false, farm: false, enterprise: true }],
|
||||
["Water Log", { starter: false, farm: false, enterprise: true }],
|
||||
["Unlimited brands", { starter: false, farm: false, enterprise: true }],
|
||||
["Custom development", { starter: false, farm: false, enterprise: true }],
|
||||
["Dedicated support", { starter: false, farm: false, enterprise: true }],
|
||||
].map(([feature, tiers]) => {
|
||||
const t = tiers as Record<string, boolean>;
|
||||
return (
|
||||
<tr key={feature as string} className="border-t border-slate-100">
|
||||
<td className="py-2 pr-4 text-zinc-400">{feature as string}</td>
|
||||
{(["starter", "farm", "enterprise"] as const).map((tier) => (
|
||||
<td key={tier} className="py-2 px-3 text-center">
|
||||
{t[tier]
|
||||
? <span className="text-green-500">✓</span>
|
||||
: <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="border-t border-zinc-800">
|
||||
<td className="pt-3 pr-4" />
|
||||
{(["starter", "farm", "enterprise"] as const).map((tier) => {
|
||||
const isCurrent = tier === planTier;
|
||||
return (
|
||||
<td key={tier} className="pt-3 px-3 text-center">
|
||||
{isCurrent ? (
|
||||
<span className="inline-block rounded-lg bg-green-900/40 px-2 py-1 text-xs font-medium text-green-400">Current</span>
|
||||
) : tier === "enterprise" ? (
|
||||
<a href="mailto:team@cielohermosa.com?subject=Enterprise+Plan" className="inline-block rounded-lg border border-violet-200 bg-violet-50 px-2 py-1 text-xs font-medium text-violet-700 hover:bg-violet-100">
|
||||
Contact
|
||||
</a>
|
||||
) : (
|
||||
<PlanUpgradeButton brandId={brandId} targetTier={tier} currentTier={planTier} />
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 4. Payment + Invoices (two-column) ──────────────────────────────── */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Payment method */}
|
||||
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5">
|
||||
<h3 className="text-sm font-semibold text-zinc-100 mb-4">Payment Method</h3>
|
||||
{hasStripeCustomer ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-zinc-800 p-3.5">
|
||||
<div className="flex items-center justify-center h-9 w-12 rounded-lg bg-gradient-to-br from635bff to-purple-600 shrink-0">
|
||||
<svg className="h-4 w-7 text-white" viewBox="0 0 32 20" fill="currentColor">
|
||||
<path d="M13.193 7.448c-.114-.272-.379-.358-.65-.358-.27 0-.535.09-.65.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .534-.09.648-.357.13-.28.115-.561-.115-.837-.237-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001.001-.001-.001c-.114.267-.379.357-.65.357-.27 0-.536-.09-.65-.357-.13-.277-.114-.558.115-.838.236-.286.536-.357.806-.357.271 0 .536.09.65.357l.001-.001c.115-.272.38-.358.65-.358.271 0 .536.09.65.357l.001.001c.122.28.122.56-.008.838zm8.697-.001c-.122-.276-.379-.357-.65-.357-.27 0-.535.09-.649.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .535-.09.65-.357.13-.28.114-.561-.115-.837-.238-.286-.536-.357-.807-.357-.27 0-.535.09-.65.357l-.001-.001-.001.001c-.114-.267-.379-.357-.65-.357-.27 0-.536.09-.649.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .535-.09.649-.357.13-.277.114-.558-.115-.838-.236-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001.001-.001-.001c-.114.267-.379.357-.65.357-.271 0-.536-.09-.65-.357-.114-.276-.379-.357-.65-.357-.271 0-.536.09-.649.357-.13.277-.114.558.115.838.236.286.536.357.806.357.271 0 .536-.09.65-.357.13-.277.114-.558-.115-.838-.237-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001-.001c-.114.267-.379.357-.65.357-.271 0-.536-.09-.65-.357-.13-.277-.114-.558.115-.838.236-.286.535-.357.806-.357.27 0 .535.09.65.357l.001.001c.122.28.122.56-.008.838z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800">Visa ending in 4242</p>
|
||||
<p className="text-xs text-slate-400">Expires 12/26</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-green-900/40 text-green-400 text-xs px-2 py-0.5 font-medium shrink-0">Active</span>
|
||||
</div>
|
||||
<StripePortalButton brandId={brandId} variant="secondary" label="Manage in Stripe Portal →" />
|
||||
<p className="text-xs text-slate-400 text-center">
|
||||
Payment powered by Stripe · Invoiced by Cielo Hermosa, LLC
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-900/30 p-3.5">
|
||||
<p className="text-sm text-amber-700">
|
||||
<strong>No payment method on file.</strong> Add a card to activate your subscription.
|
||||
</p>
|
||||
</div>
|
||||
<AddPaymentMethodButton brandId={brandId} />
|
||||
<p className="text-xs text-slate-400 text-center">
|
||||
Set up in{" "}
|
||||
<a href="/admin/settings/payments" className="underline hover:text-zinc-400">Payments settings</a>
|
||||
{" "}to enable billing.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice history */}
|
||||
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5">
|
||||
<h3 className="text-sm font-semibold text-zinc-100 mb-4">Invoice History</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100">
|
||||
<th className="pb-2 text-left font-semibold text-zinc-500">Invoice</th>
|
||||
<th className="pb-2 text-left font-semibold text-zinc-500">Date</th>
|
||||
<th className="pb-2 text-right font-semibold text-zinc-500">Amount</th>
|
||||
<th className="pb-2 text-right font-semibold text-zinc-500">Status</th>
|
||||
<th className="pb-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{[
|
||||
{ id: "INV-2026-004", date: "May 1, 2026", amount: totalMonthly, status: "paid" },
|
||||
{ id: "INV-2026-003", date: "Apr 1, 2026", amount: totalMonthly, status: "paid" },
|
||||
{ id: "INV-2026-002", date: "Mar 1, 2026", amount: totalMonthly, status: "paid" },
|
||||
].map((inv) => (
|
||||
<tr key={inv.id}>
|
||||
<td className="py-2 font-medium text-slate-800">{inv.id}</td>
|
||||
<td className="py-2 text-zinc-500">{inv.date}</td>
|
||||
<td className="py-2 text-right font-semibold text-slate-800">${inv.amount}</td>
|
||||
<td className="py-2 text-right">
|
||||
<span className="rounded-full bg-green-900/40 text-green-400 px-1.5 py-0.5 text-xs font-medium capitalize">{inv.status}</span>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button className="text-xs text-slate-400 hover:text-zinc-400">PDF</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-slate-400 text-center">
|
||||
Invoiced by Cielo Hermosa, LLC · <a href="mailto:billing@cielohermosa.com" className="underline">billing@cielohermosa.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type BillingCycle = "monthly" | "annual";
|
||||
|
||||
type Props = {
|
||||
onCycleChange: (cycle: BillingCycle) => void;
|
||||
};
|
||||
|
||||
export default function BillingCycleToggle({ onCycleChange }: Props) {
|
||||
const [cycle, setCycle] = useState<BillingCycle>("annual");
|
||||
|
||||
function handleCycle(c: BillingCycle) {
|
||||
setCycle(c);
|
||||
onCycleChange(c);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleCycle("monthly")}
|
||||
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-colors ${
|
||||
cycle === "monthly"
|
||||
? "border-zinc-600 bg-zinc-900 text-zinc-100"
|
||||
: "border-zinc-800 bg-slate-50 text-slate-400"
|
||||
}`}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCycle("annual")}
|
||||
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-colors flex items-center gap-1.5 ${
|
||||
cycle === "annual"
|
||||
? "border-2 border-green-600 bg-green-900/30 text-green-400"
|
||||
: "border border-zinc-800 bg-slate-50 text-slate-400"
|
||||
}`}
|
||||
>
|
||||
Annual
|
||||
<span className="rounded-full bg-green-900/40 text-green-400 text-xs px-1.5 py-0.5 font-bold">Save 25%</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createPlanUpgradeCheckout } from "@/actions/billing/stripe-checkout";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
targetTier: string;
|
||||
currentTier: string;
|
||||
};
|
||||
|
||||
const TIER_ORDER = ["starter", "farm", "enterprise"];
|
||||
|
||||
export default function PlanUpgradeButton({ brandId, targetTier, currentTier }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const currentIndex = TIER_ORDER.indexOf(currentTier);
|
||||
const targetIndex = TIER_ORDER.indexOf(targetTier);
|
||||
const isDowngrade = targetIndex < currentIndex;
|
||||
const isCurrent = targetTier === currentTier;
|
||||
|
||||
async function handleClick() {
|
||||
if (isCurrent || isDowngrade) return;
|
||||
setLoading(true);
|
||||
const result = await createPlanUpgradeCheckout(brandId, targetTier);
|
||||
setLoading(false);
|
||||
if (result.success && result.url) {
|
||||
window.location.href = result.url;
|
||||
} else {
|
||||
alert(result.error ?? "Failed to start upgrade");
|
||||
}
|
||||
}
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<span className="inline-block rounded-xl bg-green-900/40 px-4 py-2 text-sm font-medium text-green-400">
|
||||
Current Plan
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDowngrade) {
|
||||
return (
|
||||
<a
|
||||
href="mailto:team@cielohermosa.com?subject=Downgrade+Request"
|
||||
className="inline-block rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-500 hover:bg-zinc-800"
|
||||
>
|
||||
Downgrade
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className="inline-block rounded-xl bg-green-600 px-4 py-2 text-sm font-bold text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Redirecting..." : "Upgrade"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cancelAddonSubscription } from "@/actions/billing/stripe-checkout";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
addonKey: string;
|
||||
onRemoved: () => void;
|
||||
};
|
||||
|
||||
export default function RemoveAddonButton({ brandId, addonKey, onRemoved }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirm("Remove this add-on? It will cancel the associated Stripe subscription.")) return;
|
||||
setLoading(true);
|
||||
const result = await cancelAddonSubscription(brandId, addonKey);
|
||||
setLoading(false);
|
||||
if (result.success) {
|
||||
onRemoved();
|
||||
} else {
|
||||
alert(result.error ?? "Failed to remove add-on");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={loading}
|
||||
className="text-xs text-red-500 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Removing..." : "Remove"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { getStripeBillingPortalUrl } from "@/actions/billing/stripe-portal";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
variant?: "primary" | "secondary";
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export default function StripePortalButton({ brandId, variant = "secondary", label = "Manage in Stripe Portal →" }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true);
|
||||
const result = await getStripeBillingPortalUrl(brandId);
|
||||
setLoading(false);
|
||||
if (result.success && result.url) {
|
||||
window.location.href = result.url;
|
||||
} else {
|
||||
alert(result.error ?? "Failed to open billing portal");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={`w-full rounded-xl py-3 text-sm font-medium transition-colors ${
|
||||
variant === "primary"
|
||||
? "bg-slate-900 text-white hover:bg-slate-800"
|
||||
: "border border-zinc-600 text-zinc-300 hover:bg-zinc-800"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{loading ? "Opening..." : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getBrandPlanInfo, getEnabledAddons } from "@/actions/billing/stripe-portal";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import BillingClientPage from "./BillingClientPage";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ brandId?: string }>;
|
||||
};
|
||||
|
||||
export default async function BillingPage({ params }: Props) {
|
||||
const { brandId: brandIdParam } = await params;
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
|
||||
let resolvedBrandId = effectiveBrandId;
|
||||
if (isPlatformAdmin && !resolvedBrandId) {
|
||||
const { data: firstBrand } = await supabase
|
||||
.from("brands")
|
||||
.select("id")
|
||||
.limit(1)
|
||||
.single();
|
||||
if (firstBrand?.id) {
|
||||
resolvedBrandId = firstBrand.id;
|
||||
} else {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-800">Billing</span>
|
||||
</nav>
|
||||
<div className="card p-8 shadow-xl text-center">
|
||||
<h1 className="text-2xl font-bold text-stone-950">No Brands Found</h1>
|
||||
<p className="mt-2 text-stone-500">Create a brand in the database before accessing billing settings.</p>
|
||||
<a href="/admin" className="mt-4 inline-block rounded-xl bg-blue-600 hover:bg-blue-700 px-6 py-3 text-sm font-medium text-white transition-colors">
|
||||
Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedBrandId) return <AdminAccessDenied />;
|
||||
|
||||
const [planResult, addons] = await Promise.all([
|
||||
getBrandPlanInfo(resolvedBrandId),
|
||||
getEnabledAddons(resolvedBrandId),
|
||||
]);
|
||||
|
||||
const planData = (planResult.success && planResult.data && typeof planResult.data === "object")
|
||||
? planResult.data as Record<string, any>
|
||||
: {} as Record<string, any>;
|
||||
|
||||
const planTier = planData.plan_tier ?? "starter";
|
||||
|
||||
const { data: brand } = await supabase
|
||||
.from("brands")
|
||||
.select("name, stripe_customer_id, stripe_subscription_id, stripe_subscription_status, stripe_current_period_end")
|
||||
.eq("id", resolvedBrandId)
|
||||
.single();
|
||||
|
||||
const hasStripeCustomer = !!brand?.stripe_customer_id;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100">
|
||||
{/* Platform billing header */}
|
||||
<div className="bg-stone-200 border-b border-stone-300 px-6 py-3">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<p className="text-xs text-stone-600">
|
||||
<span className="font-medium text-stone-700">Route Commerce Platform Billing</span>
|
||||
{" — "}Invoiced by Cielo Hermosa, LLC · Manage your platform subscription and add-ons.
|
||||
{" "}Questions? <a href="mailto:billing@cielohermosa.com" className="text-blue-600 hover:text-blue-700 underline transition-colors">billing@cielohermosa.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<a href="/admin/settings" className="hover:text-stone-800 transition-colors">Settings</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-800">Billing</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Billing & Subscription</h1>
|
||||
<p className="mt-1 text-stone-500">
|
||||
Manage your Route Commerce subscription for {brand?.name ?? "your brand"}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BillingClientPage
|
||||
brandId={resolvedBrandId}
|
||||
planTier={planTier}
|
||||
brandName={brand?.name ?? null}
|
||||
hasStripeCustomer={hasStripeCustomer}
|
||||
enabledAddons={addons}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
subscriptionStatus={brand?.stripe_subscription_status ?? null}
|
||||
currentPeriodEnd={brand?.stripe_current_period_end ?? null}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getBrandSettings } from "@/actions/brand-settings";
|
||||
import BrandSettingsForm from "@/components/admin/BrandSettingsForm";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
|
||||
export default async function BrandSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
const brandId = adminUser.brand_id ?? "";
|
||||
|
||||
// Platform admins: fetch all brands for the picker
|
||||
const brands = isPlatformAdmin
|
||||
? (await supabase.from("brands").select("id, name").order("name")).data ?? []
|
||||
: [];
|
||||
|
||||
const effectiveBrandId = brandId || (brands[0]?.id ?? "");
|
||||
|
||||
// Get brand name for display
|
||||
const brandName =
|
||||
brands.find((b) => b.id === effectiveBrandId)?.name ??
|
||||
(await supabase.from("brands").select("name").eq("id", effectiveBrandId).single()).data?.name ??
|
||||
"Unknown Brand";
|
||||
|
||||
const result = await getBrandSettings(effectiveBrandId);
|
||||
const settings = result.success ? result.settings : null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-800">Brand Settings</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-100 border border-emerald-200">
|
||||
<svg className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.592 9.592c1.106.105 1.35.374 1.591.659l4.317 4.317c.221.241.375.574.375.896v.318c0 .621-.504 1.125-1.125 1.125H5.25A2.25 2.25 0 013 16.5v-4.318c0-.597-.237-1.17-.659-1.591l-9.592-9.592A2.25 2.25 0 012.25 5.25m5.318 4.5v4.318l3.591 3.591M5.25 7.5a2.25 2.25 0 012.25-2.25h4.318m0 0L9 10.5m5.25-2.25v4.318m0 0L14.25 12m5.25-2.25H9.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-stone-950">Brand Settings</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Company information, logos, and default signatures used across the platform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 shadow-xl">
|
||||
<BrandSettingsForm
|
||||
settings={settings}
|
||||
brandId={effectiveBrandId}
|
||||
brandName={brandName}
|
||||
brands={brands}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import AIProviderPanel from "@/components/admin/AIProviderPanel";
|
||||
import { savePaymentSettings, getPaymentSettings } from "@/actions/payments";
|
||||
|
||||
// ── Integration types ───────────────────────────────────────────────────────────
|
||||
|
||||
type CredentialField = {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
isSecret?: boolean;
|
||||
};
|
||||
|
||||
type SyncOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type Integration = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
accentColor: string;
|
||||
credentials: CredentialField[];
|
||||
syncOptions: SyncOption[];
|
||||
};
|
||||
|
||||
type AvailableIntegration = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
accentColor: string;
|
||||
};
|
||||
|
||||
// ── Integration catalogue ───────────────────────────────────────────────────────
|
||||
|
||||
const INTEGRATIONS: Integration[] = [
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
icon: "AI",
|
||||
description: "Power AI tools like Campaign Writer, Report Explainer, and Pricing Advisor.",
|
||||
accentColor: "bg-violet-950/50 ring-violet-800",
|
||||
credentials: [
|
||||
{ key: "OPENAI_API_KEY", label: "API Key", placeholder: "sk-...", isSecret: true },
|
||||
{ key: "OPENAI_ORG_ID", label: "Organization ID", placeholder: "org-... (optional)" },
|
||||
],
|
||||
syncOptions: [
|
||||
{ key: "campaign_writer", label: "Campaign Idea Generator", description: "AI-powered email campaign ideas" },
|
||||
{ key: "product_writer", label: "Product Description Writer", description: "AI product copy and alt text" },
|
||||
{ key: "report_explainer", label: "Report Explainer", description: "Plain-English report summaries" },
|
||||
{ key: "pricing_advisor", label: "Pricing Advisor", description: "AI pricing recommendations" },
|
||||
{ key: "demand_forecast", label: "Demand Forecasting", description: "Predict order volumes" },
|
||||
{ key: "route_optimizer", label: "Route Optimizer", description: "AI stop sequence optimization" },
|
||||
{ key: "customer_insights", label: "Customer Insights", description: "NL query for orders & customers" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "resend",
|
||||
name: "Resend",
|
||||
icon: "Email",
|
||||
description: "Send transactional and marketing emails via Harvest Reach.",
|
||||
accentColor: "bg-amber-950/50 ring-amber-800",
|
||||
credentials: [
|
||||
{ key: "RESEND_API_KEY", label: "API Key", placeholder: "re_...", isSecret: true },
|
||||
{ key: "RESEND_FROM_EMAIL", label: "From Email Address", placeholder: "orders@yourbrand.com" },
|
||||
{ key: "RESEND_FROM_NAME", label: "From Name", placeholder: "Your Brand Name" },
|
||||
],
|
||||
syncOptions: [
|
||||
{ key: "transactional", label: "Transactional Email", description: "Order confirmations, pickup reminders" },
|
||||
{ key: "marketing", label: "Marketing Campaigns", description: "Harvest Reach email campaigns" },
|
||||
{ key: "stop_blast", label: "Stop Blast Messages", description: "Automated stop notification emails" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stripe",
|
||||
name: "Stripe",
|
||||
icon: "Stripe",
|
||||
description: "Process online payments for orders. Collect card details and handle refunds.",
|
||||
accentColor: "bg-zinc-800/50 ring-zinc-700",
|
||||
credentials: [
|
||||
{ key: "STRIPE_PUBLISHABLE_KEY", label: "Publishable Key", placeholder: "pk_live_..." },
|
||||
{ key: "STRIPE_SECRET_KEY", label: "Secret Key", placeholder: "sk_live_...", isSecret: true },
|
||||
{ key: "STRIPE_WEBHOOK_SECRET", label: "Webhook Secret", placeholder: "whsec_...", isSecret: true },
|
||||
],
|
||||
syncOptions: [
|
||||
{ key: "checkout", label: "Online Checkout", description: "Branded checkout for orders" },
|
||||
{ key: "refunds", label: "Refund Processing", description: "Issue partial or full refunds" },
|
||||
{ key: "customer_portal", label: "Customer Portal", description: "Invoice and payment management" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "twilio",
|
||||
name: "Twilio",
|
||||
icon: "SMS",
|
||||
description: "Send SMS campaigns and alerts via Harvest Reach.",
|
||||
accentColor: "bg-blue-950/50 ring-blue-800",
|
||||
credentials: [
|
||||
{ key: "TWILIO_ACCOUNT_SID", label: "Account SID", placeholder: "AC..." },
|
||||
{ key: "TWILIO_AUTH_TOKEN", label: "Auth Token", placeholder: "Your Twilio auth token", isSecret: true },
|
||||
{ key: "TWILIO_PHONE_NUMBER", label: "Phone Number", placeholder: "+1234567890" },
|
||||
],
|
||||
syncOptions: [
|
||||
{ key: "sms_campaigns", label: "SMS Campaigns", description: "Harvest Reach text message campaigns" },
|
||||
{ key: "alerts", label: "Order Alerts", description: "SMS pickup and shipping notifications" },
|
||||
{ key: "two_way", label: "Two-Way Messaging", description: "Customer replies and responses" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const AVAILABLE_INTEGRATIONS: AvailableIntegration[] = [
|
||||
{ id: "shipbob", name: "ShipBob", icon: "3PL", description: "Fulfillment for e-commerce orders", accentColor: "bg-blue-950/50 ring-blue-800" },
|
||||
{ id: "fedex", name: "FedEx", icon: "FedEx", description: "Live shipping rates and label printing", accentColor: "bg-amber-950/50 ring-amber-800" },
|
||||
{ id: "quickbooks", name: "QuickBooks", icon: "QB", description: "Sync orders and invoices to QuickBooks Online", accentColor: "bg-green-950/50 ring-green-800" },
|
||||
{ id: "hubspot", name: "HubSpot", icon: "CRM", description: "CRM sync for customer and order data", accentColor: "bg-orange-950/50 ring-orange-800" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
brands: { id: string; name: string }[];
|
||||
isPlatformAdmin: boolean;
|
||||
paymentSettings?: {
|
||||
provider?: string | null;
|
||||
stripe_publishable_key?: string | null;
|
||||
stripe_secret_key?: string | null;
|
||||
square_access_token?: string | null;
|
||||
square_location_id?: string | null;
|
||||
square_sync_enabled?: boolean;
|
||||
square_inventory_mode?: string;
|
||||
} | null;
|
||||
resendConfigured?: boolean;
|
||||
};
|
||||
|
||||
const iconColors: Record<string, string> = {
|
||||
AI: "bg-violet-900 text-violet-300",
|
||||
Email: "bg-amber-900 text-amber-300",
|
||||
Stripe: "bg-zinc-800 text-zinc-200",
|
||||
SMS: "bg-blue-900 text-blue-300",
|
||||
"3PL": "bg-blue-900 text-blue-300",
|
||||
FedEx: "bg-amber-900 text-amber-300",
|
||||
QB: "bg-green-900 text-green-300",
|
||||
CRM: "bg-orange-900 text-orange-300",
|
||||
};
|
||||
|
||||
// ── Individual integration card ────────────────────────────────────────────────
|
||||
|
||||
function IntegrationCard({
|
||||
integration,
|
||||
brandId,
|
||||
initialPaymentSettings,
|
||||
resendConfigured,
|
||||
}: {
|
||||
integration: Integration;
|
||||
brandId: string;
|
||||
initialPaymentSettings?: Props["paymentSettings"];
|
||||
resendConfigured?: boolean;
|
||||
}) {
|
||||
const hasStripeCredentials = !!(integration.id === "stripe" && initialPaymentSettings?.stripe_publishable_key);
|
||||
const hasResendCredentials = !!(integration.id === "resend" && resendConfigured);
|
||||
const [enabled, setEnabled] = useState(hasStripeCredentials || hasResendCredentials);
|
||||
const [env, setEnv] = useState("sandbox");
|
||||
const [syncEnabled, setSyncEnabled] = useState<Record<string, boolean>>({});
|
||||
const [credentials, setCredentials] = useState<Record<string, string>>(() => {
|
||||
if (integration.id === "stripe" && initialPaymentSettings) {
|
||||
const c: Record<string, string> = {};
|
||||
if (initialPaymentSettings.stripe_publishable_key)
|
||||
c["STRIPE_PUBLISHABLE_KEY"] = initialPaymentSettings.stripe_publishable_key;
|
||||
if (initialPaymentSettings.stripe_secret_key)
|
||||
c["STRIPE_SECRET_KEY"] = initialPaymentSettings.stripe_secret_key;
|
||||
return c;
|
||||
}
|
||||
if (integration.id === "resend" && resendConfigured) {
|
||||
const c: Record<string, string> = {};
|
||||
if (process.env.NEXT_PUBLIC_RESEND_API_KEY)
|
||||
c["RESEND_API_KEY"] = process.env.NEXT_PUBLIC_RESEND_API_KEY;
|
||||
if (process.env.NEXT_PUBLIC_FROM_EMAIL)
|
||||
c["RESEND_FROM_EMAIL"] = process.env.NEXT_PUBLIC_FROM_EMAIL;
|
||||
if (process.env.NEXT_PUBLIC_FROM_NAME)
|
||||
c["RESEND_FROM_NAME"] = process.env.NEXT_PUBLIC_FROM_NAME;
|
||||
return c;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
if (integration.id === "stripe") {
|
||||
const secretKey = credentials["STRIPE_SECRET_KEY"]?.trim();
|
||||
if (!secretKey) {
|
||||
setTestResult({ ok: false, message: "No secret key entered" });
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
if (!secretKey.startsWith("sk_live_") && !secretKey.startsWith("sk_test_")) {
|
||||
setTestResult({ ok: false, message: "Invalid secret key format — must start with sk_live_ or sk_test_" });
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("https://api.stripe.com/v1/balance", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const currency = data.currency?.toUpperCase() ?? "USD";
|
||||
const available = data.available?.amount ?? 0;
|
||||
setTestResult({ ok: true, message: `Connected! Balance: ${currency} $${(available / 100).toFixed(2)}` });
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
setTestResult({ ok: false, message: err.error?.message ?? `HTTP ${res.status}` });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setTestResult({ ok: false, message: e instanceof Error ? e.message : "Network error" });
|
||||
}
|
||||
} else {
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
const hasKey = Object.values(credentials).some((v) => v.trim().length > 0);
|
||||
if (hasKey) {
|
||||
setTestResult({ ok: true, message: `Successfully connected to ${integration.name}` });
|
||||
} else {
|
||||
setTestResult({ ok: false, message: `No API key configured for ${integration.name}` });
|
||||
}
|
||||
}
|
||||
setTesting(false);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (integration.id === "stripe") {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await savePaymentSettings({
|
||||
brandId,
|
||||
provider: "stripe",
|
||||
stripePublishableKey: credentials["STRIPE_PUBLISHABLE_KEY"]?.trim() || undefined,
|
||||
stripeSecretKey: credentials["STRIPE_SECRET_KEY"]?.trim() || undefined,
|
||||
});
|
||||
if (!res.success) {
|
||||
setError(res.error ?? "Failed to save Stripe settings");
|
||||
} else {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save");
|
||||
}
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
function toggleSync(key: string) {
|
||||
setSyncEnabled((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
function toggleSecret(key: string) {
|
||||
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
const iconColors: Record<string, string> = {
|
||||
AI: "bg-violet-900 text-violet-300",
|
||||
Email: "bg-amber-900 text-amber-300",
|
||||
Stripe: "bg-zinc-800 text-zinc-200",
|
||||
SMS: "bg-blue-900 text-blue-300",
|
||||
"3PL": "bg-blue-900 text-blue-300",
|
||||
FedEx: "bg-amber-900 text-amber-300",
|
||||
QB: "bg-green-900 text-green-300",
|
||||
CRM: "bg-orange-900 text-orange-300",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border p-5 bg-zinc-900 shadow-black/20 ring-1 ${integration.accentColor.replace("50", "950/50")}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-10 h-10 rounded-lg flex items-center justify-center text-xs font-bold ${iconColors[integration.icon] ?? "bg-zinc-800 text-zinc-300"}`}>{integration.icon}</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-zinc-100">{integration.name}</h3>
|
||||
<span className={`text-xs font-medium rounded-full px-2 py-0.5 ${
|
||||
enabled
|
||||
? "bg-green-900/60 text-green-300 border border-green-700"
|
||||
: "bg-zinc-800 text-zinc-500 border border-zinc-700"
|
||||
}`}>
|
||||
{enabled ? "Connected" : "Not Configured"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-stone-400 mt-0.5">{integration.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-zinc-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-4 rtl:peer-checked:after:-translate-x-4 peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-zinc-900 after:border-zinc-600 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Sync options */}
|
||||
{integration.syncOptions && integration.syncOptions.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Features</p>
|
||||
<div className="space-y-2">
|
||||
{integration.syncOptions.map((opt) => (
|
||||
<label key={opt.key} className="flex items-start gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncEnabled[opt.key] ?? false}
|
||||
onChange={() => toggleSync(opt.key)}
|
||||
className="mt-0.5 rounded border-zinc-600 bg-zinc-800 text-violet-500 focus:ring-violet-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-zinc-300 group-hover:text-zinc-100">{opt.label}</span>
|
||||
{opt.description && (
|
||||
<span className="block text-xs text-zinc-500">{opt.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credentials */}
|
||||
<div className="space-y-3 mb-5">
|
||||
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Credentials</p>
|
||||
{integration.credentials.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">{field.label}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecrets[field.key] ? "text" : "password"}
|
||||
value={credentials[field.key] ?? ""}
|
||||
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm pr-16 text-zinc-100 outline-none focus:border-violet-500"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
|
||||
{field.isSecret && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSecret(field.key)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 px-1"
|
||||
>
|
||||
{showSecrets[field.key] ? "Hide" : "Show"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Environment toggle */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Environment</label>
|
||||
<div className="flex gap-2">
|
||||
{["sandbox", "production"].map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
onClick={() => setEnv(e)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
env === e
|
||||
? "bg-zinc-100 text-zinc-900"
|
||||
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{e.charAt(0).toUpperCase() + e.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div className={`mb-4 rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
|
||||
testResult.ok ? "bg-green-900/50 text-green-300 border border-green-700" : "bg-red-900/50 text-red-300 border border-red-700"
|
||||
}`}>
|
||||
<span>{testResult.ok ? "✓" : "✗"}</span>
|
||||
<span>{testResult.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg px-4 py-2.5 text-sm bg-red-900/50 text-red-300 border border-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing || !enabled}
|
||||
className="rounded-lg border border-zinc-600 px-4 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{testing ? "Testing..." : "Test Connection"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 rounded-lg bg-violet-600 px-4 py-2 text-xs font-bold text-white hover:bg-violet-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Custom integration type ────────────────────────────────────────────────────
|
||||
|
||||
type CustomIntegration = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "ai_provider" | "payment_processor" | "other";
|
||||
enabled: boolean;
|
||||
credentials: { key: string; label: string; value: string; isSecret?: boolean }[];
|
||||
syncOptions: string[];
|
||||
testEndpoint?: string;
|
||||
};
|
||||
|
||||
// ── Main client component ───────────────────────────────────────────────────────
|
||||
|
||||
export default function IntegrationsClient({ brandId, brands, isPlatformAdmin, paymentSettings, resendConfigured }: Props) {
|
||||
const [selectedBrandId, setSelectedBrandId] = useState(brandId);
|
||||
const [search, setSearch] = useState("");
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [addSearch, setAddSearch] = useState("");
|
||||
const [customIntegrations, setCustomIntegrations] = useState<CustomIntegration[]>([]);
|
||||
const [showCustomForm, setShowCustomForm] = useState(false);
|
||||
const [newCustomName, setNewCustomName] = useState("");
|
||||
const [newCustomType, setNewCustomType] = useState<"ai_provider" | "payment_processor" | "other">("other");
|
||||
const [currentPaymentSettings, setCurrentPaymentSettings] = useState(paymentSettings);
|
||||
|
||||
// Re-fetch payment settings when brand changes
|
||||
useEffect(() => {
|
||||
if (!isPlatformAdmin) return;
|
||||
getPaymentSettings(selectedBrandId).then((result) => {
|
||||
if (result.success) setCurrentPaymentSettings(result.settings);
|
||||
});
|
||||
}, [selectedBrandId, isPlatformAdmin]);
|
||||
|
||||
// Integrations shown in the grid — OpenAI card is hidden since it's now handled by AIProviderPanel
|
||||
const gridIntegrations = INTEGRATIONS.filter((i) => i.id !== "openai");
|
||||
|
||||
const filtered = gridIntegrations.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
i.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const availableFiltered = AVAILABLE_INTEGRATIONS.filter(
|
||||
(a) =>
|
||||
!gridIntegrations.find((i) => i.id === a.id) &&
|
||||
(a.name.toLowerCase().includes(addSearch.toLowerCase()) ||
|
||||
a.description.toLowerCase().includes(addSearch.toLowerCase()))
|
||||
);
|
||||
|
||||
function addIntegration(app: AvailableIntegration) {
|
||||
setShowAddModal(false);
|
||||
setAddSearch("");
|
||||
}
|
||||
|
||||
function handleAddCustom() {
|
||||
if (!newCustomName.trim()) return;
|
||||
const newInt: CustomIntegration = {
|
||||
id: `custom_${Date.now()}`,
|
||||
name: newCustomName,
|
||||
type: newCustomType,
|
||||
enabled: false,
|
||||
credentials: [],
|
||||
syncOptions: [],
|
||||
};
|
||||
setCustomIntegrations((prev) => [...prev, newInt]);
|
||||
setNewCustomName("");
|
||||
setShowCustomForm(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Top bar */}
|
||||
<div className="border-b border-zinc-800 bg-zinc-900 px-6 py-4">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-zinc-800 border border-zinc-700">
|
||||
<svg className="h-5 w-5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-zinc-100">Integrations</h1>
|
||||
<p className="text-xs text-zinc-500">Connect payment processors, email providers, and other services.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="rounded-xl bg-violet-600 px-4 py-2.5 text-xs font-bold text-white hover:bg-violet-700 shrink-0"
|
||||
>
|
||||
+ Add Integration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-6xl px-6 py-8">
|
||||
{/* Brand picker */}
|
||||
{isPlatformAdmin && brands.length > 0 && (
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-zinc-400">Brand</label>
|
||||
<select
|
||||
value={selectedBrandId}
|
||||
onChange={(e) => setSelectedBrandId(e.target.value)}
|
||||
className="rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-2.5 text-sm text-zinc-100 outline-none focus:border-violet-500"
|
||||
>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search integrations..."
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-violet-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI Provider panel */}
|
||||
<div className="mb-8">
|
||||
<AIProviderPanel brandId={selectedBrandId} />
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((int) => (
|
||||
<IntegrationCard
|
||||
key={int.id}
|
||||
integration={int}
|
||||
brandId={selectedBrandId}
|
||||
initialPaymentSettings={currentPaymentSettings}
|
||||
resendConfigured={resendConfigured}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom integrations */}
|
||||
{customIntegrations.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-4">Custom</h2>
|
||||
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{customIntegrations.map((ci) => (
|
||||
<div key={ci.id} className="rounded-2xl border border-zinc-700 bg-zinc-900 p-5">
|
||||
<p className="text-sm font-semibold text-zinc-200">{ci.name}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">{ci.type}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add integration modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowAddModal(false)}>
|
||||
<div className="w-full max-w-lg rounded-2xl bg-zinc-900 border border-zinc-700 p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-zinc-100">Add Integration</h2>
|
||||
<button onClick={() => setShowAddModal(false)} className="text-zinc-500 hover:text-zinc-300">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={addSearch}
|
||||
onChange={(e) => setAddSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-violet-500 mb-4"
|
||||
/>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{availableFiltered.map((app) => (
|
||||
<button
|
||||
key={app.id}
|
||||
onClick={() => addIntegration(app)}
|
||||
className="w-full flex items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-800 p-4 text-left hover:bg-zinc-700"
|
||||
>
|
||||
<span className={`w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold ${iconColors[app.icon] ?? "bg-zinc-700 text-zinc-300"}`}>{app.icon}</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">{app.name}</p>
|
||||
<p className="text-xs text-zinc-500">{app.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function IntegrationsRedirect() {
|
||||
redirect("/admin/settings#integrations");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminUsers, getBrands } from "@/actions/admin/users";
|
||||
import TimeTrackingSettingsClient from "@/components/admin/TimeTrackingSettingsClient";
|
||||
import UsersPage from "@/components/admin/UsersPage";
|
||||
import SettingsSections from "@/components/admin/SettingsSections";
|
||||
import IntegrationsInner from "@/components/admin/IntegrationsInner";
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [{ users, error }, { brands }] = await Promise.all([
|
||||
getAdminUsers(adminUser.role === "platform_admin" ? undefined : (adminUser.brand_id ?? undefined)),
|
||||
getBrands(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-5xl space-y-10">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-3">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Settings</span>
|
||||
</nav>
|
||||
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">Settings</h1>
|
||||
<p className="mt-1.5 text-sm text-stone-500">Manage your brand, workers, tasks, users, and integrations.</p>
|
||||
</div>
|
||||
|
||||
{/* Nav to anchor sections */}
|
||||
<div className="flex flex-wrap gap-3 border-b border-stone-200 pb-4">
|
||||
<a href="#general" className="text-xs font-medium text-stone-500 hover:text-stone-800 transition-colors">General</a>
|
||||
<span className="text-stone-300">·</span>
|
||||
<a href="#workers" className="text-xs font-medium text-stone-500 hover:text-stone-800 transition-colors">Workers & PINs</a>
|
||||
<span className="text-stone-300">·</span>
|
||||
<a href="#tasks" className="text-xs font-medium text-stone-500 hover:text-stone-800 transition-colors">Tasks</a>
|
||||
<span className="text-stone-300">·</span>
|
||||
<a href="#users" className="text-xs font-medium text-stone-500 hover:text-stone-800 transition-colors">Users & Permissions</a>
|
||||
<span className="text-stone-300">·</span>
|
||||
<a href="#integrations" className="text-xs font-medium text-stone-500 hover:text-stone-800 transition-colors">Integrations</a>
|
||||
</div>
|
||||
|
||||
{/* Section 1: General Settings */}
|
||||
<section id="general">
|
||||
<SettingsSections brandId={brandId} />
|
||||
</section>
|
||||
|
||||
{/* Section 4: Users & Permissions */}
|
||||
<section id="users">
|
||||
<div className="border-t border-stone-200 pt-10">
|
||||
<h2 className="text-lg font-bold text-stone-950 mb-4">Users & Permissions</h2>
|
||||
<UsersPage
|
||||
initialUsers={error ? [] : users}
|
||||
brands={brands}
|
||||
currentUser={{
|
||||
id: adminUser.id ?? adminUser.user_id,
|
||||
role: adminUser.role,
|
||||
can_manage_users: adminUser.can_manage_users,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 5: Integrations */}
|
||||
<section id="integrations">
|
||||
<div className="border-t border-stone-200 pt-10">
|
||||
<h2 className="text-lg font-bold text-stone-950 mb-4">Integrations & Exports</h2>
|
||||
<IntegrationsInner brandId={brandId} brands={brands} />
|
||||
|
||||
{/* Time Tracking Exports */}
|
||||
<div className="mt-6 border-t border-stone-200 pt-6">
|
||||
<h3 className="text-base font-semibold text-stone-800 mb-4">Time Tracking Exports</h3>
|
||||
<TimeTrackingSettingsClient brandId={brandId} />
|
||||
</div>
|
||||
<p className="text-xs text-stone-500 mt-3">
|
||||
For more settings, see{" "}
|
||||
<a href="/admin/settings/ai" className="text-emerald-600 hover:text-emerald-700 underline underline-offset-2">AI Tools</a>
|
||||
{" "}and{" "}
|
||||
<a href="/admin/settings/shipping" className="text-emerald-600 hover:text-emerald-700 underline underline-offset-2">Shipping</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import PaymentSettingsForm from "@/components/admin/PaymentSettingsForm";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
|
||||
export default async function PaymentSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (!adminUser.can_manage_orders || adminUser.role === "store_employee") redirect("/admin/pickup");
|
||||
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
const brandId = adminUser.brand_id ?? "";
|
||||
|
||||
// Platform admins: fetch all brands for the picker
|
||||
const brands = isPlatformAdmin
|
||||
? (await supabase.from("brands").select("id, name").order("name")).data ?? []
|
||||
: [];
|
||||
|
||||
const effectiveBrandId = brandId || (brands[0]?.id ?? "");
|
||||
|
||||
const result = await getPaymentSettings(effectiveBrandId);
|
||||
const settings = result.success ? result.settings : null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-800">Payments</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-stone-200 border border-stone-300">
|
||||
<svg className="h-5 w-5 text-stone-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 0h3m-3.75 0h3m-3.75 0h3m-3.75 0h3m3.75 0h3m3.75 0h3m-3.75 0h3m-3.75 0h3m-3.75 0h3m-3.75 0h3m3.75 0h3m-3.75 0h3m-3.75 0h3" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-stone-950">Payment Settings</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Configure your payment provider for checkout processing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 shadow-xl">
|
||||
<PaymentSettingsForm
|
||||
settings={settings}
|
||||
brandId={effectiveBrandId}
|
||||
brands={brands}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getShippingSettings } from "@/actions/shipping/settings";
|
||||
import ShippingSettingsForm from "@/components/admin/ShippingSettingsForm";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
|
||||
export default async function ShippingSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (!adminUser.can_manage_orders || adminUser.role === "store_employee") {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
const brandId = adminUser.brand_id ?? "";
|
||||
|
||||
// Platform admins: fetch all brands for the picker
|
||||
const brands = isPlatformAdmin
|
||||
? (await supabase.from("brands").select("id, name").order("name")).data ?? []
|
||||
: [];
|
||||
|
||||
const effectiveBrandId = brandId || (brands[0]?.id ?? "");
|
||||
|
||||
const result = await getShippingSettings(effectiveBrandId);
|
||||
const settings = result.success ? result.settings : null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-800">Shipping</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-stone-200 border border-stone-300">
|
||||
<svg className="h-5 w-5 text-stone-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25m-2.25 0h2.25m0 0v-.375c0-.621-.504-1.125-1.125-1.125H15m-1.5-3l1.5 0l.75 0v-.375c0-.621-.504-1.125-1.125-1.125H15m0 0v-.375c0-.621-.504-1.125-1.125-1.125H12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-stone-950">Shipping Settings</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Configure FedEx integration for shipping fresh produce — sweet corn, onions, and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 shadow-xl">
|
||||
<ShippingSettingsForm
|
||||
settings={settings}
|
||||
brandId={effectiveBrandId}
|
||||
brands={brands}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { syncSquareNow, getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||
import { savePaymentSettings } from "@/actions/payments";
|
||||
|
||||
type InventoryMode = "none" | "rc_to_square" | "square_to_rc" | "bidirectional";
|
||||
|
||||
type Props = {
|
||||
settings: {
|
||||
provider?: string | null;
|
||||
square_access_token?: string | null;
|
||||
square_location_id?: string | null;
|
||||
square_sync_enabled?: boolean;
|
||||
square_inventory_mode?: InventoryMode;
|
||||
square_last_sync_at?: string | null;
|
||||
square_last_sync_error?: string | null;
|
||||
} | null;
|
||||
logs: SyncLogEntry[];
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
export default function SquareSyncSettingsClient({ settings, logs, brandId }: Props) {
|
||||
const [squareSyncEnabled, setSquareSyncEnabled] = useState(
|
||||
settings?.square_sync_enabled ?? false
|
||||
);
|
||||
const [squareInventoryMode, setSquareInventoryMode] = useState<InventoryMode>(
|
||||
settings?.square_inventory_mode ?? "none"
|
||||
);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [displayLogs, setDisplayLogs] = useState<SyncLogEntry[]>(logs);
|
||||
|
||||
async function handleSaveSettings() {
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
const result = await savePaymentSettings({
|
||||
brandId,
|
||||
provider: (settings?.provider as any) || null,
|
||||
squareSyncEnabled,
|
||||
squareInventoryMode,
|
||||
});
|
||||
setSaving(false);
|
||||
if (result.success) {
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} else {
|
||||
setError(result.error ?? "Failed to save settings");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncNow(type: "products" | "orders" | "all") {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
const result = await syncSquareNow(brandId, type);
|
||||
setSyncMsg({
|
||||
kind: result.success ? "success" : "error",
|
||||
text: result.success
|
||||
? `Sync complete — ${result.synced} item(s) synced.`
|
||||
: `Sync failed: ${result.errors[0] ?? "Unknown error"}`,
|
||||
});
|
||||
setSyncing(false);
|
||||
const logResult = await getSyncLog(brandId);
|
||||
if (logResult.success) setDisplayLogs(logResult.logs);
|
||||
}
|
||||
|
||||
const hasToken = !!settings?.square_access_token;
|
||||
const lastSyncAt = settings?.square_last_sync_at
|
||||
? formatDateTime(settings.square_last_sync_at)
|
||||
: "Never";
|
||||
const lastError = settings?.square_last_sync_error;
|
||||
const isEnabled = settings?.square_sync_enabled && hasToken;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
{/* Top nav */}
|
||||
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
|
||||
<div className="mx-auto max-w-4xl flex items-center gap-3">
|
||||
<Link href="/admin" className="text-sm text-zinc-500 hover:text-zinc-300">Admin</Link>
|
||||
<span className="text-slate-400">/</span>
|
||||
<Link href="/admin/settings" className="text-sm text-zinc-500 hover:text-zinc-300">Settings</Link>
|
||||
<span className="text-slate-400">/</span>
|
||||
<span className="text-sm font-medium text-zinc-100">Square Sync</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-4xl px-6 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-zinc-100">Square Sync</h1>
|
||||
<p className="mt-2 text-zinc-400">
|
||||
Sync products, orders, and inventory between Route Commerce and Square.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-900/30 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{syncMsg && (
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${
|
||||
syncMsg.kind === "success"
|
||||
? "border-green-200 bg-green-900/30 text-green-400"
|
||||
: "border-red-200 bg-red-900/30 text-red-400"
|
||||
}`}>
|
||||
{syncMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<h2 className="text-lg font-semibold text-zinc-100 mb-4">Connection Status</h2>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-zinc-500">Provider</p>
|
||||
<p className="font-medium text-zinc-100">{settings?.provider ?? "Not set"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">Square Account</p>
|
||||
<p className={`font-medium ${hasToken ? "text-green-400" : "text-slate-400"}`}>
|
||||
{hasToken ? "Connected" : "Not connected"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">Last Sync</p>
|
||||
<p className="font-medium text-zinc-100">{lastSyncAt}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">Sync Status</p>
|
||||
{lastError ? (
|
||||
<p className="font-medium text-red-400">Error — {lastError}</p>
|
||||
) : isEnabled ? (
|
||||
<p className="font-medium text-green-400">Active</p>
|
||||
) : hasToken ? (
|
||||
<p className="font-medium text-yellow-600">Disabled</p>
|
||||
) : (
|
||||
<p className="font-medium text-slate-400">Not connected</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<Link
|
||||
href="/admin/settings/payments"
|
||||
className="rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Manage Connection
|
||||
</Link>
|
||||
{hasToken && (
|
||||
<button
|
||||
onClick={() => handleSyncNow("all")}
|
||||
disabled={syncing}
|
||||
className="rounded-xl bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "Syncing..." : "Sync All Now"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync settings */}
|
||||
{hasToken && (
|
||||
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<h2 className="text-lg font-semibold text-zinc-100 mb-5">Sync Settings</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-zinc-100">Enable Square Sync</p>
|
||||
<p className="text-sm text-zinc-500">Automatically sync products and orders with Square.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSquareSyncEnabled(!squareSyncEnabled)}
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${
|
||||
squareSyncEnabled ? "bg-green-600" : "bg-slate-300"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-zinc-900 shadow transition-transform ${
|
||||
squareSyncEnabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{squareSyncEnabled && (
|
||||
<>
|
||||
{/* Inventory mode */}
|
||||
<div>
|
||||
<p className="mb-2 font-medium text-zinc-100">Inventory Direction</p>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{[
|
||||
{ value: "none", label: "None", desc: "No inventory sync" },
|
||||
{ value: "rc_to_square", label: "RC → Square", desc: "RC inventory to Square" },
|
||||
{ value: "square_to_rc", label: "Square → RC", desc: "Square inventory to RC" },
|
||||
{ value: "bidirectional", label: "Bidirectional", desc: "Sync both ways" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setSquareInventoryMode(opt.value as InventoryMode)}
|
||||
className={`rounded-xl border p-3 text-left text-sm ${
|
||||
squareInventoryMode === opt.value
|
||||
? "border-green-600 bg-green-900/30 text-green-900"
|
||||
: "border-zinc-800 text-zinc-400 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<p className="font-semibold">{opt.label}</p>
|
||||
<p className="text-xs mt-0.5 opacity-70">{opt.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual sync buttons */}
|
||||
<div>
|
||||
<p className="mb-2 font-medium text-zinc-100">Manual Sync</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => handleSyncNow("products")}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "..." : "Sync Products"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSyncNow("orders")}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "..." : "Sync Orders"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSaveSettings}
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
{saved && (
|
||||
<span className="ml-3 text-sm text-green-600">Settings saved.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync log */}
|
||||
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<h2 className="text-lg font-semibold text-zinc-100 mb-4">Sync Log</h2>
|
||||
{displayLogs.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 py-4 text-center">No sync activity yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{displayLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`flex items-start justify-between rounded-xl border px-4 py-3 text-sm ${
|
||||
log.status === "success"
|
||||
? "border-green-200 bg-green-900/30"
|
||||
: log.status === "partial"
|
||||
? "border-yellow-200 bg-amber-900/30"
|
||||
: "border-red-200 bg-red-900/30"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block rounded px-1.5 py-0.5 text-xs font-semibold ${
|
||||
log.status === "success"
|
||||
? "bg-green-900/40 text-green-400"
|
||||
: log.status === "partial"
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-red-900/40 text-red-400"
|
||||
}`}>
|
||||
{log.status}
|
||||
</span>
|
||||
<span className="font-medium text-zinc-300">{log.event_type}</span>
|
||||
{log.direction && (
|
||||
<span className="text-xs text-slate-400">({log.direction})</span>
|
||||
)}
|
||||
</div>
|
||||
{log.message && (
|
||||
<p className="mt-1 text-xs text-zinc-500">{log.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="ml-3 text-xs text-slate-400 whitespace-nowrap">
|
||||
{formatDateTime(log.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<h2 className="text-lg font-semibold text-zinc-100 mb-4">Quick Links</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/admin/orders?square=1"
|
||||
className="rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Square Orders
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/products?sync=square"
|
||||
className="rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Products with Square
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/settings/payments"
|
||||
className="rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Payment Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
|
||||
|
||||
export default async function SquareSyncSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
if (!adminUser.can_manage_orders) redirect("/admin/pickup");
|
||||
|
||||
const brandId =
|
||||
adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [settingsResult, logResult] = await Promise.all([
|
||||
getPaymentSettings(brandId),
|
||||
getSyncLog(brandId),
|
||||
]);
|
||||
|
||||
const settings = settingsResult.success ? settingsResult.settings : null;
|
||||
const logs: SyncLogEntry[] = logResult.success ? logResult.logs : [];
|
||||
|
||||
return (
|
||||
<SquareSyncSettingsClient
|
||||
settings={settings as any}
|
||||
logs={logs}
|
||||
brandId={brandId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user