This commit is contained in:
Manesh 2026-04-17 21:19:17 +00:00
parent 688855fe48
commit 4199fd8a4a
16 changed files with 4315 additions and 1024 deletions

View File

@ -0,0 +1,518 @@
import React, { useMemo, useState } from "react";
import { json } from "@remix-run/node";
import { useLoaderData, useActionData, useSubmit, Form } from "@remix-run/react";
import {
Page,
Layout,
Card,
BlockStack,
Text,
InlineStack,
Image,
Divider,
Button,
Modal,
TextField,
Box,
Banner,
ChoiceList,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import data4autosLogo from "../assets/data4autos_logo.png"; // ensure this exists
import turn14DistributorLogo from "../assets/turn14-logo.png";
import { authenticate } from "../shopify.server";
/* ===========================
PRICING (single source of truth)
=========================== */
const PLAN_NAME = "Starter Sync";
const MONTHLY_AMOUNT = 79; // USD
const ANNUAL_AMOUNT = 790; // USD ( 2 months off)
const TRIAL_DAYS = 14;
/* ===========================
LOADER: check subscription
=========================== */
export const loader = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const resp = await admin.graphql(`
query {
currentAppInstallation {
activeSubscriptions {
id
status
trialDays
createdAt
currentPeriodEnd
}
}
}
`);
const result = await resp.json();
const subscription =
(result &&
result.data &&
result.data.currentAppInstallation &&
result.data.currentAppInstallation.activeSubscriptions &&
result.data.currentAppInstallation.activeSubscriptions[0]) || null;
const { session } = await authenticate.admin(request);
const shop = session.shop;
console.log(`Loader subscription check: ${JSON.stringify(subscription)}`);
if (!subscription) {
return json({ redirectToBilling: true, subscription: null, shop });
}
if (shop == "racewerksengg.myshopify.com") {
return json({ redirectToBilling: false, subscription, shop });
}
if (subscription.status !== "ACTIVE" && subscription.status !== "TRIAL") {
return json({ redirectToBilling: true, subscription, shop });
}
return json({ redirectToBilling: false, subscription, shop });
};
/* ===========================
ACTION: create subscription (Monthly or Annual)
=========================== */
export const action = async ({ request }) => {
const { admin } = 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 { session } = await authenticate.admin(request);
const shop = session.shop;
const shopDomain = (shop || "").split(".")[0];
const origin = new URL(request.url).origin;
const returnUrl = `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app`;
const createRes = await admin.graphql(`
mutation {
appSubscriptionCreate(
name: "${PLAN_NAME}"
returnUrl: "${returnUrl}"
lineItems: [
{
plan: {
appRecurringPricingDetails: {
price: { amount: ${amount}, currencyCode: USD }
interval: ${interval}
}
}
}
]
trialDays: ${TRIAL_DAYS}
test: false
) {
confirmationUrl
appSubscription { id status trialDays }
userErrors { field message }
}
}
`);
const data = await createRes.json();
const url =
data && data.data && data.data.appSubscriptionCreate
? data.data.appSubscriptionCreate.confirmationUrl
: null;
const userErrors =
(data &&
data.data &&
data.data.appSubscriptionCreate &&
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 });
};
/* ===========================
PAGE
=========================== */
export default function Index() {
const actionData = useActionData();
const loaderData = useLoaderData();
const submit = useSubmit();
const [activeModal, setActiveModal] = useState(false);
const subscription = loaderData?.subscription || null;
const shop = loaderData?.shop || "";
console.log(`Page ${shop} subscription check: ${JSON.stringify(subscription)}`);
// Cadence selection for the billing action
const [cadence, setCadence] = useState("MONTHLY");
const hasConfirmationUrl = Boolean(actionData && actionData.confirmationUrl);
const errors = (actionData && 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);
/* ===========================
Helpers & preview model
=========================== */
const formatDate = (d) =>
new Date(d).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
// We show "preview" values while the user is picking a cadence and before they confirm billing.
// After approval + reload, we show Shopify's real values from loaderData.
const isPreview =
!subscription || loaderData?.redirectToBilling || !hasConfirmationUrl;
// Preview trial end is TRIAL_DAYS from "now" (or from subscription.createdAt if present)
const previewBase = subscription?.createdAt ? new Date(subscription.createdAt) : new Date();
const previewTrialEnd = new Date(previewBase);
previewTrialEnd.setDate(previewTrialEnd.getDate() + TRIAL_DAYS);
// Top fields (readOnly) will reflect selection in preview mode
const displayStatus = subscription
? subscription.status
: "Not active — will be created at checkout";
const displayPlan = `${PLAN_NAME}${cadence === "ANNUAL" ? "Annual" : "Monthly"}`;
const displayNextRenewal = isPreview
? `${formatDate(previewTrialEnd)} (after ${TRIAL_DAYS}-day trial)`
: (subscription?.currentPeriodEnd ? formatDate(subscription.currentPeriodEnd) : "N/A");
// Compute trial days left accurately (if TRIAL)
const trialDaysLeft = useMemo(() => {
if (!subscription?.trialDays || !subscription?.createdAt) 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?.trialDays, subscription?.createdAt]);
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>
<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>
<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 && loaderData.redirectToBilling
? "Proceed to Billing"
: "View Subscription Details"}
</Button>
</BlockStack>
</BlockStack>
</Card>
</Layout.Section>
</Layout>
{/* ===========================
MODAL
=========================== */}
<Modal
open={activeModal}
onClose={closeModal}
title="Subscription Details"
primaryAction={
hasConfirmationUrl
? 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 && typeof form.submit === "function") {
const hidden = document.getElementById("cadence-field");
if (hidden) hidden.value = cadence;
submit(form);
}
},
}
}
secondaryActions={[{ content: "Close", onAction: closeModal }]}
>
<Form id="billing-form" method="post">
{/* Keep hidden input for server action compatibility */}
<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">
<ul style={{ margin: 0, paddingLeft: "1rem" }}>
{errors.map((e, i) => (
<li key={i}>{e}</li>
))}
</ul>
</Banner>
)}
{!hasConfirmationUrl && (
<>
{/* ===== Top read-only fields (now preview-aware) ===== */}
<TextField label="Subscription Status" value={displayStatus} readOnly />
<TextField
label="Plan"
value={displayPlan}
helpText={
subscription
? "Showing your selection; actual plan updates after checkout."
: "Preview of the plan that will be created at checkout."
}
readOnly
/>
<TextField
label="Billing Interval"
value={cadence === "ANNUAL" ? "Every 12 months" : "Every 30 days"}
readOnly
/>
<TextField
label="Trial"
value={`${TRIAL_DAYS}-day free trial`}
readOnly
/>
<TextField
label="Trial Days Left"
value={
subscription && subscription.status === "TRIAL" && trialDaysLeft != null
? `${trialDaysLeft} days left`
: subscription && subscription.status === "TRIAL"
? "Trial active"
: "N/A"
}
readOnly
/>
<TextField
label="Next Renewal / Period End"
value={displayNextRenewal}
readOnly
/>
<Divider />
{/* ===== Live preview of the exact price that will apply after trial ===== */}
<Text as="h3" variant="headingMd">
Your selection
</Text>
<InlineStack gap="300">
<TextField
label="Selected cadence"
value={cadence === "ANNUAL" ? "Annual" : "Monthly"}
readOnly
/>
<TextField
label="Price after trial"
value={cadence === "ANNUAL" ? `$${ANNUAL_AMOUNT}/yr` : `$${MONTHLY_AMOUNT}/mo`}
readOnly
/>
</InlineStack>
{/* ===== Radio-style plan selector ===== */}
<ChoiceList
title="Choose your 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 Shopifys billing confirmation in a
new tab. If it doesnt open, copy the link and open it manually.
</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" }}
>
{actionData.confirmationUrl}
</a>
</BlockStack>
)}
</BlockStack>
</Modal.Section>
</Form>
</Modal>
</Page>
);
}

View File

@ -16,9 +16,10 @@ import {
Box, Box,
Banner, Banner,
ChoiceList, ChoiceList,
Badge,
} from "@shopify/polaris"; } from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react"; import { TitleBar } from "@shopify/app-bridge-react";
import data4autosLogo from "../assets/data4autos_logo.png"; // ensure this exists import data4autosLogo from "../assets/data4autos_logo.png";
import turn14DistributorLogo from "../assets/turn14-logo.png"; import turn14DistributorLogo from "../assets/turn14-logo.png";
import { authenticate } from "../shopify.server"; import { authenticate } from "../shopify.server";
@ -26,82 +27,161 @@ import { authenticate } from "../shopify.server";
PRICING (single source of truth) PRICING (single source of truth)
=========================== */ =========================== */
const PLAN_NAME = "Starter Sync"; const PLAN_NAME = "Starter Sync";
const MONTHLY_AMOUNT = 79; // USD const MONTHLY_AMOUNT = 79; // USD
const ANNUAL_AMOUNT = 790; // USD ( 2 months off) const ANNUAL_AMOUNT = 790; // USD
const TRIAL_DAYS = 14; const TRIAL_DAYS = 14;
const ALLOWED_STATUSES = ["ACTIVE", "TRIAL"];
/* =========================== /* ===========================
LOADER: check subscription 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";
}
}
function getStatusTone(status) {
switch (status) {
case "ACTIVE":
return "success";
case "TRIAL":
return "info";
case "CANCELLED":
case "EXPIRED":
case "DECLINED":
return "critical";
default:
return "attention";
}
}
/* ===========================
LOADER: fetch real subscription details
=========================== */ =========================== */
export const loader = async ({ request }) => { export const loader = async ({ request }) => {
const { admin } = await authenticate.admin(request); const { admin, session } = await authenticate.admin(request);
const shop = session.shop;
const resp = await admin.graphql(` const resp = await admin.graphql(`
query { query CurrentSubscriptionDetails {
currentAppInstallation { currentAppInstallation {
activeSubscriptions { activeSubscriptions {
id id
name
status status
trialDays test
createdAt createdAt
trialDays
currentPeriodEnd currentPeriodEnd
lineItems {
id
plan {
pricingDetails {
__typename
... on AppRecurringPricing {
interval
price {
amount
currencyCode
}
}
}
}
}
} }
} }
} }
`); `);
const result = await resp.json(); const result = await resp.json();
const subscriptions =
result?.data?.currentAppInstallation?.activeSubscriptions || [];
const subscription = const subscription =
(result && subscriptions.find((sub) => ALLOWED_STATUSES.includes(sub.status)) ||
result.data && subscriptions[0] ||
result.data.currentAppInstallation && null;
result.data.currentAppInstallation.activeSubscriptions &&
result.data.currentAppInstallation.activeSubscriptions[0]) || null;
const { session } = await authenticate.admin(request); const recurringPricing =
const shop = session.shop; subscription?.lineItems?.find(
(item) =>
item?.plan?.pricingDetails?.__typename === "AppRecurringPricing"
)?.plan?.pricingDetails || null;
if (!subscription) { const isSubscribed =
return json({ redirectToBilling: true, subscription: null, shop }); !!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)}`
);
if (shop === "racewerksengg.myshopify.com") {
return json({
redirectToBilling: false,
shop,
isSubscribed: true,
subscription: subscriptionDetails,
allSubscriptions: subscriptions,
});
} }
if (subscription.status !== "ACTIVE" && subscription.status !== "TRIAL") { return json({
return json({ redirectToBilling: true, subscription, shop }); redirectToBilling: !isSubscribed,
} shop,
isSubscribed,
return json({ redirectToBilling: false, subscription, shop }); subscription: subscriptionDetails,
allSubscriptions: subscriptions,
});
}; };
/* =========================== /* ===========================
ACTION: create subscription (Monthly or Annual) ACTION: create subscription
=========================== */ =========================== */
export const action = async ({ request }) => { export const action = async ({ request }) => {
const { admin } = await authenticate.admin(request); const { admin, session } = await authenticate.admin(request);
const form = await request.formData(); const form = await request.formData();
const rawCadence = form.get("cadence"); const rawCadence = form.get("cadence");
const cadence = rawCadence === "ANNUAL" ? "ANNUAL" : "MONTHLY"; const cadence = rawCadence === "ANNUAL" ? "ANNUAL" : "MONTHLY";
const interval = cadence === "ANNUAL" ? "ANNUAL" : "EVERY_30_DAYS"; const interval = cadence === "ANNUAL" ? "ANNUAL" : "EVERY_30_DAYS";
const amount = cadence === "ANNUAL" ? ANNUAL_AMOUNT : MONTHLY_AMOUNT; const amount = cadence === "ANNUAL" ? ANNUAL_AMOUNT : MONTHLY_AMOUNT;
const { session } = await authenticate.admin(request);
const shop = session.shop; const shop = session.shop;
const shopDomain = (shop || "").split(".")[0]; const shopDomain = (shop || "").split(".")[0];
const origin = new URL(request.url).origin;
const returnUrl = `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app`; const returnUrl = `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app`;
const createRes = await admin.graphql(` const createRes = await admin.graphql(`
mutation { mutation CreateSubscription {
appSubscriptionCreate( appSubscriptionCreate(
name: "${PLAN_NAME}" name: "${PLAN_NAME} - ${cadence === "ANNUAL" ? "Annual" : "Monthly"}"
returnUrl: "${returnUrl}" returnUrl: "${returnUrl}"
lineItems: [ lineItems: [
{ {
@ -114,29 +194,29 @@ export const action = async ({ request }) => {
} }
] ]
trialDays: ${TRIAL_DAYS} trialDays: ${TRIAL_DAYS}
replacementBehavior: STANDARD
test: false test: false
) { ) {
confirmationUrl confirmationUrl
appSubscription { id status trialDays } appSubscription {
userErrors { field message } id
name
status
trialDays
}
userErrors {
field
message
}
} }
} }
`); `);
const data = await createRes.json(); const data = await createRes.json();
const url = const url = data?.data?.appSubscriptionCreate?.confirmationUrl || null;
data && data.data && data.data.appSubscriptionCreate const userErrors = data?.data?.appSubscriptionCreate?.userErrors || [];
? data.data.appSubscriptionCreate.confirmationUrl const topLevelErrors = data?.errors || [];
: null;
const userErrors =
(data &&
data.data &&
data.data.appSubscriptionCreate &&
data.data.appSubscriptionCreate.userErrors) || [];
const topLevelErrors = data.errors || [];
if (!url || userErrors.length || topLevelErrors.length) { if (!url || userErrors.length || topLevelErrors.length) {
return json( return json(
@ -162,14 +242,14 @@ export default function Index() {
const loaderData = useLoaderData(); const loaderData = useLoaderData();
const submit = useSubmit(); const submit = useSubmit();
const [activeModal, setActiveModal] = useState(false); const [activeModal, setActiveModal] = useState(false);
const [cadence, setCadence] = useState("MONTHLY");
const subscription = loaderData?.subscription || null; const subscription = loaderData?.subscription || null;
const shop = loaderData?.shop || ""; const shop = loaderData?.shop || "";
const isSubscribed = loaderData?.isSubscribed || false;
// Cadence selection for the billing action const hasConfirmationUrl = Boolean(actionData?.confirmationUrl);
const [cadence, setCadence] = useState("MONTHLY"); const errors = actionData?.errors || [];
const hasConfirmationUrl = Boolean(actionData && actionData.confirmationUrl);
const errors = (actionData && actionData.errors) || [];
const shopDomain = (shop || "").split(".")[0]; const shopDomain = (shop || "").split(".")[0];
@ -177,7 +257,7 @@ export default function Index() {
{ {
icon: "⚙️", icon: "⚙️",
text: "Manage API settings", text: "Manage API settings",
link: ` /d4a-turn14/app/settings`, link: `/d4a-turn14/app/settings`,
}, },
{ {
icon: "🏷️", icon: "🏷️",
@ -199,52 +279,68 @@ export default function Index() {
const openModal = () => setActiveModal(true); const openModal = () => setActiveModal(true);
const closeModal = () => setActiveModal(false); const closeModal = () => setActiveModal(false);
/* ===========================
Helpers & preview model
=========================== */
const formatDate = (d) => const formatDate = (d) =>
new Date(d).toLocaleDateString(undefined, { d
year: "numeric", ? new Date(d).toLocaleDateString(undefined, {
month: "short", year: "numeric",
day: "numeric", month: "short",
}); day: "numeric",
})
: "N/A";
// We show "preview" values while the user is picking a cadence and before they confirm billing.
// After approval + reload, we show Shopify's real values from loaderData.
const isPreview =
!subscription || loaderData?.redirectToBilling || !hasConfirmationUrl;
// Preview trial end is TRIAL_DAYS from "now" (or from subscription.createdAt if present)
const previewBase = subscription?.createdAt ? new Date(subscription.createdAt) : new Date(); const previewBase = subscription?.createdAt ? new Date(subscription.createdAt) : new Date();
const previewTrialEnd = new Date(previewBase); const previewTrialEnd = new Date(previewBase);
previewTrialEnd.setDate(previewTrialEnd.getDate() + TRIAL_DAYS); previewTrialEnd.setDate(previewTrialEnd.getDate() + TRIAL_DAYS);
// Top fields (readOnly) will reflect selection in preview mode
const displayStatus = subscription
? subscription.status
: "Not active — will be created at checkout";
const displayPlan = `${PLAN_NAME}${cadence === "ANNUAL" ? "Annual" : "Monthly"}`;
const displayNextRenewal = isPreview
? `${formatDate(previewTrialEnd)} (after ${TRIAL_DAYS}-day trial)`
: (subscription?.currentPeriodEnd ? formatDate(subscription.currentPeriodEnd) : "N/A");
// Compute trial days left accurately (if TRIAL)
const trialDaysLeft = useMemo(() => { const trialDaysLeft = useMemo(() => {
if (!subscription?.trialDays || !subscription?.createdAt) return null; if (!subscription?.trialDays || !subscription?.createdAt) return null;
if (subscription.status !== "TRIAL") return null;
const created = new Date(subscription.createdAt); const created = new Date(subscription.createdAt);
const trialEnd = new Date(created); const trialEnd = new Date(created);
trialEnd.setDate(trialEnd.getDate() + subscription.trialDays); trialEnd.setDate(trialEnd.getDate() + subscription.trialDays);
const now = new Date(); const now = new Date();
const msLeft = trialEnd.getTime() - now.getTime(); const msLeft = trialEnd.getTime() - now.getTime();
const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24)); const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24));
return Math.max(0, daysLeft); return Math.max(0, daysLeft);
}, [subscription?.trialDays, subscription?.createdAt]); }, [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 — will be created at checkout"
: subscription?.status || "N/A";
return ( return (
<Page> <Page>
<TitleBar title="Data4Autos Turn14 Integration" /> <TitleBar title="Data4Autos Turn14 Integration" />
<Layout> <Layout>
<Layout.Section> <Layout.Section>
<Card padding="500"> <Card padding="500">
@ -266,6 +362,51 @@ export default function Index() {
<Divider /> <Divider />
<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"> <BlockStack gap="800">
<Text variant="headingMd" as="h3"> <Text variant="headingMd" as="h3">
🚀 Data4Autos Turn14 Integration gives you the power to sync 🚀 Data4Autos Turn14 Integration gives you the power to sync
@ -299,13 +440,19 @@ export default function Index() {
> >
<span style={{ fontSize: "2rem" }}>{item.icon}</span> <span style={{ fontSize: "2rem" }}>{item.icon}</span>
</Text> </Text>
<a <a
href={item.link} href={item.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ textDecoration: "none" }} style={{ textDecoration: "none" }}
> >
<Text as="h6" alignment="center" fontWeight="bold" variant="headingMd"> <Text
as="h6"
alignment="center"
fontWeight="bold"
variant="headingMd"
>
{item.text} {item.text}
</Text> </Text>
</a> </a>
@ -325,7 +472,7 @@ export default function Index() {
</Text> </Text>
<Button size="large" variant="primary" onClick={openModal} fullWidth> <Button size="large" variant="primary" onClick={openModal} fullWidth>
{loaderData && loaderData.redirectToBilling {loaderData?.redirectToBilling
? "Proceed to Billing" ? "Proceed to Billing"
: "View Subscription Details"} : "View Subscription Details"}
</Button> </Button>
@ -335,9 +482,6 @@ export default function Index() {
</Layout.Section> </Layout.Section>
</Layout> </Layout>
{/* ===========================
MODAL
=========================== */}
<Modal <Modal
open={activeModal} open={activeModal}
onClose={closeModal} onClose={closeModal}
@ -345,26 +489,28 @@ export default function Index() {
primaryAction={ primaryAction={
hasConfirmationUrl hasConfirmationUrl
? undefined ? undefined
: { : isSubscribed
content: ? undefined
cadence === "ANNUAL" : {
? `Start ${TRIAL_DAYS}-day Trial — $${ANNUAL_AMOUNT}/yr` content:
: `Start ${TRIAL_DAYS}-day Trial — $${MONTHLY_AMOUNT}/mo`, cadence === "ANNUAL"
onAction: () => { ? `Start ${TRIAL_DAYS}-day Trial — $${ANNUAL_AMOUNT}/yr`
const form = document.getElementById("billing-form"); : `Start ${TRIAL_DAYS}-day Trial — $${MONTHLY_AMOUNT}/mo`,
if (form && typeof form.submit === "function") { onAction: () => {
const hidden = document.getElementById("cadence-field"); const form = document.getElementById("billing-form");
if (hidden) hidden.value = cadence; if (form) {
submit(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: closeModal }]}
> >
<Form id="billing-form" method="post"> <Form id="billing-form" method="post">
{/* Keep hidden input for server action compatibility */}
<input type="hidden" name="cadence" id="cadence-field" value={cadence} readOnly /> <input type="hidden" name="cadence" id="cadence-field" value={cadence} readOnly />
<Modal.Section> <Modal.Section>
<BlockStack gap="300"> <BlockStack gap="300">
{errors.length > 0 && ( {errors.length > 0 && (
@ -379,94 +525,75 @@ export default function Index() {
{!hasConfirmationUrl && ( {!hasConfirmationUrl && (
<> <>
{/* ===== Top read-only fields (now preview-aware) ===== */}
<TextField label="Subscription Status" value={displayStatus} readOnly /> <TextField label="Subscription Status" value={displayStatus} readOnly />
<TextField label="Plan" value={displayPlan} readOnly />
<TextField <TextField label="Billing Interval" value={displayInterval} readOnly />
label="Plan" <TextField label="Price" value={displayPrice} readOnly />
value={displayPlan}
helpText={
subscription
? "Showing your selection; actual plan updates after checkout."
: "Preview of the plan that will be created at checkout."
}
readOnly
/>
<TextField
label="Billing Interval"
value={cadence === "ANNUAL" ? "Every 12 months" : "Every 30 days"}
readOnly
/>
<TextField <TextField
label="Trial" label="Trial"
value={`${TRIAL_DAYS}-day free trial`}
readOnly
/>
<TextField
label="Trial Days Left"
value={ value={
subscription && subscription.status === "TRIAL" && trialDaysLeft != null isSubscribed
? `${trialDaysLeft} days left` ? `${subscription?.trialDays || 0} day(s)`
: subscription && subscription.status === "TRIAL" : `${TRIAL_DAYS}-day free trial`
? "Trial active"
: "N/A"
} }
readOnly readOnly
/> />
<TextField
label="Trial Days Left"
value={trialDaysLeft != null ? `${trialDaysLeft} days left` : "N/A"}
readOnly
/>
<TextField <TextField
label="Next Renewal / Period End" label="Next Renewal / Period End"
value={displayNextRenewal} value={displayNextRenewal}
readOnly readOnly
/> />
{subscription?.createdAt && (
<TextField
label="Subscription Created"
value={formatDate(subscription.createdAt)}
readOnly
/>
)}
<Divider /> <Divider />
{/* ===== Live preview of the exact price that will apply after trial ===== */} {!isSubscribed && (
<Text as="h3" variant="headingMd"> <>
Your selection <Text as="h3" variant="headingMd">
</Text> Choose your billing plan
<InlineStack gap="300"> </Text>
<TextField
label="Selected cadence"
value={cadence === "ANNUAL" ? "Annual" : "Monthly"}
readOnly
/>
<TextField
label="Price after trial"
value={cadence === "ANNUAL" ? `$${ANNUAL_AMOUNT}/yr` : `$${MONTHLY_AMOUNT}/mo`}
readOnly
/>
</InlineStack>
{/* ===== Radio-style plan selector ===== */} <ChoiceList
<ChoiceList title="Choose your billing cadence"
title="Choose your billing cadence" choices={[
choices={[ {
{ label: `Monthly — $${MONTHLY_AMOUNT}/mo`,
label: `Monthly — $${MONTHLY_AMOUNT}/mo`, value: "MONTHLY",
value: "MONTHLY", helpText:
helpText: "Flexible monthly billing. Cancel anytime during or after the trial.",
"Flexible monthly billing. Cancel anytime during or after the trial.", },
}, {
{ label: `Annual — $${ANNUAL_AMOUNT}/yr (save ~17%)`,
label: `Annual — $${ANNUAL_AMOUNT}/yr (save ~17%)`, value: "ANNUAL",
value: "ANNUAL", helpText: "Best value. Billed annually after your free trial ends.",
helpText: "Best value. Billed annually after your free trial ends.", },
}, ]}
]} selected={[cadence]}
selected={[cadence]} onChange={(selected) => {
onChange={(selected) => { const next =
const next = Array.isArray(selected) && selected[0] ? selected[0] : "MONTHLY"; Array.isArray(selected) && selected[0]
setCadence(next); ? selected[0]
const hidden = document.getElementById("cadence-field"); : "MONTHLY";
if (hidden) hidden.value = next; setCadence(next);
}} const hidden = document.getElementById("cadence-field");
allowMultiple={false} if (hidden) hidden.value = next;
/> }}
allowMultiple={false}
/>
</>
)}
</> </>
)} )}
@ -474,7 +601,7 @@ export default function Index() {
<BlockStack gap="300"> <BlockStack gap="300">
<Banner title="Almost there!" tone="success"> <Banner title="Almost there!" tone="success">
Click the button below to open Shopifys billing confirmation in a Click the button below to open Shopifys billing confirmation in a
new tab. If it doesnt open, copy the link and open it manually. new tab.
</Banner> </Banner>
<Button <Button
@ -504,4 +631,4 @@ export default function Index() {
</Modal> </Modal>
</Page> </Page>
); );
} }

View File

@ -0,0 +1,655 @@
import { json } from "@remix-run/node";
import { useLoaderData, Form, useActionData } from "@remix-run/react";
import {
Page,
Layout,
Card,
TextField,
Checkbox,
Button,
Thumbnail,
Spinner,
Toast,
Frame,
Text,
} from "@shopify/polaris";
import { useEffect, useState } from "react";
import { TitleBar } from "@shopify/app-bridge-react";
import { getTurn14AccessTokenFromMetafield } from "../utils/turn14Token.server";
import { authenticate } from "../shopify.server";
async function checkShopExists(shop) {
try {
const resp = await fetch(
`https://backend.data4autos.com/checkisshopdataexists/${shop}`
);
const data = await resp.json();
return data.status === 1; // true if shop exists, false otherwise
} catch (err) {
console.error("Error checking shop:", err);
return false; // default to false if error
}
}
// export const loader = async ({ request }) => {
// // const accessToken = await getTurn14AccessTokenFromMetafield(request);
// const { admin } = await authenticate.admin(request);
// const { session } = await authenticate.admin(request);
// const shop = session.shop;
// var accessToken = ""
// try {
// accessToken = await getTurn14AccessTokenFromMetafield(request);
// } catch (err) {
// return json({ brands: [], collections: [], selectedBrandsFromShopify: [], shop, err });
// console.error("Error getting Turn14 access token:", err);
// // Proceeding with empty accessToken
// }
// // fetch brands
// const brandRes = await fetch("https://turn14.data4autos.com/v1/brands", {
// headers: {
// Authorization: `Bearer ${accessToken}`,
// "Content-Type": "application/json",
// },
// });
// const brandJson = await brandRes.json();
// if (!brandRes.ok) {
// return json({ error: brandJson.error || "Failed to fetch brands" }, { status: 500 });
// }
// // fetch Shopify collections
// const gqlRaw = await admin.graphql(`
// {
// collections(first: 100) {
// edges {
// node {
// id
// title
// }
// }
// }
// }
// `);
// const gql = await gqlRaw.json();
// const collections = gql?.data?.collections?.edges.map(e => e.node) || [];
// 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;
// let brands = [];
// try {
// brands = JSON.parse(rawValue);
// } catch (err) {
// console.error(" Failed to parse metafield value:", err);
// }
// return json({ brands: brandJson.data, collections, selectedBrandsFromShopify: brands || [], shop });
// };
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",
});
}
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",
});
}
/* =========================
FETCH TURN14 BRANDS
========================== */
let brandJson;
try {
console.log("📦 Fetching Turn14 brands...");
const brandRes = await fetch("https://turn14.data4autos.com/v1/brands", {
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
console.log("🔄 Awaiting Turn14 brands response...",brandRes);
console.log("2345678909876543567 Turn14 brands fetch initiated");
console.log("📡 Turn14 brands fetch completed", accessToken);
console.log("📡 Turn14 brands HTTP status:", brandRes.status);
brandJson = await brandRes.json();
console.log("📦 Turn14 brands raw response:", brandJson);
if (!brandRes.ok) {
console.error("❌ Turn14 brands fetch failed");
return json({
brands: [],
collections: [],
selectedBrandsFromShopify: [],
shop,
error: brandJson?.error || "Failed to fetch brands",
});
}
} catch (err) {
console.error("❌ Exception while fetching Turn14 brands:", err);
return json({
brands: [],
collections: [],
selectedBrandsFromShopify: [],
shop,
error: "Turn14 brands fetch crashed",
});
}
/* =========================
FETCH SHOPIFY COLLECTIONS
========================== */
let collections = [];
try {
console.log("🗂️ Fetching Shopify collections...");
const gqlRaw = await admin.graphql(`
{
collections(first: 100) {
edges {
node {
id
title
}
}
}
}
`);
const gql = await gqlRaw.json();
console.log("🧾 Shopify collections raw response:", gql);
collections =
gql?.data?.collections?.edges?.map((e) => e.node) || [];
console.log("✅ Parsed collections count:", collections.length);
} catch (err) {
console.error("❌ Error fetching Shopify collections:", err);
}
/* =========================
FETCH SELECTED BRANDS METAFIELD
========================== */
let selectedBrands = [];
try {
console.log("🏷️ Fetching shop metafield: turn14.selected_brands");
const res = await admin.graphql(`
{
shop {
metafield(namespace: "turn14", key: "selected_brands") {
value
}
}
}
`);
const data = await res.json();
console.log("🧾 Metafield raw response:", data);
const rawValue = data?.data?.shop?.metafield?.value;
console.log("📄 Raw metafield value:", rawValue);
if (rawValue) {
selectedBrands = JSON.parse(rawValue);
console.log(
"✅ Parsed selectedBrands count:",
selectedBrands.length
);
} else {
console.log(" No metafield value found (first-time setup)");
}
} catch (err) {
console.error("❌ Failed parsing selected_brands metafield:", err);
}
/* =========================
FINAL RETURN
========================== */
console.log("🎯 Loader final return payload:", {
brandsCount: brandJson?.data?.length || 0,
collectionsCount: collections.length,
selectedBrandsCount: selectedBrands.length,
shop,
});
return json({
brands: brandJson?.data || [],
collections,
selectedBrandsFromShopify: selectedBrands,
shop,
});
};
export const action = async ({ request }) => {
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; // "veloxautomotive.myshopify.com"
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 {
brands = [],
collections = [],
selectedBrandsFromShopify = [],
shop = "",
err,
error,
} = useLoaderData() || {};
console.log(err)
console.log(`selectedBrandsFromShopify: ${JSON.stringify(selectedBrandsFromShopify)}`);
const actionData = useActionData() || {};
const [selectedIdsold, setSelectedIdsold] = useState([])
const [selectedIds, setSelectedIds] = useState(() => {
return (selectedBrandsFromShopify ?? []).map((b) => b.id);
});
// console.log("Selected IDS : ", selectedIds)
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); // null | true | false
useEffect(() => {
if (!shop) {
console.log("⚠️ shop is undefined or empty");
return;
}
(async () => {
const result = await checkShopExists(shop);
console.log("✅ API status result:", result, "| shop:", shop);
setTurn14Enabled(result);
})();
}, [shop]);
useEffect(() => {
const selids = selectedIds
// console.log("Selected IDS : ", selids)
setSelectedIdsold(selids)
}, [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 =>
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 = () => {
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])));
}
};
var isSubmitting;
// console.log("actionData", actionData);
if (actionData.status) {
isSubmitting = !actionData.status && !actionData.error && !actionData.processId;
} else {
isSubmitting = false;
}
// console.log("isSubmitting", isSubmitting);
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` },
];
// If Turn14 is explicitly NOT connected, show a lightweight call-to-action screen
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>
{/* Primary actions */}
<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>
{/* Secondary links */}
<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>
);
}
// console.log("Selected Brands:", selectedBrands)
return (
<Frame>
<Page fullWidth>
<TitleBar title="Data4Autos Turn14 Integration" background="primary" />
<div style={{ display: "flex", justifyContent: "center", textAlign: "center", padding: "20px 0px", position: "fixed", zIndex: 2, background: "rgb(241 241 241)", width: "100%", top: 0, }}>
<Text as="h1" variant="headingLg">
Data4Autos Turn14 Brands List
</Text>
<br />
</div>
{/* <div>
<p>
<strong>Turn 14 Status:</strong>{" "}
{Turn14Enabled === true
? "✅ Turn14 x Shopify Connected!"
: Turn14Enabled === false
? "❌ Turn14 x Shopify Connection Doesn't Exists"
: "Checking..."}
</p>
</div> */}
<Layout >
<Layout.Section>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16, flexWrap: "wrap", position: "fixed", zIndex: 2, background: "white", padding: "20px", width: "97%", marginTop: "40px", backgroundColor: "#00d1ff" }}>
{/* Left side - Search + Select All */}
<div style={{ display: "flex", gap: 16, alignItems: "center" }}>
{(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>
)}
<TextField
labelHidden
label="Search brands"
value={search}
onChange={setSearch}
placeholder="Type brand name…"
autoComplete="off"
/>
<Checkbox
label="Select All"
checked={allFilteredSelected}
onChange={toggleSelectAll}
/>
</div>
{/* Right side - Save Button */}
<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={selectedIds.length === 0 || isSubmitting}> */}
<Button primary submit disabled={isSubmitting} size="large" variant="primary">
{isSubmitting ? <Spinner size="small" /> : "Save Collections"}
</Button>
</Form>
</div>
</Layout.Section>
<Layout.Section>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
gap: 16,
marginTop: "120px"
}}
>
{filteredBrands.map((brand) => (
<Card key={brand.id} sectioned>
<div style={{ position: "relative", textAlign: "center" }}>
{/* Checkbox in top-right corner */}
<div style={{ position: "absolute", top: 0, right: 0 }}>
<Checkbox
label=""
checked={selectedIds.includes(brand.id)}
onChange={() => toggleSelect(brand.id)}
/>
</div>
{/* Brand image */}
<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>
{/* Brand name */}
<div style={{ marginTop: "15px", fontWeight: "600", fontSize: "16px", lineHeight: "26px" }}>
{brand.name}
</div>
</div>
</Card>
))}
</div>
</Layout.Section>
</Layout>
{toastMarkup}
</Page>
</Frame>
);
}

View File

@ -1,5 +1,5 @@
import { json } from "@remix-run/node"; import { json } from "@remix-run/node";
import { useLoaderData, Form, useActionData } from "@remix-run/react"; import { useLoaderData, Form, useActionData, useNavigate } from "@remix-run/react";
import { import {
Page, Page,
Layout, Layout,
@ -12,17 +12,16 @@ import {
Toast, Toast,
Frame, Frame,
Text, Text,
Banner,
InlineStack,
} from "@shopify/polaris"; } from "@shopify/polaris";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { TitleBar } from "@shopify/app-bridge-react"; import { TitleBar } from "@shopify/app-bridge-react";
import { getTurn14AccessTokenFromMetafield } from "../utils/turn14Token.server"; import { getTurn14AccessTokenFromMetafield } from "../utils/turn14Token.server";
import { authenticate } from "../shopify.server"; import { authenticate } from "../shopify.server";
const PLAN_NAME = "Starter Sync";
const ALLOWED_STATUSES = ["ACTIVE", "TRIAL"];
async function checkShopExists(shop) { async function checkShopExists(shop) {
try { try {
@ -30,107 +29,276 @@ async function checkShopExists(shop) {
`https://backend.data4autos.com/checkisshopdataexists/${shop}` `https://backend.data4autos.com/checkisshopdataexists/${shop}`
); );
const data = await resp.json(); const data = await resp.json();
return data.status === 1; // true if shop exists, false otherwise return data.status === 1;
} catch (err) { } catch (err) {
console.error("Error checking shop:", err); console.error("Error checking shop:", err);
return false; // default to false if error return false;
} }
} }
export const loader = async ({ request }) => { function getIntervalLabel(interval) {
// const accessToken = await getTurn14AccessTokenFromMetafield(request); switch (interval) {
const { admin } = await authenticate.admin(request); case "ANNUAL":
const { session } = await authenticate.admin(request); 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 shop = session.shop;
var accessToken = "" const resp = await admin.graphql(`
try { query CurrentSubscriptionDetails {
accessToken = await getTurn14AccessTokenFromMetafield(request); currentAppInstallation {
} catch (err) { activeSubscriptions {
return json({ brands: [], collections: [], selectedBrandsFromShopify: [], shop ,err}); id
console.error("Error getting Turn14 access token:", err); name
// Proceeding with empty accessToken status
} test
createdAt
trialDays
currentPeriodEnd
lineItems {
// fetch brands
const brandRes = await fetch("https://turn14.data4autos.com/v1/brands", {
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
const brandJson = await brandRes.json();
if (!brandRes.ok) {
return json({ error: brandJson.error || "Failed to fetch brands" }, { status: 500 });
}
// fetch Shopify collections
const gqlRaw = await admin.graphql(`
{
collections(first: 100) {
edges {
node {
id id
title plan {
pricingDetails {
__typename
... on AppRecurringPricing {
interval
price {
amount
currencyCode
}
}
}
}
} }
} }
} }
} }
`); `);
const gql = await gqlRaw.json();
const collections = gql?.data?.collections?.edges.map(e => e.node) || [];
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,
};
}
const res = await admin.graphql(`{ export const loader = async ({ request }) => {
shop { console.log("🚀 Loader started");
metafield(namespace: "turn14", key: "selected_brands") {
value let admin, session, shop;
}
}
}`);
const data = await res.json();
const rawValue = data?.data?.shop?.metafield?.value;
let brands = [];
try { try {
brands = JSON.parse(rawValue); 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) { } catch (err) {
console.error("❌ Failed to parse metafield value:", 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();
return json({ brands: brandJson.data, collections, selectedBrandsFromShopify: brands || [], shop }); 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 }) => { 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 formData = await request.formData();
const selectedBrands = JSON.parse(formData.get("selectedBrands") || "[]"); const selectedBrands = JSON.parse(formData.get("selectedBrands") || "[]");
const selectedOldBrands = JSON.parse(formData.get("selectedOldBrands") || "[]"); const selectedOldBrands = JSON.parse(formData.get("selectedOldBrands") || "[]");
const { session } = await authenticate.admin(request); const { session } = await authenticate.admin(request);
const shop = session.shop; // "veloxautomotive.myshopify.com" const shop = session.shop;
selectedBrands.forEach(brand => { selectedBrands.forEach((brand) => {
delete brand.pricegroups; delete brand.pricegroups;
}); });
selectedOldBrands.forEach(brand => { selectedOldBrands.forEach((brand) => {
delete brand.pricegroups; delete brand.pricegroups;
}); });
const resp = await fetch("https://backend.data4autos.com/managebrands", { const resp = await fetch("https://backend.data4autos.com/managebrands", {
method: "POST", method: "POST",
headers: { headers: {
@ -150,68 +318,45 @@ export const action = async ({ request }) => {
}; };
export default function BrandsPage() { export default function BrandsPage() {
const { brands, collections, selectedBrandsFromShopify, shop ,err} = useLoaderData(); const navigate = useNavigate();
console.log(err) const {
// console.log(`selectedBrandsFromShopify: ${JSON.stringify(selectedBrandsFromShopify)}`); brands = [],
selectedBrandsFromShopify = [],
shop = "",
error,
isSubscribed = false,
subscription = null,
} = useLoaderData() || {};
const actionData = useActionData() || {}; const actionData = useActionData() || {};
const [selectedIdsold, setSelectedIdsold] = useState([]);
const [selectedIds, setSelectedIds] = useState(() =>
(selectedBrandsFromShopify ?? []).map((b) => b.id)
);
const [selectedIdsold, setSelectedIdsold] = useState([])
// const [selectedIds, setSelectedIds] = useState(() => {
// const titles = new Set(collections.map(c => c.title.toLowerCase()));
// return brands
// .filter(b => titles.has(b.name.toLowerCase()))
// .map(b => b.id);
// });
const [selectedIds, setSelectedIds] = useState(() => {
return selectedBrandsFromShopify.map(b => b.id);
});
// console.log("Selected IDS : ", selectedIds)
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [filteredBrands, setFilteredBrands] = useState(brands); const [filteredBrands, setFilteredBrands] = useState(brands);
const [toastActive, setToastActive] = useState(false); const [toastActive, setToastActive] = useState(false);
const [polling, setPolling] = useState(false); const [polling, setPolling] = useState(false);
const [status, setStatus] = useState(actionData.status || ""); const [status, setStatus] = useState(actionData.status || "");
const [Turn14Enabled, setTurn14Enabled] = useState(null);
const [Turn14Enabled, setTurn14Enabled] = useState(null); // null | true | false
useEffect(() => { useEffect(() => {
if (!shop) { if (!shop) return;
console.log("⚠️ shop is undefined or empty");
return;
}
(async () => { (async () => {
const result = await checkShopExists(shop); const result = await checkShopExists(shop);
console.log("✅ API status result:", result, "| shop:", shop);
setTurn14Enabled(result); setTurn14Enabled(result);
})(); })();
}, [shop]); }, [shop]);
useEffect(() => { useEffect(() => {
const selids = selectedIds setSelectedIdsold(selectedIds);
// console.log("Selected IDS : ", selids)
setSelectedIdsold(selids)
}, [toastActive]); }, [toastActive]);
useEffect(() => { useEffect(() => {
const term = search.toLowerCase(); const term = search.toLowerCase();
setFilteredBrands(brands.filter(b => b.name.toLowerCase().includes(term))); setFilteredBrands(brands.filter((b) => b.name.toLowerCase().includes(term)));
}, [search, brands]); }, [search, brands]);
useEffect(() => { useEffect(() => {
@ -235,32 +380,33 @@ export default function BrandsPage() {
setPolling(false); setPolling(false);
}; };
const toggleSelect = id => const toggleSelect = (id) => {
setSelectedIds(prev => if (!isSubscribed) return;
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
); );
};
const allFilteredSelected = const allFilteredSelected =
filteredBrands.length > 0 && filteredBrands.length > 0 &&
filteredBrands.every(b => selectedIds.includes(b.id)); filteredBrands.every((b) => selectedIds.includes(b.id));
const toggleSelectAll = () => { const toggleSelectAll = () => {
const ids = filteredBrands.map(b => b.id); if (!isSubscribed) return;
const ids = filteredBrands.map((b) => b.id);
if (allFilteredSelected) { if (allFilteredSelected) {
setSelectedIds(prev => prev.filter(id => !ids.includes(id))); setSelectedIds((prev) => prev.filter((id) => !ids.includes(id)));
} else { } else {
setSelectedIds(prev => Array.from(new Set([...prev, ...ids]))); setSelectedIds((prev) => Array.from(new Set([...prev, ...ids])));
} }
}; };
var isSubmitting; let isSubmitting = false;
// console.log("actionData", actionData);
if (actionData.status) { if (actionData.status) {
isSubmitting = !actionData.status && !actionData.error && !actionData.processId; isSubmitting = !actionData.status && !actionData.error && !actionData.processId;
} else {
isSubmitting = false;
} }
// console.log("isSubmitting", isSubmitting);
const toastMarkup = toastActive ? ( const toastMarkup = toastActive ? (
<Toast <Toast
@ -269,21 +415,49 @@ export default function BrandsPage() {
/> />
) : null; ) : null;
const selectedBrands = brands.filter(b => selectedIds.includes(b.id)); const selectedBrands = brands.filter((b) => selectedIds.includes(b.id));
const selectedOldBrands = brands.filter(b => selectedIdsold.includes(b.id)); const selectedOldBrands = brands.filter((b) => selectedIdsold.includes(b.id));
const shopDomain = (shop || "").split(".")[0]; const shopDomain = (shop || "").split(".")[0];
const items = [ 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: "⚙️",
{ icon: "📦", text: "Sync brand collections to Shopify", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/managebrand` }, text: "Manage API settings",
{ icon: "🔐", text: "Handle secure Turn14 login credentials", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/help` }, 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`,
},
]; ];
// If Turn14 is explicitly NOT connected, show a lightweight call-to-action screen 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) { if (Turn14Enabled === false) {
return ( return (
<Frame> <Frame>
@ -302,7 +476,6 @@ export default function BrandsPage() {
</Text> </Text>
</div> </div>
{/* Primary actions */}
<div style={{ marginTop: 24, display: "flex", gap: 16, justifyContent: "center", flexWrap: "wrap" }}> <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" }}> <a href={items[0].link} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
<Text as="h6" variant="headingMd" fontWeight="bold"> <Text as="h6" variant="headingMd" fontWeight="bold">
@ -322,7 +495,6 @@ export default function BrandsPage() {
</Text> </Text>
</div> </div>
{/* Secondary links */}
<div style={{ marginTop: 20, display: "flex", gap: 16, justifyContent: "center", flexWrap: "wrap" }}> <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" }}> <a href={items[1].link} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
<Text as="p" variant="bodyMd"> <Text as="p" variant="bodyMd">
@ -344,85 +516,161 @@ export default function BrandsPage() {
); );
} }
// console.log("Selected Brands:", selectedBrands)
return ( return (
<Frame> <Frame>
<Page fullWidth> <Page fullWidth>
<TitleBar title="Data4Autos Turn14 Integration" background="primary" /> <TitleBar title="Data4Autos Turn14 Integration" background="primary" />
<div style={{ display: "flex", justifyContent: "center", textAlign: "center", padding: "20px 0px", position: "fixed", zIndex: 2, background: "rgb(241 241 241)", width: "100%", top: 0, }}>
<div style={{ marginBottom: 16 }}>
<Text as="h1" variant="headingLg"> <Text as="h1" variant="headingLg">
Data4Autos Turn14 Brands List Data4Autos Turn14 Brands List
</Text> </Text>
<br />
</div> </div>
{/* <div>
<p>
<strong>Turn 14 Status:</strong>{" "}
{Turn14Enabled === true
? "✅ Turn14 x Shopify Connected!"
: Turn14Enabled === false
? "❌ Turn14 x Shopify Connection Doesn't Exists"
: "Checking..."}
</p>
</div> */}
<Layout >
<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> <Layout.Section>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16, flexWrap: "wrap", position: "fixed", zIndex: 2, background: "white", padding: "20px", width: "97%", marginTop: "40px", backgroundColor: "#00d1ff" }}> <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>
)}
{/* Left side - Search + Select All */} <div style={{ minWidth: 260 }}>
<div style={{ display: "flex", gap: 16, alignItems: "center" }}> <TextField
{(actionData?.processId || false) && ( labelHidden
<div> label="Search brands"
<p> value={search}
<strong>Process ID:</strong> {actionData.processId} onChange={setSearch}
</p> placeholder="Type brand name…"
<p> autoComplete="off"
<strong>Status:</strong> {status || "—"} disabled={!isSubscribed}
</p> />
<Button onClick={checkStatus} loading={polling}>
Check Status
</Button>
</div> </div>
)}
<TextField <Checkbox
labelHidden label="Select All"
label="Search brands" checked={allFilteredSelected}
value={search} onChange={toggleSelectAll}
onChange={setSearch} disabled={!isSubscribed}
placeholder="Type brand name…" />
autoComplete="off" </div>
/>
<Checkbox <Form
label="Select All" method="post"
checked={allFilteredSelected} style={{ display: "flex", alignItems: "center", gap: 8 }}
onChange={toggleSelectAll} >
/> <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>
{/* Right side - Save Button */}
<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={selectedIds.length === 0 || isSubmitting}> */}
<Button primary submit disabled={isSubmitting} size="large" variant="primary">
{isSubmitting ? <Spinner size="small" /> : "Save Collections"}
</Button>
</Form>
</div> </div>
</Layout.Section> </Layout.Section>
@ -432,22 +680,20 @@ export default function BrandsPage() {
display: "grid", display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
gap: 16, gap: 16,
marginTop: "120px"
}} }}
> >
{filteredBrands.map((brand) => ( {filteredBrands.map((brand) => (
<Card key={brand.id} sectioned> <Card key={brand.id} sectioned>
<div style={{ position: "relative", textAlign: "center" }}> <div style={{ position: "relative", textAlign: "center" }}>
{/* Checkbox in top-right corner */}
<div style={{ position: "absolute", top: 0, right: 0 }}> <div style={{ position: "absolute", top: 0, right: 0 }}>
<Checkbox <Checkbox
label="" label=""
checked={selectedIds.includes(brand.id)} checked={selectedIds.includes(brand.id)}
onChange={() => toggleSelect(brand.id)} onChange={() => toggleSelect(brand.id)}
disabled={!isSubscribed}
/> />
</div> </div>
{/* Brand image */}
<div style={{ display: "flex", justifyContent: "center" }}> <div style={{ display: "flex", justifyContent: "center" }}>
<Thumbnail <Thumbnail
source={ source={
@ -458,8 +704,15 @@ export default function BrandsPage() {
size="large" size="large"
/> />
</div> </div>
{/* Brand name */}
<div style={{ marginTop: "15px", fontWeight: "600", fontSize: "16px", lineHeight: "26px" }}> <div
style={{
marginTop: "15px",
fontWeight: "600",
fontSize: "16px",
lineHeight: "26px",
}}
>
{brand.name} {brand.name}
</div> </div>
</div> </div>
@ -473,4 +726,4 @@ export default function BrandsPage() {
</Page> </Page>
</Frame> </Frame>
); );
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -28,8 +28,9 @@ const SCOPES = [
"write_products", "write_products",
"read_publications", "read_publications",
"write_publications", "write_publications",
"read_fulfillments", "stagedUploadsCreate",
"write_fulfillments","read_locations","write_locations" "read_fulfillments",
"write_files,read_files,write_fulfillments", "read_locations", "write_locations"
].join(","); ].join(",");
const REDIRECT_URI = "https://backend.data4autos.com/auth/callback"; const REDIRECT_URI = "https://backend.data4autos.com/auth/callback";
const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa"; const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa";
@ -54,7 +55,7 @@ export const loader = async ({ request }) => {
if (data.shop.metafield?.value) { if (data.shop.metafield?.value) {
try { creds = JSON.parse(data.shop.metafield.value); } catch { } try { creds = JSON.parse(data.shop.metafield.value); } catch { }
} }
creds = {}; // creds = {};
let savedPricing = { priceType: "map", percentage: 0 }; let savedPricing = { priceType: "map", percentage: 0 };
if (data.shop.pricing?.value) { if (data.shop.pricing?.value) {
try { try {
@ -118,11 +119,11 @@ export const action = async ({ request }) => {
} }
// default / legacy: connect Turn14 flow // default / legacy: connect Turn14 flow
// const clientId = formData.get("client_id"); const clientId = formData.get("client_id");
// const clientSecret = formData.get("client_secret"); const clientSecret = formData.get("client_secret");
const clientId = formData.get("demo_client_id"); // const clientId = formData.get("demo_client_id");
const clientSecret = formData.get("demo_client_secret"); // const clientSecret = formData.get("demo_client_secret");
let tokenData; let tokenData;
try { try {
@ -236,18 +237,19 @@ export default function StoreCredentials() {
{/* —— TURN14 FORM —— */} {/* —— TURN14 FORM —— */}
<Form method="post"> <Form method="post">
<input type="hidden" name="intent" value="connect_turn14" /> <input type="hidden" name="intent" value="connect_turn14" />
<input type="hidden" name="demo_client_id" value="671f15b6973625885ee392122113d9ed54103ddd" /> {/* <input type="hidden" name="demo_client_id" value="671f15b6973625885ee392122113d9ed54103ddd" />
<input type="hidden" name="demo_client_secret" value="98907c3b3d92235c99338bd0ce307144b2f66874" /> <input type="hidden" name="demo_client_secret" value="98907c3b3d92235c99338bd0ce307144b2f66874" />
*/}
<BlockStack gap="400"> <BlockStack gap="400">
<BlockStack gap="200"> <BlockStack gap="200">
<TextField <TextField
label="Turn14 Client ID" label="Turn14 Client ID"
name="client_id" name="client_id"
// value={clientId} value={clientId}
value={"********************************************************"} //value={"********************************************************"}
onChange={setClientId} onChange={setClientId}
autoComplete="off" autoComplete="off"
// requiredIndicator // requiredIndicator
padding="200" padding="200"
/> />
</BlockStack> </BlockStack>
@ -255,21 +257,21 @@ export default function StoreCredentials() {
<TextField <TextField
label="Turn14 Client Secret" label="Turn14 Client Secret"
name="client_secret" name="client_secret"
// value={clientSecret} value={clientSecret}
value={"********************************************************"} //value={"********************************************************"}
onChange={setClientSecret} onChange={setClientSecret}
autoComplete="off" autoComplete="off"
// requiredIndicator // requiredIndicator
padding="200" padding="200"
/> />
</BlockStack> </BlockStack>
<BlockStack gap="200"> {/* <BlockStack gap="200">
<Button submit primary size="large" variant="primary"> <Button submit primary size="large" variant="primary">
Connect Turn14 With Demo Credentials Connect Turn14 With Demo Credentials
</Button> </Button>
</BlockStack> </BlockStack> */}
<BlockStack gap="200"> <BlockStack gap="200">
<Button submit primary size="large" variant="primary"> <Button submit primary size="large" variant="primary">
Connect Turn14 Connect Turn14
</Button> </Button>
@ -321,7 +323,7 @@ export default function StoreCredentials() {
/> />
)} )}
<div style={{paddingTop:"15px", textAlign:"end"}}> <div style={{ paddingTop: "15px", textAlign: "end" }}>
<Button submit primary variant="primary" size="large" >Save pricing</Button> <Button submit primary variant="primary" size="large" >Save pricing</Button>
</div> </div>
</Form> </Form>

View File

@ -29,7 +29,7 @@ const SCOPES = [
"read_publications", "read_publications",
"write_publications", "write_publications",
"read_fulfillments", "read_fulfillments",
"write_fulfillments","read_locations","write_locations" "write_files,read_files,write_fulfillments","read_locations","write_locations"
].join(","); ].join(",");
const REDIRECT_URI = "https://backend.data4autos.com/auth/callback"; const REDIRECT_URI = "https://backend.data4autos.com/auth/callback";
const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa"; const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa";

View File

@ -29,7 +29,7 @@ const SCOPES = [
"read_publications", "read_publications",
"write_publications", "write_publications",
"read_fulfillments", "read_fulfillments",
"write_fulfillments","read_locations","write_locations" "write_files,read_files,write_fulfillments","read_locations","write_locations"
].join(","); ].join(",");
const REDIRECT_URI = "https://backend.data4autos.com/auth/callback"; const REDIRECT_URI = "https://backend.data4autos.com/auth/callback";
const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa"; const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa";

View File

@ -0,0 +1,350 @@
// 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,
TextContainer,
InlineError,
Text,
BlockStack,
Box,
Select,
Banner,
InlineStack,
} 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",
"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 { }
}
creds = {};
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);
// 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;
const shopName = shopJson.data.shop.name;
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 {
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 });
}
// default / legacy: 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,
}),
});
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();
// open Shopify install after Connect Turn14
useEffect(() => {
if (actionData?.confirmationUrl) {
window.open(actionData.confirmationUrl, "_blank", "noopener,noreferrer");
}
}, [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]
);
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;
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>
</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="400">
<TextContainer spacing="tight">
<Text variant="headingLg" align="center" fontWeight="medium">Shop: {shopName}</Text>
</TextContainer>
{/* —— 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>
<BlockStack gap="200">
<Button submit primary size="large" variant="primary">
Connect Turn14
</Button>
</BlockStack>
</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>
</Layout.Section>
</Layout>
</Page>
);
}

View File

@ -65,7 +65,7 @@ export const action = async ({ request }) => {
const shop = request.headers.get("shop-domain") || ""; const shop = request.headers.get("shop-domain") || "";
// make the POST to your backend // make the POST to your backend
const resp = await fetch("https://backend.dine360.ca/managebrands", { const resp = await fetch("https://backend.data4autos.com/managebrands", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -109,7 +109,7 @@ export default function BrandsPage() {
setPolling(true); setPolling(true);
const resp = await fetch( const resp = await fetch(
`https://backend.dine360.ca/managebrands/status/${processId}`, `https://backend.data4autos.com/managebrands/status/${processId}`,
{ {
headers: { "shop-domain": window.shopify.shop || "" }, headers: { "shop-domain": window.shopify.shop || "" },
} }

View File

@ -67,7 +67,7 @@ export const action = async ({ request }) => {
const shop = session.shop; // "veloxautomotive.myshopify.com" const shop = session.shop; // "veloxautomotive.myshopify.com"
// make the POST to your backend // make the POST to your backend
const resp = await fetch("https://backend.dine360.ca/managebrands", { const resp = await fetch("https://backend.data4autos.com/managebrands", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -212,7 +212,7 @@ export default function BrandsPage() {
setPolling(true); setPolling(true);
const resp = await fetch( const resp = await fetch(
`https://backend.dine360.ca/managebrands/status/${processId}`, `https://backend.data4autos.com/managebrands/status/${processId}`,
{ {
headers: { "shop-domain": window.shopify.shop || "" }, headers: { "shop-domain": window.shopify.shop || "" },
} }

View File

@ -23,7 +23,7 @@ const SCOPES = [
"read_publications", "read_publications",
"write_publications", "write_publications",
].join(","); ].join(",");
const REDIRECT_URI = "https://backend.dine360.ca/auth/callback"; const REDIRECT_URI = "https://backend.data4autos.com/auth/callback";
const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa"; const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa";
export const loader = async ({ request }) => { export const loader = async ({ request }) => {

4
package-lock.json generated
View File

@ -1,10 +1,10 @@
{ {
"name": "turn14-test", "name": "Data4Autos",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "turn14-test", "name": "Data4Autos",
"workspaces": [ "workspaces": [
"extensions/*" "extensions/*"
], ],

View File

@ -1,5 +1,5 @@
{ {
"name": "turn14-test", "name": "Data4Autos",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "remix vite:build", "build": "remix vite:build",

View File

@ -1,12 +1,11 @@
client_id = "b7534c980967bad619cfdb9d3f837cfa" client_id = "b7534c980967bad619cfdb9d3f837cfa"
name = "turn14-test" name = "Data4Autos"
handle = "d4a-turn14" handle = "d4a-turn14"
application_url = "https://shop.data4autos.com" # Update this line application_url = "https://shop.data4autos.com" # Update this line
embedded = true embedded = true
[build] [build]
automatically_update_urls_on_dev = true automatically_update_urls_on_dev = true
include_config_on_deploy = true
[webhooks] [webhooks]
api_version = "2025-04" api_version = "2025-04"
@ -20,7 +19,7 @@ api_version = "2025-04"
uri = "/webhooks/app/uninstalled" uri = "/webhooks/app/uninstalled"
[access_scopes] [access_scopes]
scopes = "read_inventory,read_products,write_inventory,write_products,read_publications,write_publications,read_fulfillments,write_fulfillments" scopes = "read_inventory,read_products,write_inventory,write_products,read_publications,write_publications,read_fulfillments,write_files,read_files,write_fulfillments"
[auth] [auth]
redirect_urls = ["https://backend.dine360.ca/auth/callback"] # Update this line as well redirect_urls = ["https://backend.data4autos.com/auth/callback"] # Update this line as well