- 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>
371 lines
15 KiB
JavaScript
371 lines
15 KiB
JavaScript
// app/routes/store-credentials.jsx
|
||
|
||
import { json } from "@remix-run/node";
|
||
import { useLoaderData, useActionData, Form } from "@remix-run/react";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import {
|
||
Page,
|
||
Layout,
|
||
Card,
|
||
TextField,
|
||
Button,
|
||
InlineError,
|
||
Text,
|
||
BlockStack,
|
||
Select,
|
||
Banner,
|
||
InlineStack,
|
||
Badge,
|
||
Spinner,
|
||
} from "@shopify/polaris";
|
||
import { TitleBar } from "@shopify/app-bridge-react";
|
||
import { authenticate } from "../shopify.server";
|
||
|
||
const SCOPES = [
|
||
"read_inventory",
|
||
"read_products",
|
||
"write_inventory",
|
||
"write_products",
|
||
"read_publications",
|
||
"write_publications",
|
||
"stagedUploadsCreate",
|
||
"read_fulfillments",
|
||
"write_files,read_files,write_fulfillments", "read_locations", "write_locations"
|
||
].join(",");
|
||
const REDIRECT_URI = "https://backend.data4autos.com/auth/callback";
|
||
const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa";
|
||
|
||
// ===== LOADER =====
|
||
export const loader = async ({ request }) => {
|
||
const { admin } = await authenticate.admin(request);
|
||
const resp = await admin.graphql(`
|
||
{
|
||
shop {
|
||
id
|
||
name
|
||
myshopifyDomain
|
||
metafield(namespace: "turn14", key: "credentials") { value }
|
||
pricing: metafield(namespace: "turn14", key: "pricing_config") { value }
|
||
}
|
||
}
|
||
`);
|
||
const { data } = await resp.json();
|
||
|
||
let creds = {};
|
||
if (data.shop.metafield?.value) {
|
||
try { creds = JSON.parse(data.shop.metafield.value); } catch { }
|
||
}
|
||
let savedPricing = { priceType: "map", percentage: 0 };
|
||
if (data.shop.pricing?.value) {
|
||
try {
|
||
const p = JSON.parse(data.shop.pricing.value);
|
||
savedPricing.priceType = (p.priceType || "map").toLowerCase();
|
||
savedPricing.percentage = Number(p.percentage) || 0;
|
||
} catch { }
|
||
}
|
||
|
||
return json({
|
||
shopName: data.shop.name,
|
||
shopDomain: data.shop.myshopifyDomain,
|
||
shopId: data.shop.id,
|
||
savedCreds: creds,
|
||
savedPricing,
|
||
});
|
||
};
|
||
|
||
// ===== ACTION =====
|
||
export const action = async ({ request }) => {
|
||
const formData = await request.formData();
|
||
const intent = formData.get("intent"); // "connect_turn14" | "save_pricing"
|
||
const { admin } = await authenticate.admin(request);
|
||
|
||
const shopResp = await admin.graphql(`{ shop { id name myshopifyDomain } }`);
|
||
const shopJson = await shopResp.json();
|
||
const shopId = shopJson.data.shop.id;
|
||
const shopName = shopJson.data.shop.name;
|
||
const shopDomain = shopJson.data.shop.myshopifyDomain;
|
||
|
||
if (intent === "save_pricing") {
|
||
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 {
|
||
metafieldsSet(metafields: [{
|
||
ownerId: "${shopId}",
|
||
namespace: "turn14",
|
||
key: "pricing_config",
|
||
type: "json",
|
||
value: "${JSON.stringify(cfg).replace(/"/g, '\\"')}"
|
||
}]) {
|
||
userErrors { message }
|
||
}
|
||
}
|
||
`;
|
||
const saveRes = await admin.graphql(mutation);
|
||
const saveJson = await saveRes.json();
|
||
const errs = saveJson.data.metafieldsSet.userErrors;
|
||
if (errs.length) {
|
||
return json({ success: false, pricingSaved: false, error: errs[0].message });
|
||
}
|
||
return json({ success: true, pricingSaved: true, savedPricing: cfg });
|
||
}
|
||
|
||
// connect Turn14 flow
|
||
const clientId = formData.get("client_id");
|
||
const clientSecret = formData.get("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 }),
|
||
});
|
||
tokenData = await tokenRes.json();
|
||
if (!tokenRes.ok) throw new Error(tokenData.error || "Failed to fetch Turn14 token");
|
||
} catch (err) {
|
||
return json({ success: false, error: err.message });
|
||
}
|
||
|
||
const creds = {
|
||
clientId,
|
||
clientSecret,
|
||
accessToken: tokenData.access_token,
|
||
expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(),
|
||
};
|
||
const mutation = `
|
||
mutation {
|
||
metafieldsSet(metafields: [{
|
||
ownerId: "${shopId}",
|
||
namespace: "turn14",
|
||
key: "credentials",
|
||
type: "json",
|
||
value: "${JSON.stringify(creds).replace(/"/g, '\\"')}"
|
||
}]) {
|
||
userErrors { message }
|
||
}
|
||
}
|
||
`;
|
||
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 });
|
||
|
||
const stateNonce = Math.random().toString(36).slice(2);
|
||
const installUrl =
|
||
`https://${shopDomain}/admin/oauth/authorize` +
|
||
`?client_id=${CLIENT_ID}` +
|
||
`&scope=${SCOPES}` +
|
||
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
|
||
`&state=${stateNonce}`;
|
||
|
||
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);
|
||
|
||
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);
|
||
|
||
const initialPriceType = useMemo(
|
||
() => (actionData?.savedPricing?.priceType || savedPricing?.priceType || "map"),
|
||
[actionData?.savedPricing?.priceType, savedPricing?.priceType]
|
||
);
|
||
const initialPercentage = useMemo(
|
||
() => Number(actionData?.savedPricing?.percentage ?? savedPricing?.percentage ?? 0),
|
||
[actionData?.savedPricing?.percentage, savedPricing?.percentage]
|
||
);
|
||
|
||
const [priceType, setPriceType] = useState(initialPriceType);
|
||
const [percentage, setPercentage] = useState(initialPercentage);
|
||
|
||
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" />
|
||
|
||
{/* 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>
|
||
<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">
|
||
<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>
|
||
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="save_pricing" />
|
||
<input type="hidden" name="price_type" value={priceType} />
|
||
|
||
<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>
|
||
</BlockStack>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 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>
|
||
);
|
||
}
|