feat: complete UI/UX rework + live import dashboard

- 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>
This commit is contained in:
MOHAN 2026-06-10 02:23:09 +05:30
parent 4199fd8a4a
commit 6b46600fff
7 changed files with 1477 additions and 2300 deletions

View File

@ -1,74 +1,47 @@
import React, { useMemo, useState } from "react";
import { json } from "@remix-run/node";
import { useLoaderData, useActionData, useSubmit, Form } from "@remix-run/react";
import { useLoaderData, useActionData, useSubmit, Form, useNavigate } from "@remix-run/react";
import {
Page,
Layout,
Card,
BlockStack,
Text,
InlineStack,
Image,
Divider,
Button,
Modal,
TextField,
Box,
Banner,
BlockStack,
InlineStack,
ChoiceList,
Badge,
Banner,
Button,
Divider,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import data4autosLogo from "../assets/data4autos_logo.png";
import turn14DistributorLogo from "../assets/turn14-logo.png";
import { authenticate } from "../shopify.server";
/* ===========================
PRICING (single source of truth)
=========================== */
/* ─── pricing constants ────────────────────────────────────────────────────── */
const PLAN_NAME = "Starter Sync";
const MONTHLY_AMOUNT = 79; // USD
const ANNUAL_AMOUNT = 790; // USD
const MONTHLY_AMOUNT = 79;
const ANNUAL_AMOUNT = 790;
const TRIAL_DAYS = 14;
const ALLOWED_STATUSES = ["ACTIVE", "TRIAL"];
/* ===========================
HELPERS
=========================== */
/* ─── helpers ──────────────────────────────────────────────────────────────── */
function formatMoney(amount, currencyCode = "USD") {
if (amount == null) return "N/A";
return `${currencyCode} ${Number(amount).toFixed(2)}`;
}
function getIntervalLabel(interval) {
switch (interval) {
case "ANNUAL":
return "Every 12 months";
case "EVERY_30_DAYS":
return "Every 30 days";
default:
return interval || "N/A";
}
if (interval === "ANNUAL") return "Every 12 months";
if (interval === "EVERY_30_DAYS") return "Every 30 days";
return interval || "N/A";
}
function getStatusTone(status) {
switch (status) {
case "ACTIVE":
return "success";
case "TRIAL":
return "info";
case "CANCELLED":
case "EXPIRED":
case "DECLINED":
return "critical";
default:
return "attention";
}
if (status === "ACTIVE") return { bg: "#f0fdf4", border: "#bbf7d0", color: "#15803d" };
if (status === "TRIAL") return { bg: "#eff6ff", border: "#bfdbfe", color: "#1d4ed8" };
if (["CANCELLED", "EXPIRED", "DECLINED"].includes(status)) return { bg: "#fff1f2", border: "#fecdd3", color: "#dc2626" };
return { bg: "#fefce8", border: "#fde68a", color: "#d97706" };
}
/* ===========================
LOADER: fetch real subscription details
=========================== */
/* ─── loader ────────────────────────────────────────────────────────────────── */
export const loader = async ({ request }) => {
const { admin, session } = await authenticate.admin(request);
const shop = session.shop;
@ -77,13 +50,7 @@ export const loader = async ({ request }) => {
query CurrentSubscriptionDetails {
currentAppInstallation {
activeSubscriptions {
id
name
status
test
createdAt
trialDays
currentPeriodEnd
id name status test createdAt trialDays currentPeriodEnd
lineItems {
id
plan {
@ -91,10 +58,7 @@ export const loader = async ({ request }) => {
__typename
... on AppRecurringPricing {
interval
price {
amount
currencyCode
}
price { amount currencyCode }
}
}
}
@ -105,75 +69,33 @@ export const loader = async ({ request }) => {
`);
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);
const subscriptionDetails = 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;
console.log(
`Loader subscription details for ${shop}: ${JSON.stringify(subscriptionDetails)}`
);
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);
const subscriptionDetails = 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;
if (shop === "racewerksengg.myshopify.com") {
return json({
redirectToBilling: false,
shop,
isSubscribed: true,
subscription: subscriptionDetails,
allSubscriptions: subscriptions,
});
return json({ redirectToBilling: false, shop, isSubscribed: true, subscription: subscriptionDetails, allSubscriptions: subscriptions });
}
return json({
redirectToBilling: !isSubscribed,
shop,
isSubscribed,
subscription: subscriptionDetails,
allSubscriptions: subscriptions,
});
return json({ redirectToBilling: !isSubscribed, shop, isSubscribed, subscription: subscriptionDetails, allSubscriptions: subscriptions });
};
/* ===========================
ACTION: create subscription
=========================== */
/* ─── action ────────────────────────────────────────────────────────────────── */
export const action = async ({ request }) => {
const { admin, session } = await authenticate.admin(request);
const form = await request.formData();
const rawCadence = form.get("cadence");
const cadence = rawCadence === "ANNUAL" ? "ANNUAL" : "MONTHLY";
const interval = cadence === "ANNUAL" ? "ANNUAL" : "EVERY_30_DAYS";
const amount = cadence === "ANNUAL" ? ANNUAL_AMOUNT : MONTHLY_AMOUNT;
const shop = session.shop;
const shopDomain = (shop || "").split(".")[0];
const returnUrl = `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app`;
@ -183,64 +105,58 @@ export const action = async ({ request }) => {
appSubscriptionCreate(
name: "${PLAN_NAME} - ${cadence === "ANNUAL" ? "Annual" : "Monthly"}"
returnUrl: "${returnUrl}"
lineItems: [
{
plan: {
appRecurringPricingDetails: {
price: { amount: ${amount}, currencyCode: USD }
interval: ${interval}
}
}
}
]
lineItems: [{ plan: { appRecurringPricingDetails: { price: { amount: ${amount}, currencyCode: USD } interval: ${interval} } } }]
trialDays: ${TRIAL_DAYS}
replacementBehavior: STANDARD
test: false
) {
confirmationUrl
appSubscription {
id
name
status
trialDays
}
userErrors {
field
message
}
appSubscription { id name status trialDays }
userErrors { field message }
}
}
`);
const data = await createRes.json();
const url = data?.data?.appSubscriptionCreate?.confirmationUrl || null;
const userErrors = data?.data?.appSubscriptionCreate?.userErrors || [];
const topLevelErrors = data?.errors || [];
if (!url || userErrors.length || topLevelErrors.length) {
return json(
{
errors: [
"Failed to create subscription.",
...userErrors.map((e) => e.message),
...topLevelErrors.map((e) => e.message || String(e)),
],
},
{ status: 400 }
);
return json({ errors: ["Failed to create subscription.", ...userErrors.map((e) => e.message), ...topLevelErrors.map((e) => e.message || String(e))] }, { status: 400 });
}
return json({ confirmationUrl: url });
};
/* ===========================
PAGE
=========================== */
/* ─── NavCard ────────────────────────────────────────────────────────────────── */
function NavCard({ icon, title, desc, link, accent }) {
const colors = {
blue: { bg: "#eff6ff", border: "#bfdbfe", icon: "#2563eb" },
green: { bg: "#f0fdf4", border: "#bbf7d0", icon: "#16a34a" },
purple: { bg: "#faf5ff", border: "#e9d5ff", icon: "#7c3aed" },
amber: { bg: "#fffbeb", border: "#fde68a", icon: "#b45309" },
};
const c = colors[accent] || colors.blue;
return (
<a href={link} style={{ textDecoration: "none", display: "block" }}>
<div style={{ background: c.bg, border: `1px solid ${c.border}`, borderRadius: 12, padding: "18px 20px", cursor: "pointer", transition: "transform 0.15s, box-shadow 0.15s", height: "100%", display: "flex", flexDirection: "column", gap: 10 }}
onMouseEnter={(e) => { e.currentTarget.style.transform = "translateY(-2px)"; e.currentTarget.style.boxShadow = "0 8px 20px rgba(0,0,0,0.08)"; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = ""; e.currentTarget.style.boxShadow = ""; }}
>
<div style={{ fontSize: 28 }}>{icon}</div>
<div style={{ fontWeight: 700, fontSize: 15, color: c.icon }}>{title}</div>
<div style={{ fontSize: 13, color: "#6b7280", lineHeight: 1.5 }}>{desc}</div>
</div>
</a>
);
}
/* ─── Page ───────────────────────────────────────────────────────────────────── */
export default function Index() {
const actionData = useActionData();
const loaderData = useLoaderData();
const submit = useSubmit();
const navigate = useNavigate();
const [activeModal, setActiveModal] = useState(false);
const [cadence, setCadence] = useState("MONTHLY");
@ -250,275 +166,143 @@ export default function Index() {
const hasConfirmationUrl = Boolean(actionData?.confirmationUrl);
const errors = actionData?.errors || [];
const shopDomain = (shop || "").split(".")[0];
const items = [
{
icon: "⚙️",
text: "Manage API settings",
link: `/d4a-turn14/app/settings`,
},
{
icon: "🏷️",
text: "Browse and import available brands",
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/brands`,
},
{
icon: "📦",
text: "Sync brand collections to Shopify",
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/managebrand`,
},
{
icon: "🔐",
text: "Handle secure Turn14 login credentials",
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/help`,
},
];
const openModal = () => setActiveModal(true);
const closeModal = () => setActiveModal(false);
const formatDate = (d) =>
d
? new Date(d).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})
: "N/A";
const formatDate = (d) => d ? new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) : "N/A";
const previewBase = subscription?.createdAt ? new Date(subscription.createdAt) : new Date();
const previewTrialEnd = new Date(previewBase);
previewTrialEnd.setDate(previewTrialEnd.getDate() + TRIAL_DAYS);
const trialDaysLeft = useMemo(() => {
if (!subscription?.trialDays || !subscription?.createdAt) return null;
if (subscription.status !== "TRIAL") return null;
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);
const now = new Date();
const msLeft = trialEnd.getTime() - now.getTime();
const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24));
return Math.max(0, daysLeft);
const msLeft = trialEnd.getTime() - Date.now();
return Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24)));
}, [subscription?.trialDays, subscription?.createdAt, subscription?.status]);
const isPreviewMode = !isSubscribed;
const displayPlan = isPreviewMode
? `${PLAN_NAME}${cadence === "ANNUAL" ? "Annual" : "Monthly"}`
: subscription?.name || PLAN_NAME;
const displayPlan = isPreviewMode ? `${PLAN_NAME}${cadence === "ANNUAL" ? "Annual" : "Monthly"}` : subscription?.name || PLAN_NAME;
const displayInterval = isPreviewMode ? (cadence === "ANNUAL" ? "Every 12 months" : "Every 30 days") : getIntervalLabel(subscription?.interval);
const displayPrice = isPreviewMode ? (cadence === "ANNUAL" ? `$${ANNUAL_AMOUNT}/yr` : `$${MONTHLY_AMOUNT}/mo`) : (subscription?.priceAmount ? `${formatMoney(subscription.priceAmount, subscription.currencyCode)}${subscription?.interval === "ANNUAL" ? "/yr" : "/mo"}` : "N/A");
const displayNextRenewal = isPreviewMode ? `${formatDate(previewTrialEnd)} (after ${TRIAL_DAYS}-day trial)` : formatDate(subscription?.currentPeriodEnd);
const displayStatus = isPreviewMode ? "Not active" : subscription?.status || "N/A";
const displayInterval = isPreviewMode
? cadence === "ANNUAL"
? "Every 12 months"
: "Every 30 days"
: getIntervalLabel(subscription?.interval);
const statusStyle = getStatusTone(subscription?.status || "PENDING");
const displayPrice = isPreviewMode
? cadence === "ANNUAL"
? `$${ANNUAL_AMOUNT}/yr`
: `$${MONTHLY_AMOUNT}/mo`
: subscription?.priceAmount
? `${formatMoney(subscription.priceAmount, subscription.currencyCode)}${
subscription?.interval === "ANNUAL" ? "/yr" : "/mo"
}`
: "N/A";
const displayNextRenewal = isPreviewMode
? `${formatDate(previewTrialEnd)} (after ${TRIAL_DAYS}-day trial)`
: formatDate(subscription?.currentPeriodEnd);
const displayStatus = isPreviewMode
? "Not active — will be created at checkout"
: subscription?.status || "N/A";
const navItems = [
{ icon: "⚙️", title: "Settings", desc: "Connect Turn14 API & configure pricing", link: `/app/settings`, accent: "purple" },
{ icon: "🏷️", title: "Browse Brands", desc: "Select brands to sync from Turn14", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/brands`, accent: "blue" },
{ icon: "📦", title: "Manage Brands", desc: "Import products from selected brands", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/managebrand`, accent: "green" },
{ icon: "📊", title: "Dashboard", desc: "Track live import progress & stats", link: `/app/dashboard`, accent: "amber" },
];
return (
<Page>
<TitleBar title="Data4Autos Turn14 Integration" />
<Layout>
<Layout.Section>
<Card padding="500">
<BlockStack gap="400">
<BlockStack gap="400" align="center">
<Text variant="headingLg" as="h1" alignment="center">
Welcome to your Turn14 Dashboard
</Text>
{/* Hero header */}
<div style={{ background: "linear-gradient(135deg, #0f172a 0%, #1e3a5f 50%, #2563eb 100%)", borderRadius: 16, padding: "36px 40px", marginBottom: 28, color: "#fff", position: "relative", overflow: "hidden" }}>
<div style={{ position: "absolute", right: -40, top: -40, width: 220, height: 220, borderRadius: "50%", background: "rgba(255,255,255,0.04)" }} />
<div style={{ position: "absolute", right: 60, bottom: -60, width: 150, height: 150, borderRadius: "50%", background: "rgba(255,255,255,0.03)" }} />
<div style={{ position: "relative" }}>
<div style={{ fontSize: 13, fontWeight: 700, letterSpacing: "0.1em", opacity: 0.6, textTransform: "uppercase", marginBottom: 8 }}>Data4Autos</div>
<div style={{ fontSize: 26, fontWeight: 900, marginBottom: 8, lineHeight: 1.2 }}>Turn14 Distribution Integration</div>
<div style={{ fontSize: 15, opacity: 0.75, maxWidth: 520, lineHeight: 1.6 }}>
Sync product brands, manage collections, and automate catalog setup directly from Turn14 into your Shopify store.
</div>
<div style={{ marginTop: 20, display: "flex", gap: 12, flexWrap: "wrap" }}>
<button
onClick={() => navigate("/app/dashboard")}
style={{ background: "rgba(255,255,255,0.15)", border: "1px solid rgba(255,255,255,0.3)", borderRadius: 8, color: "#fff", padding: "9px 20px", cursor: "pointer", fontWeight: 700, fontSize: 14 }}
>
📊 Open Dashboard
</button>
<button
onClick={() => setActiveModal(true)}
style={{ background: isSubscribed ? "rgba(34,197,94,0.2)" : "#2563eb", border: isSubscribed ? "1px solid rgba(34,197,94,0.4)" : "none", borderRadius: 8, color: "#fff", padding: "9px 20px", cursor: "pointer", fontWeight: 700, fontSize: 14 }}
>
{isSubscribed ? "✅ Subscription Active" : "🚀 Start Free Trial"}
</button>
</div>
</div>
</div>
<InlineStack gap="800" align="center" blockAlign="center">
<Image source={data4autosLogo} alt="Data4Autos Logo" width={120} />
<Image
source={turn14DistributorLogo}
alt="Turn14 Distributors Logo"
width={200}
/>
</InlineStack>
</BlockStack>
{/* Subscription status bar */}
<div style={{ background: statusStyle.bg, border: `1px solid ${statusStyle.border}`, borderRadius: 12, padding: "14px 20px", marginBottom: 24, display: "flex", gap: 24, flexWrap: "wrap", alignItems: "center" }}>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: "#9ca3af", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 3 }}>Subscription Status</div>
<div style={{ fontSize: 15, fontWeight: 800, color: statusStyle.color }}>{isSubscribed ? subscription?.status : "NOT SUBSCRIBED"}</div>
</div>
<div style={{ width: 1, height: 36, background: "#e5e7eb" }} />
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: "#9ca3af", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 3 }}>Plan</div>
<div style={{ fontSize: 14, fontWeight: 600, color: "#374151" }}>{displayPlan}</div>
</div>
<div style={{ width: 1, height: 36, background: "#e5e7eb" }} />
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: "#9ca3af", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 3 }}>Price</div>
<div style={{ fontSize: 14, fontWeight: 600, color: "#374151" }}>{displayPrice}</div>
</div>
{trialDaysLeft != null && (
<>
<div style={{ width: 1, height: 36, background: "#e5e7eb" }} />
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: "#9ca3af", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 3 }}>Trial Days Left</div>
<div style={{ fontSize: 14, fontWeight: 700, color: "#d97706" }}>{trialDaysLeft} days</div>
</div>
</>
)}
<div style={{ marginLeft: "auto" }}>
<button onClick={() => setActiveModal(true)} style={{ background: "#f3f4f6", border: "1px solid #e5e7eb", borderRadius: 8, color: "#374151", padding: "7px 16px", cursor: "pointer", fontWeight: 600, fontSize: 13 }}>
{isSubscribed ? "View Details" : "Manage Billing →"}
</button>
</div>
</div>
<Divider />
{/* Nav cards grid */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 16, marginBottom: 28 }}>
{navItems.map((item) => (
<NavCard key={item.title} {...item} />
))}
</div>
<BlockStack gap="500">
<InlineStack align="space-between" blockAlign="center">
<Text variant="headingMd" as="h3">
Subscription Overview
</Text>
<Badge tone={getStatusTone(subscription?.status || "PENDING")}>
{isSubscribed ? subscription?.status : "NOT SUBSCRIBED"}
</Badge>
</InlineStack>
<Box
padding="400"
background="bg-surface-secondary"
borderWidth="025"
borderRadius="200"
>
<BlockStack gap="300">
<Text as="p">
<strong>Plan:</strong> {displayPlan}
</Text>
<Text as="p">
<strong>Billing:</strong> {displayInterval}
</Text>
<Text as="p">
<strong>Price:</strong> {displayPrice}
</Text>
<Text as="p">
<strong>Next renewal / period end:</strong> {displayNextRenewal}
</Text>
<Text as="p">
<strong>Trial:</strong>{" "}
{isSubscribed
? `${subscription?.trialDays || 0} day(s)`
: `${TRIAL_DAYS} day free trial`}
</Text>
<Text as="p">
<strong>Trial days left:</strong>{" "}
{trialDaysLeft != null ? `${trialDaysLeft} day(s)` : "N/A"}
</Text>
</BlockStack>
</Box>
</BlockStack>
<Divider />
<BlockStack gap="800">
<Text variant="headingMd" as="h3">
🚀 Data4Autos Turn14 Integration gives you the power to sync
product brands, manage collections, and automate catalog setup
directly from Turn14 to your Shopify store.
</Text>
<InlineStack gap="400">
<Text as="h3" variant="headingLg" fontWeight="medium">
Use the left sidebar to:
</Text>
<Box
paddingBlockStart="800"
paddingBlockEnd="800"
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "1rem",
}}
>
{items.map((item, index) => (
<Card key={index} padding="500" background="bg-surface-secondary">
<BlockStack align="center" gap="200">
<Text
as="p"
fontWeight="bold"
alignment="center"
tone="subdued"
variant="bodyMd"
>
<span style={{ fontSize: "2rem" }}>{item.icon}</span>
</Text>
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: "none" }}
>
<Text
as="h6"
alignment="center"
fontWeight="bold"
variant="headingMd"
>
{item.text}
</Text>
</a>
</BlockStack>
</Card>
))}
</Box>
</InlineStack>
</BlockStack>
<Divider />
<BlockStack gap="400">
<Text tone="subdued" alignment="center">
Need help? Contact us at{" "}
<a href="mailto:support@data4autos.com">support@data4autos.com</a>
</Text>
<Button size="large" variant="primary" onClick={openModal} fullWidth>
{loaderData?.redirectToBilling
? "Proceed to Billing"
: "View Subscription Details"}
</Button>
</BlockStack>
</BlockStack>
</Card>
</Layout.Section>
</Layout>
{/* Footer help */}
<div style={{ textAlign: "center", padding: "16px 0", fontSize: 13, color: "#9ca3af" }}>
Need help? Email <a href="mailto:support@data4autos.com" style={{ color: "#2563eb", fontWeight: 600 }}>support@data4autos.com</a> or visit the <a href="/app/help" style={{ color: "#2563eb", fontWeight: 600 }}>Help page</a>
</div>
{/* Billing modal */}
<Modal
open={activeModal}
onClose={closeModal}
onClose={() => setActiveModal(false)}
title="Subscription Details"
primaryAction={
hasConfirmationUrl
? undefined
: isSubscribed
? undefined
: {
content:
cadence === "ANNUAL"
? `Start ${TRIAL_DAYS}-day Trial — $${ANNUAL_AMOUNT}/yr`
: `Start ${TRIAL_DAYS}-day Trial — $${MONTHLY_AMOUNT}/mo`,
onAction: () => {
const form = document.getElementById("billing-form");
if (form) {
const hidden = document.getElementById("cadence-field");
if (hidden) hidden.value = cadence;
submit(form, { method: "post" });
}
},
}
hasConfirmationUrl ? undefined : isSubscribed ? undefined : {
content: cadence === "ANNUAL" ? `Start ${TRIAL_DAYS}-day Trial — $${ANNUAL_AMOUNT}/yr` : `Start ${TRIAL_DAYS}-day Trial — $${MONTHLY_AMOUNT}/mo`,
onAction: () => {
const form = document.getElementById("billing-form");
if (form) {
const hidden = document.getElementById("cadence-field");
if (hidden) hidden.value = cadence;
submit(form, { method: "post" });
}
},
}
}
secondaryActions={[{ content: "Close", onAction: closeModal }]}
secondaryActions={[{ content: "Close", onAction: () => setActiveModal(false) }]}
>
<Form id="billing-form" method="post">
<input type="hidden" name="cadence" id="cadence-field" value={cadence} readOnly />
<Modal.Section>
<BlockStack gap="300">
{errors.length > 0 && (
<Banner title="Couldnt create subscription" tone="critical">
<Banner title="Couldn't create subscription" tone="critical">
<ul style={{ margin: 0, paddingLeft: "1rem" }}>
{errors.map((e, i) => (
<li key={i}>{e}</li>
))}
{errors.map((e, i) => <li key={i}>{e}</li>)}
</ul>
</Banner>
)}
@ -529,63 +313,23 @@ export default function Index() {
<TextField label="Plan" value={displayPlan} readOnly />
<TextField label="Billing Interval" value={displayInterval} readOnly />
<TextField label="Price" value={displayPrice} readOnly />
<TextField
label="Trial"
value={
isSubscribed
? `${subscription?.trialDays || 0} day(s)`
: `${TRIAL_DAYS}-day free trial`
}
readOnly
/>
<TextField
label="Trial Days Left"
value={trialDaysLeft != null ? `${trialDaysLeft} days left` : "N/A"}
readOnly
/>
<TextField
label="Next Renewal / Period End"
value={displayNextRenewal}
readOnly
/>
{subscription?.createdAt && (
<TextField
label="Subscription Created"
value={formatDate(subscription.createdAt)}
readOnly
/>
)}
<TextField label="Trial" value={isSubscribed ? `${subscription?.trialDays || 0} day(s)` : `${TRIAL_DAYS}-day free trial`} readOnly />
<TextField label="Trial Days Left" value={trialDaysLeft != null ? `${trialDaysLeft} days left` : "N/A"} readOnly />
<TextField label="Next Renewal / Period End" value={displayNextRenewal} readOnly />
{subscription?.createdAt && <TextField label="Subscription Created" value={formatDate(subscription.createdAt)} readOnly />}
<Divider />
{!isSubscribed && (
<>
<Text as="h3" variant="headingMd">
Choose your billing plan
</Text>
<div style={{ fontWeight: 700, fontSize: 15, marginBottom: 4 }}>Choose your billing plan</div>
<ChoiceList
title="Choose your billing cadence"
title="Billing cadence"
choices={[
{
label: `Monthly — $${MONTHLY_AMOUNT}/mo`,
value: "MONTHLY",
helpText:
"Flexible monthly billing. Cancel anytime during or after the trial.",
},
{
label: `Annual — $${ANNUAL_AMOUNT}/yr (save ~17%)`,
value: "ANNUAL",
helpText: "Best value. Billed annually after your free trial ends.",
},
{ label: `Monthly — $${MONTHLY_AMOUNT}/mo`, value: "MONTHLY", helpText: "Flexible monthly billing. Cancel anytime during or after the trial." },
{ label: `Annual — $${ANNUAL_AMOUNT}/yr (save ~17%)`, value: "ANNUAL", helpText: "Best value. Billed annually after your free trial ends." },
]}
selected={[cadence]}
onChange={(selected) => {
const next =
Array.isArray(selected) && selected[0]
? selected[0]
: "MONTHLY";
const next = Array.isArray(selected) && selected[0] ? selected[0] : "MONTHLY";
setCadence(next);
const hidden = document.getElementById("cadence-field");
if (hidden) hidden.value = next;
@ -600,27 +344,12 @@ export default function Index() {
{hasConfirmationUrl && (
<BlockStack gap="300">
<Banner title="Almost there!" tone="success">
Click the button below to open Shopifys billing confirmation in a
new tab.
Click the button below to open Shopify's billing confirmation in a new tab.
</Banner>
<Button
url={actionData.confirmationUrl}
target="_blank"
external
onClick={() => setActiveModal(false)}
variant="primary"
size="large"
>
<Button url={actionData.confirmationUrl} target="_blank" external onClick={() => setActiveModal(false)} variant="primary" size="large">
Open Billing Confirmation
</Button>
<a
href={actionData.confirmationUrl}
target="_blank"
rel="noopener noreferrer"
style={{ wordBreak: "break-all" }}
>
<a href={actionData.confirmationUrl} target="_blank" rel="noopener noreferrer" style={{ wordBreak: "break-all", fontSize: 12, color: "#6b7280" }}>
{actionData.confirmationUrl}
</a>
</BlockStack>
@ -631,4 +360,4 @@ export default function Index() {
</Modal>
</Page>
);
}
}

View File

@ -7,7 +7,6 @@ import {
TextField,
Checkbox,
Button,
Thumbnail,
Spinner,
Toast,
Frame,
@ -25,9 +24,7 @@ const ALLOWED_STATUSES = ["ACTIVE", "TRIAL"];
async function checkShopExists(shop) {
try {
const resp = await fetch(
`https://backend.data4autos.com/checkisshopdataexists/${shop}`
);
const resp = await fetch(`https://backend.data4autos.com/checkisshopdataexists/${shop}`);
const data = await resp.json();
return data.status === 1;
} catch (err) {
@ -37,14 +34,9 @@ async function checkShopExists(shop) {
}
function getIntervalLabel(interval) {
switch (interval) {
case "ANNUAL":
return "Every 12 months";
case "EVERY_30_DAYS":
return "Every 30 days";
default:
return interval || "N/A";
}
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") {
@ -54,28 +46,17 @@ function formatMoney(amount, currencyCode = "USD") {
function formatDate(date) {
if (!date) return "N/A";
return new Date(date).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
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
id name status test createdAt trialDays currentPeriodEnd
lineItems {
id
plan {
@ -83,10 +64,7 @@ async function getSubscriptionDetails(request) {
__typename
... on AppRecurringPricing {
interval
price {
amount
currencyCode
}
price { amount currencyCode }
}
}
}
@ -95,216 +73,87 @@ async function getSubscriptionDetails(request) {
}
}
`);
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);
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,
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;
console.log("✅ Shopify auth success");
console.log("🏪 Shop:", shop);
} catch (err) {
console.error("❌ Shopify authentication failed:", err);
return json({
brands: [],
collections: [],
selectedBrandsFromShopify: [],
shop: "",
error: "Shopify authentication failed",
isSubscribed: false,
subscription: null,
});
return json({ brands: [], collections: [], selectedBrandsFromShopify: [], shop: "", error: "Shopify authentication failed", isSubscribed: false, subscription: null });
}
const { isSubscribed, subscription } = await getSubscriptionDetails(request);
let accessToken = "";
try {
console.log("🔑 Fetching Turn14 access token from metafield...");
accessToken = await getTurn14AccessTokenFromMetafield(request);
console.log("✅ Turn14 access token received:", accessToken ? "YES" : "EMPTY");
} catch (err) {
console.error("❌ Error getting Turn14 access token:", err);
return json({
brands: [],
collections: [],
selectedBrandsFromShopify: [],
shop,
error: "Failed to fetch Turn14 access token",
isSubscribed,
subscription,
});
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",
},
});
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,
});
}
if (!brandRes.ok) return json({ brands: [], collections: [], selectedBrandsFromShopify: [], shop, error: brandJson?.error || "Failed to fetch brands", isSubscribed, subscription });
} catch (err) {
console.error("❌ Exception while fetching Turn14 brands:", err);
return json({
brands: [],
collections: [],
selectedBrandsFromShopify: [],
shop,
error: "Turn14 brands fetch crashed",
isSubscribed,
subscription,
});
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 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) {
console.error("❌ Error fetching Shopify collections:", err);
}
} catch (err) {}
let selectedBrands = [];
try {
const res = await admin.graphql(`
{
shop {
metafield(namespace: "turn14", key: "selected_brands") {
value
}
}
}
`);
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) {}
if (rawValue) {
selectedBrands = JSON.parse(rawValue);
}
} catch (err) {
console.error("❌ Failed parsing selected_brands metafield:", err);
}
return json({
brands: brandJson?.data || [],
collections,
selectedBrandsFromShopify: selectedBrands,
shop,
isSubscribed,
subscription,
});
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 }
);
}
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;
});
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,
},
headers: { "Content-Type": "application/json", "shop-domain": shop },
body: JSON.stringify({ shop, selectedBrands, selectedOldBrands }),
});
@ -312,47 +161,31 @@ export const action = async ({ request }) => {
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 { 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 [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);
})();
(async () => { const result = await checkShopExists(shop); setTurn14Enabled(result); })();
}, [shop]);
useEffect(() => {
setSelectedIdsold(selectedIds);
}, [toastActive]);
useEffect(() => { setSelectedIdsold(selectedIds); }, [toastActive]);
useEffect(() => {
const term = search.toLowerCase();
@ -360,157 +193,64 @@ export default function BrandsPage() {
}, [search, brands]);
useEffect(() => {
if (actionData.status) {
setStatus(actionData.status);
setToastActive(true);
}
}, [actionData.status]);
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 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})` : "")
);
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]
);
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 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])));
}
if (allFilteredSelected) setSelectedIds((prev) => prev.filter((id) => !ids.includes(id)));
else setSelectedIds((prev) => Array.from(new Set([...prev, ...ids])));
};
let isSubmitting = false;
if (actionData.status) {
isSubmitting = !actionData.status && !actionData.error && !actionData.processId;
}
const toastMarkup = toastActive ? (
<Toast
content="Collections updated successfully!"
onDismiss={() => setToastActive(false)}
/>
) : null;
const selectedBrands = brands.filter((b) => selectedIds.includes(b.id));
const selectedOldBrands = brands.filter((b) => selectedIdsold.includes(b.id));
const shopDomain = (shop || "").split(".")[0];
const items = [
{
icon: "⚙️",
text: "Manage API settings",
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/settings`,
},
{
icon: "🏷️",
text: "Browse and import available brands",
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/brands`,
},
{
icon: "📦",
text: "Sync brand collections to Shopify",
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/managebrand`,
},
{
icon: "🔐",
text: "Handle secure Turn14 login credentials",
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/help`,
},
];
const trialDaysLeft = useMemo(() => {
if (!subscription?.trialDays || !subscription?.createdAt) return null;
if (subscription.status !== "TRIAL") return null;
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);
const now = new Date();
const msLeft = trialEnd.getTime() - now.getTime();
const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24));
return Math.max(0, daysLeft);
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" background="critical" />
<Layout>
<Layout.Section>
<Card>
<div style={{ padding: 24, textAlign: "center" }}>
<Text as="h1" variant="headingLg">
Turn14 isnt connected yet
</Text>
<div style={{ marginTop: 8 }}>
<Text as="p" variant="bodyMd">
This shop hasnt been configured with Turn14 / Data4Autos. To get started, open Settings and complete the connection.
</Text>
</div>
<div style={{ marginTop: 24, display: "flex", gap: 16, justifyContent: "center", flexWrap: "wrap" }}>
<a href={items[0].link} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
<Text as="h6" variant="headingMd" fontWeight="bold">
{items[0].icon} {items[0].text}
</Text>
</a>
<a href={items[3].link} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
<Text as="h6" variant="headingMd" fontWeight="bold">
{items[3].icon} {items[3].text}
</Text>
</a>
</div>
<div style={{ marginTop: 28 }}>
<Text as="p" variant="bodySm" tone="subdued">
Once connected, youll be able to browse brands and sync collections.
</Text>
</div>
<div style={{ marginTop: 20, display: "flex", gap: 16, justifyContent: "center", flexWrap: "wrap" }}>
<a href={items[1].link} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
<Text as="p" variant="bodyMd">
{items[1].icon} {items[1].text}
</Text>
</a>
<a href={items[2].link} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
<Text as="p" variant="bodyMd">
{items[2].icon} {items[2].text}
</Text>
</a>
</div>
</div>
</Card>
</Layout.Section>
</Layout>
<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>
);
@ -519,211 +259,131 @@ export default function BrandsPage() {
return (
<Frame>
<Page fullWidth>
<TitleBar title="Data4Autos Turn14 Integration" background="primary" />
<TitleBar title="Data4Autos Turn14 Integration" />
<div style={{ marginBottom: 16 }}>
<Text as="h1" variant="headingLg">
Data4Autos Turn14 Brands List
</Text>
{/* 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>
<Layout>
{!isSubscribed && (
<Layout.Section>
<Banner title="Subscription required" tone="warning">
<p>
This feature is available only for merchants with an active
subscription or during the free trial period.
</p>
<div style={{ marginTop: 12 }}>
<p>
<strong>Current status:</strong>{" "}
{subscription?.status || "Not subscribed"}
</p>
<p>
<strong>Plan:</strong> {subscription?.name || PLAN_NAME}
</p>
<p>
<strong>Billing:</strong> {getIntervalLabel(subscription?.interval)}
</p>
<p>
<strong>Price:</strong>{" "}
{formatMoney(
subscription?.priceAmount,
subscription?.currencyCode
)}
</p>
<p>
<strong>Next renewal / period end:</strong>{" "}
{formatDate(subscription?.currentPeriodEnd)}
</p>
<p>
<strong>Trial days left:</strong>{" "}
{trialDaysLeft != null ? `${trialDaysLeft} day(s)` : "N/A"}
</p>
</div>
<div style={{ marginTop: 16 }}>
<InlineStack gap="300">
<Button variant="primary" onClick={() => navigate("/app")}>
Go to Home Page
</Button>
</InlineStack>
</div>
</Banner>
</Layout.Section>
)}
{/* 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 && (
<Layout.Section>
<Banner title="Error" tone="critical">
<p>{error}</p>
</Banner>
</Layout.Section>
)}
{error && (
<div style={{ background: "#fff1f2", border: "1px solid #fecdd3", borderRadius: 12, padding: "14px 18px", marginBottom: 20, color: "#dc2626" }}>
{error}
</div>
)}
{actionData?.error && (
<Layout.Section>
<Banner title="Action blocked" tone="critical">
<p>{actionData.error}</p>
</Banner>
</Layout.Section>
)}
{actionData?.error && (
<div style={{ background: "#fff1f2", border: "1px solid #fecdd3", borderRadius: 12, padding: "14px 18px", marginBottom: 20, color: "#dc2626" }}>
{actionData.error}
</div>
)}
<Layout.Section>
<div
style={{
position: "sticky",
top: 0,
zIndex: 10,
background: "#ffffff",
padding: "12px 16px",
borderRadius: 12,
boxShadow: "0 1px 6px rgba(0,0,0,0.08)",
marginBottom: 20,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 16,
flexWrap: "wrap",
}}
>
<div style={{ display: "flex", gap: 16, alignItems: "center", flexWrap: "wrap" }}>
{(actionData?.processId || false) && (
<div>
<p>
<strong>Process ID:</strong> {actionData.processId}
</p>
<p>
<strong>Status:</strong> {status || "—"}
</p>
<Button onClick={checkStatus} loading={polling}>
Check Status
</Button>
</div>
)}
<div style={{ minWidth: 260 }}>
<TextField
labelHidden
label="Search brands"
value={search}
onChange={setSearch}
placeholder="Type brand name…"
autoComplete="off"
disabled={!isSubscribed}
/>
</div>
<Checkbox
label="Select All"
checked={allFilteredSelected}
onChange={toggleSelectAll}
disabled={!isSubscribed}
/>
</div>
<Form
method="post"
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<input
type="hidden"
name="selectedBrands"
value={JSON.stringify(selectedBrands)}
/>
<input
type="hidden"
name="selectedOldBrands"
value={JSON.stringify(selectedOldBrands)}
/>
<Button
primary
submit
disabled={isSubmitting || !isSubscribed}
size="large"
variant="primary"
>
{isSubmitting ? <Spinner size="small" /> : "Save Collections"}
</Button>
</Form>
{/* 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>
</Layout.Section>
)}
</div>
<Layout.Section>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
gap: 16,
}}
<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 }}
>
{filteredBrands.map((brand) => (
<Card key={brand.id} sectioned>
<div style={{ position: "relative", textAlign: "center" }}>
<div style={{ position: "absolute", top: 0, right: 0 }}>
<Checkbox
label=""
checked={selectedIds.includes(brand.id)}
onChange={() => toggleSelect(brand.id)}
disabled={!isSubscribed}
/>
</div>
{saving && <Spinner size="small" />}
{saving ? "Saving…" : `Save ${selectedIds.length} Collections`}
</button>
</Form>
</div>
<div style={{ display: "flex", justifyContent: "center" }}>
<Thumbnail
source={
brand.logo ||
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
}
alt={brand.name}
size="large"
/>
</div>
<div
style={{
marginTop: "15px",
fontWeight: "600",
fontSize: "16px",
lineHeight: "26px",
}}
>
{brand.name}
</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>
</Card>
))}
</div>
</Layout.Section>
</Layout>
</div>
{toastMarkup}
<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>
);
}
}

View File

@ -0,0 +1,488 @@
// app/routes/app.dashboard.jsx Live import progress dashboard
import { useEffect, useState, useRef, useCallback } from "react";
import { json } from "@remix-run/node";
import { useLoaderData, useNavigate } from "@remix-run/react";
import { Page, Layout, Card, Text, BlockStack, InlineStack, Badge, ProgressBar, Button, Spinner, Box, Divider } from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
const BACKEND = "https://backend.data4autos.com";
const POLL_INTERVAL = 3000;
export const loader = async ({ request }) => {
const { session } = await authenticate.admin(request);
return json({ shop: session.shop });
};
// helpers
function elapsed(startedAt) {
if (!startedAt) return "—";
const secs = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
if (secs < 60) return `${secs}s`;
const m = Math.floor(secs / 60), s = secs % 60;
if (m < 60) return `${m}m ${s}s`;
const h = Math.floor(m / 60), mm = m % 60;
return `${h}h ${mm}m`;
}
function fmt(d) {
if (!d) return "—";
return new Date(d).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function statusTone(status) {
if (!status) return "subdued";
if (status === "done") return "success";
if (status === "error") return "critical";
if (status === "cancelled") return "warning";
if (status === "importing" || status === "fetching_products") return "info";
return "subdued";
}
function statusLabel(status) {
const map = {
started: "Starting",
fetching_products: "Fetching Products",
importing: "Importing",
done: "Completed",
error: "Error",
cancelled: "Cancelled",
cancelling: "Cancelling…",
};
return map[status] || (status || "Unknown");
}
function stepLabel(step) {
const map = {
started: "Initialising",
fetching_products: "Fetching products from Turn14",
importing: "Creating / updating products in Shopify",
completed: "Import complete",
error: "Import stopped — error",
cancelled: "Import cancelled",
};
return map[step] || step || "—";
}
function parseLogTone(line) {
if (!line) return "subdued";
if (line.includes("[PRODUCT-OK]") || line.includes("[IMPORT-DONE]") || line.includes("[FETCH-OK]")) return "success";
if (line.includes("[PRODUCT-FAIL]") || line.includes("[ERROR]") || line.includes("[FETCH-FAIL]")) return "critical";
if (line.includes("[SKIP]")) return "warning";
if (line.includes("[PRODUCT]") || line.includes("[STATS]")) return "info";
return "subdued";
}
function parseLogTitle(line) {
if (!line) return "Log";
if (line.includes("[PRODUCT-OK]")) return "Product created";
if (line.includes("[PRODUCT-FAIL]")) return "Product failed";
if (line.includes("[SKIP]")) return "Skipped duplicate";
if (line.includes("[IMPORT-DONE]")) return "Import complete";
if (line.includes("[IMPORT-START]")) return "Import started";
if (line.includes("[FETCH-OK]")) return "Turn14 fetch success";
if (line.includes("[FETCH-FAIL]")) return "Turn14 fetch failed";
if (line.includes("[FETCH]")) return "Fetching from Turn14";
if (line.includes("[STATS]")) return "Progress update";
if (line.includes("[PRODUCT]")) return "Processing product";
if (line.includes("[ERROR]")) return "Error";
if (line.includes("[CANCEL]")) return "Cancelled";
return "Activity";
}
const toneColors = {
success: { bg: "#f0fdf4", border: "#bbf7d0", dot: "#16a34a", text: "#166534" },
critical: { bg: "#fff1f2", border: "#fecdd3", dot: "#dc2626", text: "#991b1b" },
warning: { bg: "#fffbeb", border: "#fde68a", dot: "#d97706", text: "#92400e" },
info: { bg: "#eff6ff", border: "#bfdbfe", dot: "#2563eb", text: "#1e40af" },
subdued: { bg: "#f9fafb", border: "#e5e7eb", dot: "#9ca3af", text: "#6b7280" },
};
// StatCard
function StatCard({ label, value, sub, accent }) {
const accents = {
blue: { bg: "#eff6ff", border: "#bfdbfe", val: "#1d4ed8" },
green: { bg: "#f0fdf4", border: "#bbf7d0", val: "#15803d" },
red: { bg: "#fff1f2", border: "#fecdd3", val: "#dc2626" },
amber: { bg: "#fffbeb", border: "#fde68a", val: "#b45309" },
purple: { bg: "#faf5ff", border: "#e9d5ff", val: "#7c3aed" },
slate: { bg: "#f8fafc", border: "#e2e8f0", val: "#475569" },
};
const c = accents[accent] || accents.slate;
return (
<div style={{ background: c.bg, border: `1px solid ${c.border}`, borderRadius: 12, padding: "18px 20px", minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>{label}</div>
<div style={{ fontSize: 28, fontWeight: 800, color: c.val, lineHeight: 1 }}>{value ?? "—"}</div>
{sub && <div style={{ fontSize: 12, color: "#9ca3af", marginTop: 5 }}>{sub}</div>}
</div>
);
}
// StageRow
function StageRow({ label, status, value, meta }) {
const dotColor = status === "done" ? "#16a34a" : status === "active" ? "#2563eb" : status === "error" ? "#dc2626" : "#d1d5db";
const textColor = status === "active" ? "#1d4ed8" : status === "done" ? "#166534" : "#6b7280";
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 0", borderBottom: "1px solid #f3f4f6" }}>
<div style={{ width: 10, height: 10, borderRadius: "50%", background: dotColor, flexShrink: 0, boxShadow: status === "active" ? `0 0 0 3px ${dotColor}33` : "none" }} />
<div style={{ flex: 1, fontSize: 14, fontWeight: status === "active" ? 700 : 500, color: textColor }}>{label}</div>
{meta && <div style={{ fontSize: 12, color: "#9ca3af" }}>{meta}</div>}
{value && (
<div style={{ background: status === "done" ? "#f0fdf4" : status === "active" ? "#eff6ff" : "#f3f4f6", border: `1px solid ${status === "done" ? "#bbf7d0" : status === "active" ? "#bfdbfe" : "#e5e7eb"}`, borderRadius: 20, padding: "2px 10px", fontSize: 13, fontWeight: 700, color: status === "done" ? "#15803d" : status === "active" ? "#1d4ed8" : "#6b7280" }}>
{value}
</div>
)}
</div>
);
}
// LogEntry
function LogEntry({ entry }) {
const tone = parseLogTone(entry.line);
const title = parseLogTitle(entry.line);
const c = toneColors[tone];
const cleanLine = entry.line.replace(/^\[[\w-]+\]\s*/, "");
return (
<div style={{ display: "flex", gap: 10, padding: "8px 0", borderBottom: "1px solid #f9fafb", alignItems: "flex-start" }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: c.dot, marginTop: 5, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: c.text }}>{title}</div>
<div style={{ fontSize: 12, color: "#6b7280", wordBreak: "break-word", marginTop: 1 }}>{cleanLine}</div>
</div>
<div style={{ fontSize: 11, color: "#9ca3af", flexShrink: 0, paddingTop: 1 }}>{fmt(entry.at)}</div>
</div>
);
}
// Main component
export default function Dashboard() {
const { shop } = useLoaderData();
const navigate = useNavigate();
const [jobs, setJobs] = useState([]);
const [selectedJobId, setSelectedJobId] = useState(null);
const [job, setJob] = useState(null);
const [loading, setLoading] = useState(true);
const [tick, setTick] = useState(0);
const [now, setNow] = useState(Date.now());
const pollRef = useRef(null);
// Fetch job list
const fetchJobs = useCallback(async () => {
try {
const resp = await fetch(`${BACKEND}/jobs?shop=${encodeURIComponent(shop)}`);
const data = await resp.json();
const list = data.jobs || [];
setJobs(list);
if (!selectedJobId && list.length > 0) setSelectedJobId(list[0].id);
} catch {}
}, [shop, selectedJobId]);
// Fetch selected job detail
const fetchJob = useCallback(async (id) => {
if (!id) return;
try {
const resp = await fetch(`${BACKEND}/jobs/${id}`);
if (!resp.ok) return;
const j = await resp.json();
setJob(j);
} catch {}
}, []);
// Initial load
useEffect(() => {
(async () => {
setLoading(true);
await fetchJobs();
setLoading(false);
})();
}, []);
// Select job when list arrives
useEffect(() => {
if (!selectedJobId && jobs.length > 0) setSelectedJobId(jobs[0].id);
}, [jobs]);
// Poll selected job
useEffect(() => {
if (!selectedJobId) return;
fetchJob(selectedJobId);
pollRef.current = setInterval(async () => {
await fetchJob(selectedJobId);
setTick(t => t + 1);
}, POLL_INTERVAL);
return () => clearInterval(pollRef.current);
}, [selectedJobId]);
// Elapsed timer
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
const handleCancel = async () => {
if (!selectedJobId) return;
await fetch(`${BACKEND}/jobs/${selectedJobId}/cancel`, { method: "POST" });
fetchJob(selectedJobId);
};
const isRunning = job && (job.status === "importing" || job.status === "fetching_products" || job.status === "started");
const s = job?.liveStats || { total: 0, processed: 0, created: 0, skipped: 0, failed: 0, remaining: 0, successRate: 0 };
const progress = s.total > 0 ? Math.round((s.processed / s.total) * 100) : 0;
const elapsedStr = job?.startedAt ? elapsed(job.startedAt) : "—";
// Stage states
const stageStatus = (stageName) => {
if (!job) return "pending";
if (job.status === "error" && job.step === stageName) return "error";
if (job.step === stageName || job.status === stageName) return "active";
const order = ["started", "fetching_products", "importing", "completed"];
const jobIdx = order.indexOf(job.step || job.status);
const stageIdx = order.indexOf(stageName);
if (jobIdx > stageIdx) return "done";
return "pending";
};
const recentLogs = [...(job?.logs || [])].reverse().slice(0, 10);
return (
<Page>
<TitleBar title="Import Dashboard" />
{/* ── 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: 16 }}>
<div>
<div style={{ fontSize: 22, fontWeight: 800, marginBottom: 4 }}>📊 Import Dashboard</div>
<div style={{ fontSize: 14, opacity: 0.8 }}>{shop}</div>
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
<button
onClick={() => navigate("/app/managebrand")}
style={{ background: "rgba(255,255,255,0.15)", border: "1px solid rgba(255,255,255,0.3)", borderRadius: 8, color: "#fff", padding: "8px 18px", cursor: "pointer", fontWeight: 600, fontSize: 14 }}
>
+ New Import
</button>
{isRunning && (
<button
onClick={handleCancel}
style={{ background: "#dc2626", border: "none", borderRadius: 8, color: "#fff", padding: "8px 18px", cursor: "pointer", fontWeight: 600, fontSize: 14 }}
>
Cancel Import
</button>
)}
</div>
</div>
{loading ? (
<Card><div style={{ padding: 48, textAlign: "center" }}><Spinner /><div style={{ marginTop: 12, color: "#6b7280" }}>Loading jobs</div></div></Card>
) : jobs.length === 0 ? (
<Card>
<div style={{ padding: 64, textAlign: "center" }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>📦</div>
<Text variant="headingMd" as="h2">No import jobs yet</Text>
<div style={{ marginTop: 8, color: "#6b7280", marginBottom: 24 }}>Start an import from the Manage Brands page to see live progress here.</div>
<Button variant="primary" onClick={() => navigate("/app/managebrand")}>Go to Manage Brands</Button>
</div>
</Card>
) : (
<Layout>
{/* ── Job selector sidebar ── */}
{jobs.length > 1 && (
<Layout.Section variant="oneThird">
<Card>
<div style={{ padding: "12px 16px 8px", borderBottom: "1px solid #f3f4f6" }}>
<Text variant="headingSm" as="h3" tone="subdued">RECENT IMPORTS</Text>
</div>
<div>
{jobs.slice(0, 10).map(j => (
<div
key={j.id}
onClick={() => setSelectedJobId(j.id)}
style={{ padding: "12px 16px", cursor: "pointer", borderBottom: "1px solid #f9fafb", background: j.id === selectedJobId ? "#eff6ff" : "transparent", borderLeft: j.id === selectedJobId ? "3px solid #2563eb" : "3px solid transparent" }}
>
<div style={{ fontWeight: 700, fontSize: 13, color: "#1f2937", marginBottom: 3 }}>{j.brandName || `Brand ${j.brandId}`}</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span style={{ fontSize: 11, color: toneColors[statusTone(j.status)].dot, fontWeight: 600 }}> {statusLabel(j.status)}</span>
<span style={{ fontSize: 11, color: "#9ca3af" }}>{fmt(j.startedAt)}</span>
</div>
</div>
))}
</div>
</Card>
</Layout.Section>
)}
{/* ── Main job detail ── */}
<Layout.Section>
{job && (
<BlockStack gap="400">
{/* Status + Brand header */}
<Card>
<BlockStack gap="300">
<InlineStack align="space-between" blockAlign="center" wrap={false}>
<BlockStack gap="100">
<Text variant="headingLg" as="h2">{job.brandName || `Brand ${job.brandId}`}</Text>
<Text tone="subdued" variant="bodySm">Job ID: {job.id}</Text>
</BlockStack>
<InlineStack gap="200" blockAlign="center">
{isRunning && <Spinner size="small" />}
<Badge tone={statusTone(job.status)}>{statusLabel(job.status)}</Badge>
</InlineStack>
</InlineStack>
<Divider />
{/* Progress bar */}
<BlockStack gap="100">
<InlineStack align="space-between">
<Text variant="bodySm" tone="subdued">{stepLabel(job.step)}</Text>
<Text variant="bodyMd" fontWeight="bold">{progress}%</Text>
</InlineStack>
<ProgressBar
progress={progress}
tone={job.status === "error" ? "critical" : job.status === "done" ? "success" : "highlight"}
size="medium"
/>
</BlockStack>
{/* Timing row */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 12 }}>
<div style={{ textAlign: "center", padding: "10px 8px", background: "#f9fafb", borderRadius: 8 }}>
<div style={{ fontSize: 11, color: "#9ca3af", fontWeight: 600, textTransform: "uppercase" }}>Started</div>
<div style={{ fontSize: 14, fontWeight: 700, color: "#374151", marginTop: 3 }}>{fmt(job.startedAt)}</div>
</div>
<div style={{ textAlign: "center", padding: "10px 8px", background: "#eff6ff", borderRadius: 8 }}>
<div style={{ fontSize: 11, color: "#9ca3af", fontWeight: 600, textTransform: "uppercase" }}>Elapsed</div>
<div style={{ fontSize: 14, fontWeight: 700, color: "#1d4ed8", marginTop: 3 }}>{isRunning ? elapsed(job.startedAt) : (job.durationSeconds ? `${job.durationSeconds}s` : elapsedStr)}</div>
</div>
<div style={{ textAlign: "center", padding: "10px 8px", background: "#f9fafb", borderRadius: 8 }}>
<div style={{ fontSize: 11, color: "#9ca3af", fontWeight: 600, textTransform: "uppercase" }}>Finished</div>
<div style={{ fontSize: 14, fontWeight: 700, color: "#374151", marginTop: 3 }}>{fmt(job.finishedAt)}</div>
</div>
</div>
</BlockStack>
</Card>
{/* Current product banner */}
{job.currentProduct && (
<div style={{ background: "linear-gradient(135deg, #0f172a, #1e3a5f)", borderRadius: 12, padding: "16px 20px", color: "#fff", display: "flex", alignItems: "center", gap: 16 }}>
<Spinner size="small" />
<div>
<div style={{ fontSize: 11, opacity: 0.6, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.06em" }}>Currently importing</div>
<div style={{ fontSize: 16, fontWeight: 800, marginTop: 2 }}>{job.currentProduct.name}</div>
{job.currentProduct.partNumber && (
<div style={{ fontSize: 12, opacity: 0.7, marginTop: 1 }}>Part: {job.currentProduct.partNumber}</div>
)}
<div style={{ fontSize: 12, opacity: 0.6, marginTop: 1 }}>
Product {job.currentProduct.number} of {job.currentProduct.total}
</div>
</div>
<div style={{ marginLeft: "auto", textAlign: "right" }}>
<div style={{ fontSize: 32, fontWeight: 900, color: "#60a5fa" }}>{job.currentProduct.number}</div>
<div style={{ fontSize: 12, opacity: 0.5 }}>/ {job.currentProduct.total}</div>
</div>
</div>
)}
{/* Stats grid */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: 12 }}>
<StatCard label="Total Selected" value={s.total} accent="blue" />
<StatCard label="Processed" value={s.processed} accent="purple" sub={`${s.remaining} remaining`} />
<StatCard label="Created" value={s.created} accent="green" />
<StatCard label="Skipped" value={s.skipped} accent="amber" sub="already in store" />
<StatCard label="Failed" value={s.failed} accent="red" />
<StatCard label="Success Rate" value={`${s.successRate}%`} accent={s.successRate >= 90 ? "green" : s.successRate >= 70 ? "amber" : "red"} />
</div>
{/* Stage progress board */}
<Card>
<div style={{ padding: "4px 0" }}>
<div style={{ marginBottom: 12 }}>
<Text variant="headingSm" as="h3">Import Stages</Text>
</div>
<StageRow label="Fetch products from Turn14" status={stageStatus("fetching_products")} value={s.total > 0 ? `${s.total} found` : null} />
<StageRow label="Create / update products in Shopify" status={stageStatus("importing")} value={s.total > 0 ? s.label : null} meta={s.failed > 0 ? `${s.failed} failed` : null} />
<StageRow label="Import complete" status={stageStatus("completed")} value={job.status === "done" ? "Done" : null} />
</div>
</Card>
{/* Detail text */}
{job.detail && (
<div style={{ background: "#f0f9ff", border: "1px solid #bae6fd", borderRadius: 10, padding: "12px 16px", fontSize: 14, color: "#0369a1", fontWeight: 500 }}>
{job.detail}
</div>
)}
{/* Error details */}
{job.status === "error" && (
<div style={{ background: "#fff1f2", border: "1px solid #fecdd3", borderRadius: 10, padding: "16px" }}>
<div style={{ fontWeight: 700, color: "#dc2626", marginBottom: 6 }}> Import Error</div>
<div style={{ fontSize: 13, color: "#991b1b" }}>{job.detail}</div>
</div>
)}
{/* Per-product errors */}
{job.errors?.length > 0 && (
<Card>
<div style={{ marginBottom: 10 }}>
<Text variant="headingSm" as="h3" tone="critical">Failed Products ({job.errors.length})</Text>
</div>
<div style={{ maxHeight: 220, overflowY: "auto" }}>
{job.errors.map((e, i) => (
<div key={i} style={{ padding: "8px 0", borderBottom: "1px solid #fef2f2", display: "flex", gap: 12 }}>
<div style={{ width: 6, height: 6, borderRadius: "50%", background: "#dc2626", marginTop: 6, flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>#{e.index} {e.product}</div>
<div style={{ fontSize: 12, color: "#dc2626", marginTop: 2 }}>{e.error}</div>
</div>
</div>
))}
</div>
</Card>
)}
{/* Completion summary */}
{job.status === "done" && (
<div style={{ background: "linear-gradient(135deg, #f0fdf4, #dcfce7)", border: "1px solid #bbf7d0", borderRadius: 12, padding: "20px 24px" }}>
<div style={{ fontSize: 18, fontWeight: 800, color: "#15803d", marginBottom: 12 }}> Import Completed</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 10 }}>
{[
{ l: "Total products", v: s.total },
{ l: "Created", v: s.created },
{ l: "Skipped", v: s.skipped },
{ l: "Failed", v: s.failed },
{ l: "Success rate", v: `${s.successRate}%` },
{ l: "Duration", v: job.durationSeconds ? `${job.durationSeconds}s` : "—" },
].map(({ l, v }) => (
<div key={l} style={{ background: "#fff", borderRadius: 8, padding: "10px 12px", border: "1px solid #bbf7d0" }}>
<div style={{ fontSize: 11, color: "#6b7280", fontWeight: 600, textTransform: "uppercase" }}>{l}</div>
<div style={{ fontSize: 20, fontWeight: 800, color: "#15803d", marginTop: 2 }}>{v}</div>
</div>
))}
</div>
</div>
)}
{/* Recent activity log */}
<Card>
<div style={{ marginBottom: 10 }}>
<Text variant="headingSm" as="h3">Recent Activity</Text>
</div>
{recentLogs.length === 0 ? (
<div style={{ color: "#9ca3af", fontSize: 13, padding: "12px 0" }}>No activity yet waiting for import to start</div>
) : (
recentLogs.map((entry, i) => <LogEntry key={i} entry={entry} />)
)}
</Card>
</BlockStack>
)}
</Layout.Section>
</Layout>
)}
</Page>
);
}

View File

@ -1,13 +1,4 @@
import {
Page,
Layout,
Card,
Text,
BlockStack,
Link,
Button,
Collapsible,
} from "@shopify/polaris";
import { Page, Layout, Card, Text, BlockStack, Link } from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { useState, useCallback } from "react";
import { authenticate } from "../shopify.server";
@ -17,107 +8,119 @@ export const loader = async ({ request }) => {
return null;
};
const faqs = [
{
title: "How do I connect my Turn14 account?",
icon: "🔌",
content: "Go to the Settings page, enter your Turn14 Client ID and Secret, then click 'Connect Turn14'. A green success indicator will confirm the connection. Your credentials are stored securely in Shopify's encrypted metafields.",
},
{
title: "Where can I import brands from?",
icon: "🏷️",
content: "Use the 'Brands' tab in the left sidebar to browse all available Turn14 brands. Check the brands you want to sync, then click 'Save Collections' to create Shopify collections for each selected brand.",
},
{
title: "How do I sync brand collections and products?",
icon: "🔄",
content: "In the 'Manage Brands' section, select a brand and click 'Show Products' to load its catalog. Apply fitment filters (Make, stock type, LTL, etc.) then click 'Add Products to Store' to start an import. Watch live progress on the Dashboard page.",
},
{
title: "Is my Turn14 API key secure?",
icon: "🔐",
content: "Yes. Credentials are stored using Shopify's encrypted metafield storage, not in plain text or local files. Only your store's Shopify admin can access them.",
},
{
title: "How do I monitor import progress?",
icon: "📊",
content: "After starting an import from Manage Brands, head to the Dashboard page in the sidebar. It shows real-time progress: products created, skipped, failed, elapsed time, current product being processed, and a live activity log.",
},
{
title: "What do the product filters do?",
icon: "🔎",
content: "Fitment filters let you narrow products by vehicle Make. Additional toggles control Zero Stock (include/exclude), LTL Freight Required, Clearance items, Air Freight Prohibited items, and products with no images — giving you precise control over what gets imported.",
},
];
export default function HelpPage() {
const [openIndex, setOpenIndex] = useState(null);
const toggle = useCallback((index) => {
setOpenIndex((prev) => (prev === index ? null : index));
}, []);
const faqs = [
{
title: "📌 How do I connect my Turn14 account?",
content:
"Go to the Settings page, enter your Turn14 Client ID and Secret, then click 'Save & Connect'. A green badge will confirm successful connection.",
},
{
title: "📦 Where can I import brands from?",
content:
"Use the 'Brands' tab in the left menu to view and import available brands from Turn14 into your Shopify store.",
},
{
title: "🔄 How do I sync brand collections?",
content:
"In the 'Manage Brands' section, select the brands and hit 'Sync to Shopify'. A manual collection will be created or updated.",
},
{
title: "🔐 Is my Turn14 API key secure?",
content:
"Yes. The credentials are stored using Shopifys encrypted storage (metafields), ensuring they are safe and secure.",
},
];
const toggle = useCallback((i) => setOpenIndex((p) => (p === i ? null : i)), []);
return (
<Page>
<TitleBar title="Help & Documentation" />
{/* Dark header */}
<div style={{ background: "linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%)", borderRadius: 16, padding: "28px 32px", marginBottom: 28, color: "#fff" }}>
<div style={{ fontSize: 22, fontWeight: 800, marginBottom: 6 }}> Help & Documentation</div>
<div style={{ fontSize: 14, opacity: 0.8 }}>Everything you need to get up and running with Data4Autos × Turn14</div>
</div>
<Layout>
<Layout.Section>
<Card>
<BlockStack gap="400">
<Text variant="headingLg" as="h1">
Need Help? Youre in the Right Place!
</Text>
<Text>
This section covers frequently asked questions about the Data4Autos
Turn14 integration app.
</Text>
<BlockStack gap="300">
{faqs.map((faq, index) => (
{/* FAQ cards */}
{faqs.map((faq, index) => {
const isOpen = openIndex === index;
return (
<div
key={index}
style={{
border: "1px solid #E1E3E5",
borderRadius: "8px",
marginBottom: "0px",
border: `1px solid ${isOpen ? "#bfdbfe" : "#e5e7eb"}`,
borderRadius: 12,
overflow: "hidden",
boxShadow: "0 1px 3px rgba(0,0,0,0.05)",
boxShadow: isOpen ? "0 4px 12px rgba(37,99,235,0.08)" : "0 1px 3px rgba(0,0,0,0.04)",
transition: "box-shadow 0.2s, border-color 0.2s",
}}
>
{/* Header */}
<div
onClick={() => toggle(index)}
style={{
background: "#F6F6F7",
padding: "0.75rem 1rem",
background: isOpen ? "#eff6ff" : "#ffffff",
padding: "16px 20px",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
transition: "background 0.2s",
}}
onClick={() => toggle(index)}
>
<Text variant="bodyLg" fontWeight="bold">
{faq.title}
</Text>
<span style={{ transform: openIndex === index ? "rotate(90deg)" : "rotate(0deg)", transition: "0.2s" }}>
</span>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ width: 36, height: 36, background: isOpen ? "#2563eb" : "#f1f5f9", borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18, flexShrink: 0 }}>
{faq.icon}
</div>
<span style={{ fontWeight: 700, fontSize: 15, color: isOpen ? "#1d4ed8" : "#111827" }}>{faq.title}</span>
</div>
<div style={{ width: 24, height: 24, background: isOpen ? "#2563eb" : "#e5e7eb", borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s", flexShrink: 0 }}>
<span style={{ color: isOpen ? "#fff" : "#6b7280", fontSize: 12, transform: isOpen ? "rotate(180deg)" : "rotate(0deg)", display: "block", transition: "transform 0.2s" }}></span>
</div>
</div>
{/* Collapsible Body */}
<Collapsible open={openIndex === index}>
<div
style={{
padding: "1rem",
background: "#FFFFFF",
}}
>
<Text as="p" tone="subdued">
{faq.content}
</Text>
{isOpen && (
<div style={{ padding: "16px 20px 20px", background: "#fafcff", borderTop: "1px solid #dbeafe" }}>
<div style={{ fontSize: 14, color: "#374151", lineHeight: 1.7 }}>{faq.content}</div>
</div>
</Collapsible>
)}
</div>
))}
);
})}
</BlockStack>
</Layout.Section>
<Text tone="subdued">
Still have questions? Email us at{" "}
<Link url="mailto:support@data4autos.com">
support@data4autos.com
</Link>
</Text>
</BlockStack>
</Card>
{/* Contact card */}
<Layout.Section>
<div style={{ background: "linear-gradient(135deg, #f0f9ff, #e0f2fe)", border: "1px solid #bae6fd", borderRadius: 12, padding: "24px 28px", textAlign: "center" }}>
<div style={{ fontSize: 32, marginBottom: 10 }}>📬</div>
<div style={{ fontSize: 17, fontWeight: 700, color: "#0c4a6e", marginBottom: 6 }}>Still need help?</div>
<div style={{ fontSize: 14, color: "#0369a1", marginBottom: 14 }}>Our support team is ready to assist you with any questions about your integration.</div>
<a
href="mailto:support@data4autos.com"
style={{ display: "inline-block", background: "#2563eb", color: "#fff", borderRadius: 8, padding: "10px 24px", fontWeight: 700, fontSize: 14, textDecoration: "none" }}
>
support@data4autos.com
</a>
</div>
</Layout.Section>
</Layout>
</Page>

View File

@ -59,11 +59,11 @@ export default function App() {
<AppProvider isEmbeddedApp apiKey={apiKey}>
<NavMenu>
<Link to="/app" rel="home">🏠 Home</Link>
<Link to="/app/dashboard">📊 Dashboard</Link>
<Link to="/app/settings"> Settings</Link>
<Link to="/app/brands">🏷 Brands</Link>
<Link to="/app/managebrand">📦 Manage Brands</Link>
<Link to="/app/help">🆘 Help</Link>
{/* <Link to="/app/testing">🆘 Testing</Link> */}
</NavMenu>
<Outlet />
</AppProvider>

File diff suppressed because it is too large Load Diff

View File

@ -9,14 +9,14 @@ import {
Card,
TextField,
Button,
TextContainer,
InlineError,
Text,
BlockStack,
Box,
Select,
Banner,
InlineStack,
Badge,
Spinner,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
@ -55,7 +55,6 @@ export const loader = async ({ request }) => {
if (data.shop.metafield?.value) {
try { creds = JSON.parse(data.shop.metafield.value); } catch { }
}
// creds = {};
let savedPricing = { priceType: "map", percentage: 0 };
if (data.shop.pricing?.value) {
try {
@ -80,7 +79,6 @@ export const action = async ({ request }) => {
const intent = formData.get("intent"); // "connect_turn14" | "save_pricing"
const { admin } = await authenticate.admin(request);
// we need shop id either way
const shopResp = await admin.graphql(`{ shop { id name myshopifyDomain } }`);
const shopJson = await shopResp.json();
const shopId = shopJson.data.shop.id;
@ -88,13 +86,10 @@ export const action = async ({ request }) => {
const shopDomain = shopJson.data.shop.myshopifyDomain;
if (intent === "save_pricing") {
// --- save pricing_config metafield directly ---
const priceTypeRaw = (formData.get("price_type") || "map").toString().toLowerCase();
const percentageRaw = Number(formData.get("percentage") || 0);
const priceType = ["map", "percentage"].includes(priceTypeRaw) ? priceTypeRaw : "map";
const percentage = Number.isFinite(percentageRaw) ? percentageRaw : 0;
const cfg = { priceType, percentage };
const mutation = `
mutation {
@ -118,28 +113,19 @@ export const action = async ({ request }) => {
return json({ success: true, pricingSaved: true, savedPricing: cfg });
}
// default / legacy: connect Turn14 flow
// connect Turn14 flow
const clientId = formData.get("client_id");
const clientSecret = formData.get("client_secret");
// const clientId = formData.get("demo_client_id");
// const clientSecret = formData.get("demo_client_secret");
let tokenData;
try {
const tokenRes = await fetch("https://turn14.data4autos.com/v1/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: clientId,
client_secret: clientSecret,
}),
body: JSON.stringify({ grant_type: "client_credentials", client_id: clientId, client_secret: clientSecret }),
});
tokenData = await tokenRes.json();
if (!tokenRes.ok) {
throw new Error(tokenData.error || "Failed to fetch Turn14 token");
}
if (!tokenRes.ok) throw new Error(tokenData.error || "Failed to fetch Turn14 token");
} catch (err) {
return json({ success: false, error: err.message });
}
@ -166,9 +152,7 @@ export const action = async ({ request }) => {
const saveRes = await admin.graphql(mutation);
const saveJson = await saveRes.json();
const errs = saveJson.data.metafieldsSet.userErrors;
if (errs.length) {
return json({ success: false, error: errs[0].message });
}
if (errs.length) return json({ success: false, error: errs[0].message });
const stateNonce = Math.random().toString(36).slice(2);
const installUrl =
@ -178,30 +162,26 @@ export const action = async ({ request }) => {
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
`&state=${stateNonce}`;
return json({
success: true,
confirmationUrl: installUrl,
creds,
});
return json({ success: true, confirmationUrl: installUrl, creds });
};
// ===== COMPONENT =====
export default function StoreCredentials() {
const { shopName, savedCreds, savedPricing, shopDomain } = useLoaderData();
const actionData = useActionData();
const [connecting, setConnecting] = useState(false);
// open Shopify install after Connect Turn14
useEffect(() => {
if (actionData?.confirmationUrl) {
window.open(actionData.confirmationUrl, "_blank", "noopener,noreferrer");
}
setConnecting(false);
}, [actionData?.confirmationUrl]);
const [clientId, setClientId] = useState(actionData?.creds?.clientId || savedCreds.clientId || "");
const [clientSecret, setClientSecret] = useState(actionData?.creds?.clientSecret || savedCreds.clientSecret || "");
const connected = actionData?.success || Boolean(savedCreds.accessToken);
// Pricing UI state (seed from loader or last action)
const initialPriceType = useMemo(
() => (actionData?.savedPricing?.priceType || savedPricing?.priceType || "map"),
[actionData?.savedPricing?.priceType, savedPricing?.priceType]
@ -216,135 +196,173 @@ export default function StoreCredentials() {
const pricingSavedOk = actionData?.pricingSaved && actionData?.success && !actionData?.error;
const pricingError = actionData?.pricingSaved === false ? actionData?.error : null;
const connectError = !actionData?.pricingSaved && actionData?.error ? actionData.error : null;
return (
<Page>
<TitleBar title="Turn14 & Shopify Connect" />
<div style={{ display: "flex", justifyContent: "center", textAlign: "center", paddingBottom: "20px" }}>
<Text as="h1" variant="headingLg">Data4Autos Turn14 Integration</Text>
{/* Dark header */}
<div style={{ background: "linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%)", borderRadius: 16, padding: "28px 32px", marginBottom: 28, color: "#fff", display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}>
<div>
<div style={{ fontSize: 22, fontWeight: 800, marginBottom: 4 }}> Settings</div>
<div style={{ fontSize: 14, opacity: 0.8 }}>{shopName}</div>
</div>
<div style={{ background: connected ? "rgba(34,197,94,0.2)" : "rgba(239,68,68,0.2)", border: `1px solid ${connected ? "rgba(34,197,94,0.4)" : "rgba(239,68,68,0.4)"}`, borderRadius: 20, padding: "6px 16px", fontSize: 13, fontWeight: 700, color: connected ? "#86efac" : "#fca5a5" }}>
{connected ? "✅ Turn14 Connected" : "⚠️ Not Connected"}
</div>
</div>
<Layout>
<Layout.Section>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<Box maxWidth="520px" width="100%" marginInline="auto">
<Card sectioned padding="600">
<BlockStack gap="500">
{/* Turn14 Connect Card */}
<Card>
<BlockStack gap="400">
<div style={{ display: "flex", alignItems: "center", gap: 12, paddingBottom: 4 }}>
<div style={{ width: 40, height: 40, background: "#eff6ff", borderRadius: 10, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 20 }}>🔌</div>
<div>
<Text variant="headingMd" as="h2">Turn14 API Credentials</Text>
<Text tone="subdued" variant="bodySm">Connect your Turn14 account to start importing products</Text>
</div>
</div>
<div style={{ height: 1, background: "#f3f4f6" }} />
{connected && (
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 10, padding: "12px 16px", display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 18 }}></span>
<div>
<div style={{ fontWeight: 700, color: "#15803d", fontSize: 14 }}>Turn14 connected successfully</div>
<div style={{ fontSize: 12, color: "#6b7280" }}>You can update credentials below at any time</div>
</div>
</div>
)}
<Form method="post" onSubmit={() => setConnecting(true)}>
<input type="hidden" name="intent" value="connect_turn14" />
<BlockStack gap="300">
<TextField
label="Turn14 Client ID"
name="client_id"
value={clientId}
onChange={setClientId}
autoComplete="off"
/>
<TextField
label="Turn14 Client Secret"
name="client_secret"
value={clientSecret}
onChange={setClientSecret}
autoComplete="off"
/>
{connectError && <InlineError message={connectError} fieldID="client_id" />}
<div style={{ paddingTop: 4 }}>
<button
type="submit"
disabled={connecting}
style={{ background: "linear-gradient(135deg, #1d4ed8, #2563eb)", border: "none", borderRadius: 8, color: "#fff", padding: "12px 28px", cursor: connecting ? "not-allowed" : "pointer", fontWeight: 700, fontSize: 15, display: "flex", alignItems: "center", gap: 8, opacity: connecting ? 0.7 : 1 }}
>
{connecting && <Spinner size="small" />}
{connecting ? "Connecting…" : connected ? "Reconnect Turn14" : "Connect Turn14"}
</button>
</div>
</BlockStack>
</Form>
</BlockStack>
</Card>
{/* Pricing Card — only shown when connected */}
{connected && (
<Card>
<BlockStack gap="400">
<TextContainer spacing="tight">
<Text variant="headingLg" align="center" fontWeight="medium">Shop: {shopName}</Text>
</TextContainer>
<div style={{ display: "flex", alignItems: "center", gap: 12, paddingBottom: 4 }}>
<div style={{ width: 40, height: 40, background: "#f0fdf4", borderRadius: 10, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 20 }}>💰</div>
<div>
<Text variant="headingMd" as="h2">Pricing Configuration</Text>
<Text tone="subdued" variant="bodySm">Control how product prices are calculated at import</Text>
</div>
</div>
<div style={{ height: 1, background: "#f3f4f6" }} />
{/* Pricing type selector */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
{[
{ value: "map", label: "MAP Pricing", desc: "Use manufacturer's MAP price as-is", icon: "🏷️" },
{ value: "percentage", label: "MAP + Margin", desc: "Add a % markup on top of MAP", icon: "📈" },
].map(opt => (
<div
key={opt.value}
onClick={() => setPriceType(opt.value)}
style={{ border: `2px solid ${priceType === opt.value ? "#2563eb" : "#e5e7eb"}`, borderRadius: 10, padding: "14px 16px", cursor: "pointer", background: priceType === opt.value ? "#eff6ff" : "#fafafa", transition: "all 0.15s" }}
>
<div style={{ fontSize: 22, marginBottom: 6 }}>{opt.icon}</div>
<div style={{ fontWeight: 700, fontSize: 14, color: priceType === opt.value ? "#1d4ed8" : "#374151" }}>{opt.label}</div>
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 3 }}>{opt.desc}</div>
</div>
))}
</div>
{/* —— TURN14 FORM —— */}
<Form method="post">
<input type="hidden" name="intent" value="connect_turn14" />
{/* <input type="hidden" name="demo_client_id" value="671f15b6973625885ee392122113d9ed54103ddd" />
<input type="hidden" name="demo_client_secret" value="98907c3b3d92235c99338bd0ce307144b2f66874" />
*/}
<BlockStack gap="400">
<BlockStack gap="200">
<TextField
label="Turn14 Client ID"
name="client_id"
value={clientId}
//value={"********************************************************"}
onChange={setClientId}
autoComplete="off"
// requiredIndicator
padding="200"
/>
</BlockStack>
<BlockStack gap="200">
<TextField
label="Turn14 Client Secret"
name="client_secret"
value={clientSecret}
//value={"********************************************************"}
onChange={setClientSecret}
autoComplete="off"
// requiredIndicator
padding="200"
/>
</BlockStack>
{/* <BlockStack gap="200">
<Button submit primary size="large" variant="primary">
Connect Turn14 With Demo Credentials
</Button>
</BlockStack> */}
<input type="hidden" name="intent" value="save_pricing" />
<input type="hidden" name="price_type" value={priceType} />
<BlockStack gap="200">
<Button submit primary size="large" variant="primary">
Connect Turn14
</Button>
</BlockStack>
<BlockStack gap="300">
{priceType === "percentage" && (
<TextField
type="number"
label="Markup Percentage"
helpText="Add this percentage on top of the MAP price."
value={String(percentage)}
onChange={(val) => setPercentage(val)}
autoComplete="off"
suffix="%"
min={0}
name="percentage"
/>
)}
{priceType === "map" && (
<input type="hidden" name="percentage" value="0" />
)}
{pricingSavedOk && (
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "10px 14px", fontSize: 14, color: "#15803d", fontWeight: 600 }}>
Pricing configuration saved
</div>
)}
{pricingError && (
<div style={{ background: "#fff1f2", border: "1px solid #fecdd3", borderRadius: 8, padding: "10px 14px", fontSize: 14, color: "#dc2626" }}>
{pricingError}
</div>
)}
<div style={{ paddingTop: 4 }}>
<button
type="submit"
style={{ background: "linear-gradient(135deg, #15803d, #16a34a)", border: "none", borderRadius: 8, color: "#fff", padding: "11px 24px", cursor: "pointer", fontWeight: 700, fontSize: 14 }}
>
Save Pricing
</button>
</div>
</BlockStack>
</Form>
{actionData?.error && !actionData?.pricingSaved && (
<TextContainer spacing="tight" style={{ marginTop: "1rem" }}>
<InlineError message={actionData.error} fieldID="client_id" />
</TextContainer>
)}
{(actionData?.success || Boolean(savedCreds.accessToken)) && (
<TextContainer spacing="tight" style={{ marginTop: "1.5rem" }}>
<p style={{ color: "green", paddingTop: "5px" }}> Turn14 connected successfully!</p>
</TextContainer>
)}
{/* —— PRICING CONFIG (direct save via this route) —— */}
{(actionData?.success || Boolean(savedCreds.accessToken)) && (
<Card title="Pricing configuration" sectioned>
<BlockStack gap="400">
<Form method="post">
<input type="hidden" name="intent" value="save_pricing" />
<Select
label="Price type"
options={[
{ label: "MAP (no change)", value: "map" },
{ label: "MAP + % profit", value: "percentage" },
]}
value={priceType}
onChange={(val) => setPriceType(val)}
name="price_type"
/>
{priceType === "percentage" && (
<TextField
type="number"
label="Percentage"
helpText="Add this percentage on top of MAP."
value={String(percentage)}
onChange={(val) => setPercentage(val)}
autoComplete="off"
suffix="%"
min={0}
name="percentage"
/>
)}
<div style={{ paddingTop: "15px", textAlign: "end" }}>
<Button submit primary variant="primary" size="large" >Save pricing</Button>
</div>
</Form>
{pricingSavedOk && (
<Banner tone="success">
<p>Pricing configuration saved.</p>
</Banner>
)}
{pricingError && (
<Banner tone="critical">
<p>{pricingError}</p>
</Banner>
)}
</BlockStack>
</Card>
)}
</BlockStack>
</Card>
</Box>
</div>
)}
{/* Info card */}
<div style={{ background: "#f8fafc", border: "1px solid #e2e8f0", borderRadius: 12, padding: "16px 20px" }}>
<div style={{ fontSize: 13, color: "#64748b", lineHeight: 1.6 }}>
<strong style={{ color: "#334155" }}>Need help?</strong> Visit the <a href="/app/help" style={{ color: "#2563eb", fontWeight: 600 }}>Help page</a> for step-by-step setup instructions, or email <a href="mailto:support@data4autos.com" style={{ color: "#2563eb", fontWeight: 600 }}>support@data4autos.com</a>.
</div>
</div>
</BlockStack>
</Layout.Section>
</Layout>
</Page>