2026-04-17 21:19:17 +00:00

729 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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>
</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>
);
}