- 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>
364 lines
20 KiB
JavaScript
364 lines
20 KiB
JavaScript
import React, { useMemo, useState } from "react";
|
|
import { json } from "@remix-run/node";
|
|
import { useLoaderData, useActionData, useSubmit, Form, useNavigate } from "@remix-run/react";
|
|
import {
|
|
Page,
|
|
Modal,
|
|
TextField,
|
|
BlockStack,
|
|
InlineStack,
|
|
ChoiceList,
|
|
Banner,
|
|
Button,
|
|
Divider,
|
|
} from "@shopify/polaris";
|
|
import { TitleBar } from "@shopify/app-bridge-react";
|
|
import { authenticate } from "../shopify.server";
|
|
|
|
/* ─── pricing constants ────────────────────────────────────────────────────── */
|
|
const PLAN_NAME = "Starter Sync";
|
|
const MONTHLY_AMOUNT = 79;
|
|
const ANNUAL_AMOUNT = 790;
|
|
const TRIAL_DAYS = 14;
|
|
const ALLOWED_STATUSES = ["ACTIVE", "TRIAL"];
|
|
|
|
/* ─── helpers ──────────────────────────────────────────────────────────────── */
|
|
function formatMoney(amount, currencyCode = "USD") {
|
|
if (amount == null) return "N/A";
|
|
return `${currencyCode} ${Number(amount).toFixed(2)}`;
|
|
}
|
|
|
|
function getIntervalLabel(interval) {
|
|
if (interval === "ANNUAL") return "Every 12 months";
|
|
if (interval === "EVERY_30_DAYS") return "Every 30 days";
|
|
return interval || "N/A";
|
|
}
|
|
|
|
function getStatusTone(status) {
|
|
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 ────────────────────────────────────────────────────────────────── */
|
|
export const loader = async ({ request }) => {
|
|
const { admin, session } = await authenticate.admin(request);
|
|
const shop = session.shop;
|
|
|
|
const resp = await admin.graphql(`
|
|
query CurrentSubscriptionDetails {
|
|
currentAppInstallation {
|
|
activeSubscriptions {
|
|
id name status test createdAt trialDays currentPeriodEnd
|
|
lineItems {
|
|
id
|
|
plan {
|
|
pricingDetails {
|
|
__typename
|
|
... on AppRecurringPricing {
|
|
interval
|
|
price { amount currencyCode }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`);
|
|
|
|
const result = await resp.json();
|
|
const subscriptions = result?.data?.currentAppInstallation?.activeSubscriptions || [];
|
|
const subscription = subscriptions.find((sub) => ALLOWED_STATUSES.includes(sub.status)) || subscriptions[0] || null;
|
|
const recurringPricing = subscription?.lineItems?.find((item) => item?.plan?.pricingDetails?.__typename === "AppRecurringPricing")?.plan?.pricingDetails || null;
|
|
const isSubscribed = !!subscription && ALLOWED_STATUSES.includes(subscription.status);
|
|
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: !isSubscribed, shop, isSubscribed, subscription: subscriptionDetails, allSubscriptions: subscriptions });
|
|
};
|
|
|
|
/* ─── 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`;
|
|
|
|
const createRes = await admin.graphql(`
|
|
mutation CreateSubscription {
|
|
appSubscriptionCreate(
|
|
name: "${PLAN_NAME} - ${cadence === "ANNUAL" ? "Annual" : "Monthly"}"
|
|
returnUrl: "${returnUrl}"
|
|
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 }
|
|
}
|
|
}
|
|
`);
|
|
|
|
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({ confirmationUrl: url });
|
|
};
|
|
|
|
/* ─── 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");
|
|
|
|
const subscription = loaderData?.subscription || null;
|
|
const shop = loaderData?.shop || "";
|
|
const isSubscribed = loaderData?.isSubscribed || false;
|
|
|
|
const hasConfirmationUrl = Boolean(actionData?.confirmationUrl);
|
|
const errors = actionData?.errors || [];
|
|
const shopDomain = (shop || "").split(".")[0];
|
|
|
|
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 || subscription.status !== "TRIAL") return null;
|
|
const created = new Date(subscription.createdAt);
|
|
const trialEnd = new Date(created);
|
|
trialEnd.setDate(trialEnd.getDate() + subscription.trialDays);
|
|
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 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 statusStyle = getStatusTone(subscription?.status || "PENDING");
|
|
|
|
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" />
|
|
|
|
{/* 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>
|
|
|
|
{/* 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>
|
|
|
|
{/* 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>
|
|
|
|
{/* 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={() => 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" });
|
|
}
|
|
},
|
|
}
|
|
}
|
|
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="Couldn't create subscription" tone="critical">
|
|
<ul style={{ margin: 0, paddingLeft: "1rem" }}>
|
|
{errors.map((e, i) => <li key={i}>{e}</li>)}
|
|
</ul>
|
|
</Banner>
|
|
)}
|
|
|
|
{!hasConfirmationUrl && (
|
|
<>
|
|
<TextField label="Subscription Status" value={displayStatus} readOnly />
|
|
<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 />}
|
|
<Divider />
|
|
{!isSubscribed && (
|
|
<>
|
|
<div style={{ fontWeight: 700, fontSize: 15, marginBottom: 4 }}>Choose your billing plan</div>
|
|
<ChoiceList
|
|
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." },
|
|
]}
|
|
selected={[cadence]}
|
|
onChange={(selected) => {
|
|
const next = Array.isArray(selected) && selected[0] ? selected[0] : "MONTHLY";
|
|
setCadence(next);
|
|
const hidden = document.getElementById("cadence-field");
|
|
if (hidden) hidden.value = next;
|
|
}}
|
|
allowMultiple={false}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{hasConfirmationUrl && (
|
|
<BlockStack gap="300">
|
|
<Banner title="Almost there!" tone="success">
|
|
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">
|
|
Open Billing Confirmation
|
|
</Button>
|
|
<a href={actionData.confirmationUrl} target="_blank" rel="noopener noreferrer" style={{ wordBreak: "break-all", fontSize: 12, color: "#6b7280" }}>
|
|
{actionData.confirmationUrl}
|
|
</a>
|
|
</BlockStack>
|
|
)}
|
|
</BlockStack>
|
|
</Modal.Section>
|
|
</Form>
|
|
</Modal>
|
|
</Page>
|
|
);
|
|
}
|