729 lines
21 KiB
JavaScript
729 lines
21 KiB
JavaScript
import { json } from "@remix-run/node";
|
||
import { useLoaderData, Form, useActionData, useNavigate } from "@remix-run/react";
|
||
import {
|
||
Page,
|
||
Layout,
|
||
Card,
|
||
TextField,
|
||
Checkbox,
|
||
Button,
|
||
Thumbnail,
|
||
Spinner,
|
||
Toast,
|
||
Frame,
|
||
Text,
|
||
Banner,
|
||
InlineStack,
|
||
} from "@shopify/polaris";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { TitleBar } from "@shopify/app-bridge-react";
|
||
import { getTurn14AccessTokenFromMetafield } from "../utils/turn14Token.server";
|
||
import { authenticate } from "../shopify.server";
|
||
|
||
const PLAN_NAME = "Starter Sync";
|
||
const ALLOWED_STATUSES = ["ACTIVE", "TRIAL"];
|
||
|
||
async function checkShopExists(shop) {
|
||
try {
|
||
const resp = await fetch(
|
||
`https://backend.data4autos.com/checkisshopdataexists/${shop}`
|
||
);
|
||
const data = await resp.json();
|
||
return data.status === 1;
|
||
} catch (err) {
|
||
console.error("Error checking shop:", err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function getIntervalLabel(interval) {
|
||
switch (interval) {
|
||
case "ANNUAL":
|
||
return "Every 12 months";
|
||
case "EVERY_30_DAYS":
|
||
return "Every 30 days";
|
||
default:
|
||
return interval || "N/A";
|
||
}
|
||
}
|
||
|
||
function formatMoney(amount, currencyCode = "USD") {
|
||
if (amount == null) return "N/A";
|
||
return `${currencyCode} ${Number(amount).toFixed(2)}`;
|
||
}
|
||
|
||
function formatDate(date) {
|
||
if (!date) return "N/A";
|
||
return new Date(date).toLocaleDateString(undefined, {
|
||
year: "numeric",
|
||
month: "short",
|
||
day: "numeric",
|
||
});
|
||
}
|
||
|
||
async function getSubscriptionDetails(request) {
|
||
const { admin, session } = await authenticate.admin(request);
|
||
const shop = session.shop;
|
||
|
||
const resp = await admin.graphql(`
|
||
query CurrentSubscriptionDetails {
|
||
currentAppInstallation {
|
||
activeSubscriptions {
|
||
id
|
||
name
|
||
status
|
||
test
|
||
createdAt
|
||
trialDays
|
||
currentPeriodEnd
|
||
lineItems {
|
||
id
|
||
plan {
|
||
pricingDetails {
|
||
__typename
|
||
... on AppRecurringPricing {
|
||
interval
|
||
price {
|
||
amount
|
||
currencyCode
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
`);
|
||
|
||
const result = await resp.json();
|
||
|
||
const subscriptions =
|
||
result?.data?.currentAppInstallation?.activeSubscriptions || [];
|
||
|
||
const subscription =
|
||
subscriptions.find((sub) => ALLOWED_STATUSES.includes(sub.status)) ||
|
||
subscriptions[0] ||
|
||
null;
|
||
|
||
const recurringPricing =
|
||
subscription?.lineItems?.find(
|
||
(item) =>
|
||
item?.plan?.pricingDetails?.__typename === "AppRecurringPricing"
|
||
)?.plan?.pricingDetails || null;
|
||
|
||
const isSubscribed =
|
||
!!subscription && ALLOWED_STATUSES.includes(subscription.status);
|
||
|
||
return {
|
||
shop,
|
||
isSubscribed,
|
||
subscription: subscription
|
||
? {
|
||
id: subscription.id,
|
||
name: subscription.name || PLAN_NAME,
|
||
status: subscription.status,
|
||
test: subscription.test ?? false,
|
||
createdAt: subscription.createdAt,
|
||
trialDays: subscription.trialDays ?? 0,
|
||
currentPeriodEnd: subscription.currentPeriodEnd,
|
||
interval: recurringPricing?.interval || null,
|
||
priceAmount: recurringPricing?.price?.amount || null,
|
||
currencyCode: recurringPricing?.price?.currencyCode || "USD",
|
||
}
|
||
: null,
|
||
};
|
||
}
|
||
|
||
export const loader = async ({ request }) => {
|
||
console.log("🚀 Loader started");
|
||
|
||
let admin, session, shop;
|
||
|
||
try {
|
||
const authResult = await authenticate.admin(request);
|
||
admin = authResult.admin;
|
||
session = authResult.session;
|
||
shop = session?.shop;
|
||
|
||
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,
|
||
});
|
||
}
|
||
|
||
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,
|
||
});
|
||
}
|
||
|
||
let brandJson;
|
||
try {
|
||
const brandRes = await fetch("https://turn14.data4autos.com/v1/brands", {
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
});
|
||
|
||
brandJson = await brandRes.json();
|
||
|
||
if (!brandRes.ok) {
|
||
return json({
|
||
brands: [],
|
||
collections: [],
|
||
selectedBrandsFromShopify: [],
|
||
shop,
|
||
error: brandJson?.error || "Failed to fetch brands",
|
||
isSubscribed,
|
||
subscription,
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error("❌ Exception while fetching Turn14 brands:", err);
|
||
return json({
|
||
brands: [],
|
||
collections: [],
|
||
selectedBrandsFromShopify: [],
|
||
shop,
|
||
error: "Turn14 brands fetch crashed",
|
||
isSubscribed,
|
||
subscription,
|
||
});
|
||
}
|
||
|
||
let collections = [];
|
||
try {
|
||
const gqlRaw = await admin.graphql(`
|
||
{
|
||
collections(first: 100) {
|
||
edges {
|
||
node {
|
||
id
|
||
title
|
||
}
|
||
}
|
||
}
|
||
}
|
||
`);
|
||
|
||
const gql = await gqlRaw.json();
|
||
collections = gql?.data?.collections?.edges?.map((e) => e.node) || [];
|
||
} catch (err) {
|
||
console.error("❌ Error fetching Shopify collections:", err);
|
||
}
|
||
|
||
let selectedBrands = [];
|
||
|
||
try {
|
||
const res = await admin.graphql(`
|
||
{
|
||
shop {
|
||
metafield(namespace: "turn14", key: "selected_brands") {
|
||
value
|
||
}
|
||
}
|
||
}
|
||
`);
|
||
|
||
const data = await res.json();
|
||
const rawValue = data?.data?.shop?.metafield?.value;
|
||
|
||
if (rawValue) {
|
||
selectedBrands = JSON.parse(rawValue);
|
||
}
|
||
} catch (err) {
|
||
console.error("❌ Failed parsing selected_brands metafield:", err);
|
||
}
|
||
|
||
return json({
|
||
brands: brandJson?.data || [],
|
||
collections,
|
||
selectedBrandsFromShopify: selectedBrands,
|
||
shop,
|
||
isSubscribed,
|
||
subscription,
|
||
});
|
||
};
|
||
|
||
export const action = async ({ request }) => {
|
||
const { isSubscribed } = await getSubscriptionDetails(request);
|
||
|
||
if (!isSubscribed) {
|
||
return json(
|
||
{
|
||
error:
|
||
"An active subscription or free trial is required to save brand collections.",
|
||
},
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
|
||
const formData = await request.formData();
|
||
const selectedBrands = JSON.parse(formData.get("selectedBrands") || "[]");
|
||
const selectedOldBrands = JSON.parse(formData.get("selectedOldBrands") || "[]");
|
||
|
||
const { session } = await authenticate.admin(request);
|
||
const shop = session.shop;
|
||
|
||
selectedBrands.forEach((brand) => {
|
||
delete brand.pricegroups;
|
||
});
|
||
|
||
selectedOldBrands.forEach((brand) => {
|
||
delete brand.pricegroups;
|
||
});
|
||
|
||
const resp = await fetch("https://backend.data4autos.com/managebrands", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"shop-domain": shop,
|
||
},
|
||
body: JSON.stringify({ shop, selectedBrands, selectedOldBrands }),
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
const err = await resp.text();
|
||
return json({ error: err }, { status: resp.status });
|
||
}
|
||
|
||
const { processId, status } = await resp.json();
|
||
return json({ processId, status });
|
||
};
|
||
|
||
export default function BrandsPage() {
|
||
const navigate = useNavigate();
|
||
const {
|
||
brands = [],
|
||
selectedBrandsFromShopify = [],
|
||
shop = "",
|
||
error,
|
||
isSubscribed = false,
|
||
subscription = null,
|
||
} = useLoaderData() || {};
|
||
|
||
const actionData = useActionData() || {};
|
||
|
||
const [selectedIdsold, setSelectedIdsold] = useState([]);
|
||
const [selectedIds, setSelectedIds] = useState(() =>
|
||
(selectedBrandsFromShopify ?? []).map((b) => b.id)
|
||
);
|
||
const [search, setSearch] = useState("");
|
||
const [filteredBrands, setFilteredBrands] = useState(brands);
|
||
const [toastActive, setToastActive] = useState(false);
|
||
const [polling, setPolling] = useState(false);
|
||
const [status, setStatus] = useState(actionData.status || "");
|
||
const [Turn14Enabled, setTurn14Enabled] = useState(null);
|
||
|
||
useEffect(() => {
|
||
if (!shop) return;
|
||
|
||
(async () => {
|
||
const result = await checkShopExists(shop);
|
||
setTurn14Enabled(result);
|
||
})();
|
||
}, [shop]);
|
||
|
||
useEffect(() => {
|
||
setSelectedIdsold(selectedIds);
|
||
}, [toastActive]);
|
||
|
||
useEffect(() => {
|
||
const term = search.toLowerCase();
|
||
setFilteredBrands(brands.filter((b) => b.name.toLowerCase().includes(term)));
|
||
}, [search, brands]);
|
||
|
||
useEffect(() => {
|
||
if (actionData.status) {
|
||
setStatus(actionData.status);
|
||
setToastActive(true);
|
||
}
|
||
}, [actionData.status]);
|
||
|
||
const checkStatus = async () => {
|
||
if (!actionData.processId) return;
|
||
setPolling(true);
|
||
const resp = await fetch(
|
||
`https://backend.data4autos.com/managebrands/status/${actionData.processId}`,
|
||
{ headers: { "shop-domain": window.shopify.shop || "" } }
|
||
);
|
||
const jsonBody = await resp.json();
|
||
setStatus(
|
||
jsonBody.status + (jsonBody.detail ? ` (${jsonBody.detail})` : "")
|
||
);
|
||
setPolling(false);
|
||
};
|
||
|
||
const toggleSelect = (id) => {
|
||
if (!isSubscribed) return;
|
||
|
||
setSelectedIds((prev) =>
|
||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
|
||
);
|
||
};
|
||
|
||
const allFilteredSelected =
|
||
filteredBrands.length > 0 &&
|
||
filteredBrands.every((b) => selectedIds.includes(b.id));
|
||
|
||
const toggleSelectAll = () => {
|
||
if (!isSubscribed) return;
|
||
|
||
const ids = filteredBrands.map((b) => b.id);
|
||
if (allFilteredSelected) {
|
||
setSelectedIds((prev) => prev.filter((id) => !ids.includes(id)));
|
||
} else {
|
||
setSelectedIds((prev) => Array.from(new Set([...prev, ...ids])));
|
||
}
|
||
};
|
||
|
||
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;
|
||
|
||
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);
|
||
}, [subscription]);
|
||
|
||
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 isn’t connected yet
|
||
</Text>
|
||
<div style={{ marginTop: 8 }}>
|
||
<Text as="p" variant="bodyMd">
|
||
This shop hasn’t 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, you’ll 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>
|
||
</Page>
|
||
</Frame>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Frame>
|
||
<Page fullWidth>
|
||
<TitleBar title="Data4Autos Turn14 Integration" background="primary" />
|
||
|
||
<div style={{ marginBottom: 16 }}>
|
||
<Text as="h1" variant="headingLg">
|
||
Data4Autos Turn14 Brands List
|
||
</Text>
|
||
</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>
|
||
)}
|
||
|
||
{error && (
|
||
<Layout.Section>
|
||
<Banner title="Error" tone="critical">
|
||
<p>{error}</p>
|
||
</Banner>
|
||
</Layout.Section>
|
||
)}
|
||
|
||
{actionData?.error && (
|
||
<Layout.Section>
|
||
<Banner title="Action blocked" tone="critical">
|
||
<p>{actionData.error}</p>
|
||
</Banner>
|
||
</Layout.Section>
|
||
)}
|
||
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</Layout.Section>
|
||
|
||
<Layout.Section>
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||
gap: 16,
|
||
}}
|
||
>
|
||
{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>
|
||
|
||
<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>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</Layout.Section>
|
||
</Layout>
|
||
|
||
{toastMarkup}
|
||
</Page>
|
||
</Frame>
|
||
);
|
||
} |