- app.dashboard.jsx (new): live import progress dashboard modelled on Race-Nation — job selector, progress bar, 6-stat grid, stage board, current product banner, activity log, errors list, completion summary, 3s polling, cancel button - app.jsx: add nav links for Dashboard, Settings, Brands, Manage Brands, Help - app._index.jsx: dark gradient hero header, subscription status bar, navcard grid, billing modal preserved - app.settings.jsx: dark header, Turn14 connect card with live status, visual pricing type toggle (MAP vs percentage) - app.brands.jsx: dark header, visual brand grid with checkbox state, sticky save toolbar - app.managebrand.jsx: dark header, live import status bar with Dashboard link, collapsible brand rows, filter toggle pills, modern product cards with attribute badges - app.help.jsx: dark header, animated FAQ accordion, styled contact card Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
390 lines
18 KiB
JavaScript
390 lines
18 KiB
JavaScript
import { json } from "@remix-run/node";
|
||
import { useLoaderData, Form, useActionData, useNavigate } from "@remix-run/react";
|
||
import {
|
||
Page,
|
||
Layout,
|
||
Card,
|
||
TextField,
|
||
Checkbox,
|
||
Button,
|
||
Spinner,
|
||
Toast,
|
||
Frame,
|
||
Text,
|
||
Banner,
|
||
InlineStack,
|
||
} from "@shopify/polaris";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { TitleBar } from "@shopify/app-bridge-react";
|
||
import { getTurn14AccessTokenFromMetafield } from "../utils/turn14Token.server";
|
||
import { authenticate } from "../shopify.server";
|
||
|
||
const PLAN_NAME = "Starter Sync";
|
||
const ALLOWED_STATUSES = ["ACTIVE", "TRIAL"];
|
||
|
||
async function checkShopExists(shop) {
|
||
try {
|
||
const resp = await fetch(`https://backend.data4autos.com/checkisshopdataexists/${shop}`);
|
||
const data = await resp.json();
|
||
return data.status === 1;
|
||
} catch (err) {
|
||
console.error("Error checking shop:", err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function getIntervalLabel(interval) {
|
||
if (interval === "ANNUAL") return "Every 12 months";
|
||
if (interval === "EVERY_30_DAYS") return "Every 30 days";
|
||
return interval || "N/A";
|
||
}
|
||
|
||
function formatMoney(amount, currencyCode = "USD") {
|
||
if (amount == null) return "N/A";
|
||
return `${currencyCode} ${Number(amount).toFixed(2)}`;
|
||
}
|
||
|
||
function formatDate(date) {
|
||
if (!date) return "N/A";
|
||
return new Date(date).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||
}
|
||
|
||
async function getSubscriptionDetails(request) {
|
||
const { admin, session } = await authenticate.admin(request);
|
||
const shop = session.shop;
|
||
const resp = await admin.graphql(`
|
||
query CurrentSubscriptionDetails {
|
||
currentAppInstallation {
|
||
activeSubscriptions {
|
||
id name status test createdAt trialDays currentPeriodEnd
|
||
lineItems {
|
||
id
|
||
plan {
|
||
pricingDetails {
|
||
__typename
|
||
... on AppRecurringPricing {
|
||
interval
|
||
price { amount currencyCode }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
`);
|
||
const result = await resp.json();
|
||
const subscriptions = result?.data?.currentAppInstallation?.activeSubscriptions || [];
|
||
const subscription = subscriptions.find((sub) => ALLOWED_STATUSES.includes(sub.status)) || subscriptions[0] || null;
|
||
const recurringPricing = subscription?.lineItems?.find((item) => item?.plan?.pricingDetails?.__typename === "AppRecurringPricing")?.plan?.pricingDetails || null;
|
||
const isSubscribed = !!subscription && ALLOWED_STATUSES.includes(subscription.status);
|
||
return {
|
||
shop,
|
||
isSubscribed,
|
||
subscription: subscription ? {
|
||
id: subscription.id, name: subscription.name || PLAN_NAME, status: subscription.status,
|
||
test: subscription.test ?? false, createdAt: subscription.createdAt,
|
||
trialDays: subscription.trialDays ?? 0, currentPeriodEnd: subscription.currentPeriodEnd,
|
||
interval: recurringPricing?.interval || null, priceAmount: recurringPricing?.price?.amount || null,
|
||
currencyCode: recurringPricing?.price?.currencyCode || "USD",
|
||
} : null,
|
||
};
|
||
}
|
||
|
||
export const loader = async ({ request }) => {
|
||
console.log("🚀 Loader started");
|
||
let admin, session, shop;
|
||
try {
|
||
const authResult = await authenticate.admin(request);
|
||
admin = authResult.admin;
|
||
session = authResult.session;
|
||
shop = session?.shop;
|
||
} catch (err) {
|
||
return json({ brands: [], collections: [], selectedBrandsFromShopify: [], shop: "", error: "Shopify authentication failed", isSubscribed: false, subscription: null });
|
||
}
|
||
|
||
const { isSubscribed, subscription } = await getSubscriptionDetails(request);
|
||
let accessToken = "";
|
||
try {
|
||
accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||
} catch (err) {
|
||
return json({ brands: [], collections: [], selectedBrandsFromShopify: [], shop, error: "Failed to fetch Turn14 access token", isSubscribed, subscription });
|
||
}
|
||
|
||
let brandJson;
|
||
try {
|
||
const brandRes = await fetch("https://turn14.data4autos.com/v1/brands", { headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" } });
|
||
brandJson = await brandRes.json();
|
||
if (!brandRes.ok) return json({ brands: [], collections: [], selectedBrandsFromShopify: [], shop, error: brandJson?.error || "Failed to fetch brands", isSubscribed, subscription });
|
||
} catch (err) {
|
||
return json({ brands: [], collections: [], selectedBrandsFromShopify: [], shop, error: "Turn14 brands fetch crashed", isSubscribed, subscription });
|
||
}
|
||
|
||
let collections = [];
|
||
try {
|
||
const gqlRaw = await admin.graphql(`{ collections(first: 100) { edges { node { id title } } } }`);
|
||
const gql = await gqlRaw.json();
|
||
collections = gql?.data?.collections?.edges?.map((e) => e.node) || [];
|
||
} catch (err) {}
|
||
|
||
let selectedBrands = [];
|
||
try {
|
||
const res = await admin.graphql(`{ shop { metafield(namespace: "turn14", key: "selected_brands") { value } } }`);
|
||
const data = await res.json();
|
||
const rawValue = data?.data?.shop?.metafield?.value;
|
||
if (rawValue) selectedBrands = JSON.parse(rawValue);
|
||
} catch (err) {}
|
||
|
||
return json({ brands: brandJson?.data || [], collections, selectedBrandsFromShopify: selectedBrands, shop, isSubscribed, subscription });
|
||
};
|
||
|
||
export const action = async ({ request }) => {
|
||
const { isSubscribed } = await getSubscriptionDetails(request);
|
||
if (!isSubscribed) return json({ error: "An active subscription or free trial is required to save brand collections." }, { status: 403 });
|
||
|
||
const formData = await request.formData();
|
||
const selectedBrands = JSON.parse(formData.get("selectedBrands") || "[]");
|
||
const selectedOldBrands = JSON.parse(formData.get("selectedOldBrands") || "[]");
|
||
const { session } = await authenticate.admin(request);
|
||
const shop = session.shop;
|
||
|
||
selectedBrands.forEach((brand) => { delete brand.pricegroups; });
|
||
selectedOldBrands.forEach((brand) => { delete brand.pricegroups; });
|
||
|
||
const resp = await fetch("https://backend.data4autos.com/managebrands", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", "shop-domain": shop },
|
||
body: JSON.stringify({ shop, selectedBrands, selectedOldBrands }),
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
const err = await resp.text();
|
||
return json({ error: err }, { status: resp.status });
|
||
}
|
||
const { processId, status } = await resp.json();
|
||
return json({ processId, status });
|
||
};
|
||
|
||
export default function BrandsPage() {
|
||
const navigate = useNavigate();
|
||
const { brands = [], selectedBrandsFromShopify = [], shop = "", error, isSubscribed = false, subscription = null } = useLoaderData() || {};
|
||
const actionData = useActionData() || {};
|
||
|
||
const [selectedIdsold, setSelectedIdsold] = useState([]);
|
||
const [selectedIds, setSelectedIds] = useState(() => (selectedBrandsFromShopify ?? []).map((b) => b.id));
|
||
const [search, setSearch] = useState("");
|
||
const [filteredBrands, setFilteredBrands] = useState(brands);
|
||
const [toastActive, setToastActive] = useState(false);
|
||
const [polling, setPolling] = useState(false);
|
||
const [status, setStatus] = useState(actionData.status || "");
|
||
const [Turn14Enabled, setTurn14Enabled] = useState(null);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!shop) return;
|
||
(async () => { const result = await checkShopExists(shop); setTurn14Enabled(result); })();
|
||
}, [shop]);
|
||
|
||
useEffect(() => { setSelectedIdsold(selectedIds); }, [toastActive]);
|
||
|
||
useEffect(() => {
|
||
const term = search.toLowerCase();
|
||
setFilteredBrands(brands.filter((b) => b.name.toLowerCase().includes(term)));
|
||
}, [search, brands]);
|
||
|
||
useEffect(() => {
|
||
if (actionData.status) { setStatus(actionData.status); setToastActive(true); setSaving(false); }
|
||
if (actionData.error) setSaving(false);
|
||
}, [actionData.status, actionData.error]);
|
||
|
||
const checkStatus = async () => {
|
||
if (!actionData.processId) return;
|
||
setPolling(true);
|
||
const resp = await fetch(`https://backend.data4autos.com/managebrands/status/${actionData.processId}`, { headers: { "shop-domain": window.shopify?.shop || "" } });
|
||
const jsonBody = await resp.json();
|
||
setStatus(jsonBody.status + (jsonBody.detail ? ` (${jsonBody.detail})` : ""));
|
||
setPolling(false);
|
||
};
|
||
|
||
const toggleSelect = (id) => {
|
||
if (!isSubscribed) return;
|
||
setSelectedIds((prev) => prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]);
|
||
};
|
||
|
||
const allFilteredSelected = filteredBrands.length > 0 && filteredBrands.every((b) => selectedIds.includes(b.id));
|
||
const toggleSelectAll = () => {
|
||
if (!isSubscribed) return;
|
||
const ids = filteredBrands.map((b) => b.id);
|
||
if (allFilteredSelected) setSelectedIds((prev) => prev.filter((id) => !ids.includes(id)));
|
||
else setSelectedIds((prev) => Array.from(new Set([...prev, ...ids])));
|
||
};
|
||
|
||
const trialDaysLeft = useMemo(() => {
|
||
if (!subscription?.trialDays || !subscription?.createdAt || subscription.status !== "TRIAL") return null;
|
||
const created = new Date(subscription.createdAt);
|
||
const trialEnd = new Date(created);
|
||
trialEnd.setDate(trialEnd.getDate() + subscription.trialDays);
|
||
return Math.max(0, Math.ceil((trialEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
|
||
}, [subscription]);
|
||
|
||
const selectedBrands = brands.filter((b) => selectedIds.includes(b.id));
|
||
const selectedOldBrands = brands.filter((b) => selectedIdsold.includes(b.id));
|
||
const shopDomain = (shop || "").split(".")[0];
|
||
|
||
if (Turn14Enabled === false) {
|
||
return (
|
||
<Frame>
|
||
<Page fullWidth>
|
||
<TitleBar title="Data4Autos Turn14 Integration" />
|
||
<div style={{ background: "linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%)", borderRadius: 16, padding: "28px 32px", marginBottom: 24, color: "#fff" }}>
|
||
<div style={{ fontSize: 22, fontWeight: 800 }}>🏷️ Brand Selection</div>
|
||
<div style={{ fontSize: 14, opacity: 0.8, marginTop: 4 }}>{shop}</div>
|
||
</div>
|
||
<Card>
|
||
<div style={{ padding: 48, textAlign: "center" }}>
|
||
<div style={{ fontSize: 48, marginBottom: 12 }}>🔌</div>
|
||
<Text as="h1" variant="headingLg">Turn14 not connected yet</Text>
|
||
<div style={{ marginTop: 8, color: "#6b7280", marginBottom: 24 }}>Complete the connection in Settings before browsing brands.</div>
|
||
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
|
||
<Button variant="primary" onClick={() => navigate("/app/settings")}>Go to Settings</Button>
|
||
<Button onClick={() => navigate("/app/help")}>View Help</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</Page>
|
||
</Frame>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Frame>
|
||
<Page fullWidth>
|
||
<TitleBar title="Data4Autos Turn14 Integration" />
|
||
|
||
{/* Header */}
|
||
<div style={{ background: "linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%)", borderRadius: 16, padding: "28px 32px", marginBottom: 24, color: "#fff", display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}>
|
||
<div>
|
||
<div style={{ fontSize: 22, fontWeight: 800 }}>🏷️ Brand Selection</div>
|
||
<div style={{ fontSize: 14, opacity: 0.8, marginTop: 4 }}>{filteredBrands.length} brands available · {selectedIds.length} selected</div>
|
||
</div>
|
||
{!isSubscribed && (
|
||
<div style={{ background: "rgba(251,191,36,0.2)", border: "1px solid rgba(251,191,36,0.4)", borderRadius: 8, padding: "8px 14px", fontSize: 13, color: "#fbbf24", fontWeight: 700 }}>
|
||
⚠️ Subscription required
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Subscription warning */}
|
||
{!isSubscribed && (
|
||
<div style={{ background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 12, padding: "16px 20px", marginBottom: 20 }}>
|
||
<div style={{ fontWeight: 700, color: "#b45309", marginBottom: 6 }}>⚠️ Active subscription required</div>
|
||
<div style={{ fontSize: 13, color: "#92400e", marginBottom: 12 }}>Brand selection is only available with an active subscription or free trial.</div>
|
||
<Button variant="primary" onClick={() => navigate("/app")}>Manage Subscription</Button>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div style={{ background: "#fff1f2", border: "1px solid #fecdd3", borderRadius: 12, padding: "14px 18px", marginBottom: 20, color: "#dc2626" }}>
|
||
⛔ {error}
|
||
</div>
|
||
)}
|
||
|
||
{actionData?.error && (
|
||
<div style={{ background: "#fff1f2", border: "1px solid #fecdd3", borderRadius: 12, padding: "14px 18px", marginBottom: 20, color: "#dc2626" }}>
|
||
⛔ {actionData.error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Sticky toolbar */}
|
||
<div style={{ position: "sticky", top: 0, zIndex: 10, background: "#fff", borderRadius: 12, boxShadow: "0 2px 12px rgba(0,0,0,0.08)", padding: "14px 20px", marginBottom: 20, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
|
||
<div style={{ display: "flex", gap: 14, alignItems: "center", flexWrap: "wrap" }}>
|
||
<div style={{ minWidth: 280 }}>
|
||
<TextField
|
||
labelHidden
|
||
label="Search brands"
|
||
value={search}
|
||
onChange={setSearch}
|
||
placeholder="🔍 Search brands…"
|
||
autoComplete="off"
|
||
disabled={!isSubscribed}
|
||
/>
|
||
</div>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
<Checkbox label="" checked={allFilteredSelected} onChange={toggleSelectAll} disabled={!isSubscribed} />
|
||
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151", cursor: "pointer" }} onClick={toggleSelectAll}>Select All</span>
|
||
</div>
|
||
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
||
<span style={{ fontWeight: 700, color: "#1d4ed8" }}>{selectedIds.length}</span> selected
|
||
</div>
|
||
{(actionData?.processId) && (
|
||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||
<div style={{ fontSize: 13, color: "#374151" }}><strong>Status:</strong> {status || "—"}</div>
|
||
<Button onClick={checkStatus} loading={polling} size="slim">Refresh</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<Form method="post" onSubmit={() => setSaving(true)}>
|
||
<input type="hidden" name="selectedBrands" value={JSON.stringify(selectedBrands)} />
|
||
<input type="hidden" name="selectedOldBrands" value={JSON.stringify(selectedOldBrands)} />
|
||
<button
|
||
type="submit"
|
||
disabled={saving || !isSubscribed}
|
||
style={{ background: saving || !isSubscribed ? "#9ca3af" : "linear-gradient(135deg, #1d4ed8, #2563eb)", border: "none", borderRadius: 8, color: "#fff", padding: "10px 22px", cursor: saving || !isSubscribed ? "not-allowed" : "pointer", fontWeight: 700, fontSize: 14, display: "flex", alignItems: "center", gap: 8 }}
|
||
>
|
||
{saving && <Spinner size="small" />}
|
||
{saving ? "Saving…" : `Save ${selectedIds.length} Collections`}
|
||
</button>
|
||
</Form>
|
||
</div>
|
||
|
||
{/* Brand grid */}
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 14 }}>
|
||
{filteredBrands.map((brand) => {
|
||
const isSelected = selectedIds.includes(brand.id);
|
||
return (
|
||
<div
|
||
key={brand.id}
|
||
onClick={() => toggleSelect(brand.id)}
|
||
style={{ background: isSelected ? "#eff6ff" : "#fff", border: `2px solid ${isSelected ? "#2563eb" : "#e5e7eb"}`, borderRadius: 12, padding: "16px 14px", cursor: isSubscribed ? "pointer" : "default", position: "relative", transition: "all 0.15s", boxShadow: isSelected ? "0 0 0 3px rgba(37,99,235,0.12)" : "none" }}
|
||
>
|
||
{/* Checkbox badge */}
|
||
<div style={{ position: "absolute", top: 10, right: 10 }}>
|
||
<div style={{ width: 20, height: 20, borderRadius: "50%", background: isSelected ? "#2563eb" : "#e5e7eb", border: `2px solid ${isSelected ? "#2563eb" : "#d1d5db"}`, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||
{isSelected && <span style={{ color: "#fff", fontSize: 11 }}>✓</span>}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: "flex", justifyContent: "center", marginBottom: 12 }}>
|
||
<img
|
||
src={brand.logo || "https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"}
|
||
alt={brand.name}
|
||
style={{ width: 72, height: 72, objectFit: "contain", borderRadius: 8 }}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ textAlign: "center", fontWeight: 700, fontSize: 14, color: isSelected ? "#1d4ed8" : "#374151", lineHeight: 1.3 }}>
|
||
{brand.name}
|
||
</div>
|
||
<div style={{ textAlign: "center", fontSize: 11, color: "#9ca3af", marginTop: 4 }}>ID: {brand.id}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{filteredBrands.length === 0 && (
|
||
<div style={{ textAlign: "center", padding: 48, color: "#9ca3af" }}>
|
||
<div style={{ fontSize: 36, marginBottom: 12 }}>🔍</div>
|
||
<div style={{ fontSize: 16, fontWeight: 600 }}>No brands found</div>
|
||
<div style={{ fontSize: 13, marginTop: 6 }}>Try a different search term</div>
|
||
</div>
|
||
)}
|
||
|
||
{toastActive && (
|
||
<Toast content="Collections saved successfully!" onDismiss={() => setToastActive(false)} />
|
||
)}
|
||
</Page>
|
||
</Frame>
|
||
);
|
||
}
|