fix the cost price and few other updates

This commit is contained in:
Manesh 2025-10-06 03:49:49 +00:00
parent 7ef9040271
commit 688855fe48
16 changed files with 5365 additions and 322 deletions

View File

@ -0,0 +1,412 @@
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,
} 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;
if (!subscription) {
return json({ redirectToBilling: true, subscription: null, 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 createRes = await admin.graphql(`
mutation {
appSubscriptionCreate(
name: "${PLAN_NAME}"
returnUrl: "https://your-app.com/after-billing"
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 && loaderData.subscription;
const shop = loaderData && loaderData.shop;
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 openModal = () => setActiveModal(true);
const closeModal = () => setActiveModal(false);
const hasConfirmationUrl = Boolean(actionData && actionData.confirmationUrl);
const errors = (actionData && actionData.errors) || [];
// Compute trial days left accurately (if TRIAL)
const trialDaysLeft = useMemo(() => {
if (!subscription || !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 && subscription.trialDays, subscription && 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: "Proceed to Billing",
onAction: () => {
const form = document.getElementById("billing-form");
if (form && typeof form.submit === "function") {
submit(form);
}
},
}
}
secondaryActions={[{ content: "Close", onAction: closeModal }]}
>
<Form id="billing-form" method="post">
<input type="hidden" name="cadence" id="cadence-field" defaultValue="MONTHLY" />
<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 && (
<>
<TextField
label="Subscription Status"
value={subscription ? subscription.status : "No active subscription"}
disabled
/>
<TextField label="Plan" value={PLAN_NAME} disabled />
<TextField label="Trial" value={`${TRIAL_DAYS}-day free trial`} disabled />
<TextField
label="Trial Days Left"
value={
subscription && subscription.status === "TRIAL" && trialDaysLeft != null
? `${trialDaysLeft} days left`
: subscription && subscription.status === "TRIAL"
? "Trial active"
: "N/A"
}
disabled
/>
<TextField
label="Next Renewal / Period End"
value={
subscription && subscription.currentPeriodEnd
? new Date(subscription.currentPeriodEnd).toLocaleDateString()
: "N/A"
}
disabled
/>
<InlineStack gap="300" align="center" blockAlign="center">
<Button
variant="primary"
size="large"
onClick={() => {
const cadence = document.getElementById("cadence-field");
if (cadence) cadence.value = "MONTHLY";
const form = document.getElementById("billing-form");
if (form && typeof form.submit === "function") form.submit();
}}
>
Start {TRIAL_DAYS}-day Trial ${MONTHLY_AMOUNT}/mo
</Button>
<Button
variant="secondary"
size="large"
onClick={() => {
const cadence = document.getElementById("cadence-field");
if (cadence) cadence.value = "ANNUAL";
const form = document.getElementById("billing-form");
if (form && typeof form.submit === "function") form.submit();
}}
>
Start {TRIAL_DAYS}-day Trial ${ANNUAL_AMOUNT}/yr (save 17%)
</Button>
</InlineStack>
</>
)}
{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

@ -0,0 +1,492 @@
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;
if (!subscription) {
return json({ redirectToBilling: true, subscription: null, 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 createRes = await admin.graphql(`
mutation {
appSubscriptionCreate(
name: "${PLAN_NAME}"
returnUrl: "https://your-app.com/after-billing"
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 || "";
// 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: `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 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

@ -0,0 +1,337 @@
import React, { useState, useEffect } from "react";
import { json } from "@remix-run/node";
import { useLoaderData, useActionData, useSubmit, useNavigate } from "@remix-run/react";
import {
Page,
Layout,
Card,
BlockStack,
Text,
Badge,
InlineStack,
Image,
Divider,
Button,
Modal,
TextField,
Box,
Link,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import data4autosLogo from "../assets/data4autos_logo.png"; // make sure this exists
import turn14DistributorLogo from "../assets/turn14-logo.png";
import { authenticate } from "../shopify.server"; // Shopify server authentication
import { Form } from "@remix-run/react";
// Loader to check subscription status
export const loader = async ({ request }) => {
const { admin } = await authenticate.admin(request);
// Query the current subscription status
const resp = await admin.graphql(`
query {
currentAppInstallation {
activeSubscriptions {
id
status
trialDays
createdAt
currentPeriodEnd
}
}
}
`);
const result = await resp.json();
const subscription = result.data.currentAppInstallation.activeSubscriptions[0] || null;
const { session } = await authenticate.admin(request);
const shop = session.shop;
// For new users, there's no subscription. We will show a "Not subscribed" message.
if (!subscription) {
return json({ redirectToBilling: true, subscription: null,shop });
}
// If no active or trial subscription, return redirect signal
if (subscription.status !== "ACTIVE" && subscription.status !== "TRIAL") {
return json({ redirectToBilling: true, subscription ,shop });
}
return json({ redirectToBilling: false, subscription,shop });
};
// Action to create subscription
export const action = async ({ request }) => {
console.log("Creating subscription...");
const { admin } = await authenticate.admin(request);
const createRes = await admin.graphql(`
mutation {
appSubscriptionCreate(
name: "Pro Plan",
returnUrl: "https://your-app.com/after-billing",
lineItems: [
{
plan: {
appRecurringPricingDetails: {
price: { amount: 19.99, currencyCode: USD },
interval: EVERY_30_DAYS
}
}
}
],
trialDays: 7, # trialDays is a top-level argument!
test: true
) {
confirmationUrl
appSubscription {
id
status
trialDays
}
userErrors {
field
message
}
}
}
`);
const data = await createRes.json();
console.log("Subscription creation response:", data);
if (data.errors || !data.data.appSubscriptionCreate.confirmationUrl) {
return json({ errors: ["Failed to create subscription.",data] }, { status: 400 });
}
console.log("Subscription created successfully:", data.data.appSubscriptionCreate.confirmationUrl);
return json({
confirmationUrl: data.data.appSubscriptionCreate.confirmationUrl
});
};
export default function Index() {
const actionData = useActionData();
const loaderData = useLoaderData();
const submit = useSubmit(); // Use submit to trigger the action
const [activeModal, setActiveModal] = useState(false);
const subscription = loaderData?.subscription;
const shop = loaderData?.shop;
console.log("Shop domain from loader data:", subscription);
// useEffect(() => {
// console.log("Action data:", actionData);
// // If we have a confirmation URL, redirect to it
// if (actionData?.confirmationUrl) {
// window.location.href = actionData.confirmationUrl; // Redirect to Shopify's billing confirmation page
// }
// }, [actionData]);
// const navigate = useNavigate();
// useEffect(() => {
// if (actionData?.confirmationUrl) {
// navigate(actionData.confirmationUrl, { target: "new" }); // or "host" for embedded
// setActiveModal(false);
// }
// }, [actionData]);
const navigate = useNavigate();
useEffect(() => {
if (actionData?.confirmationUrl) {
navigate(actionData.confirmationUrl, { target: "new" }); // or "new" for a new tab
setActiveModal(false);
}
}, [actionData]);
// useEffect(() => {
// if (actionData?.confirmationUrl) {
// window.open(actionData.confirmationUrl, "_blank", "noopener,noreferrer");
// setActiveModal(false); // close the modal
// }
// }, [actionData]);
const openModal = () => setActiveModal(true);
const closeModal = () => setActiveModal(false);
// const items = [
// { icon: "", text: "Manage API settings", link: "https://admin.shopify.com/store/veloxautomotive/apps/d4a-turn14/app/settings" },
// { icon: "🏷", text: "Browse and import available brands", link: "https://admin.shopify.com/store/veloxautomotive/apps/d4a-turn14/app/brands" },
// { icon: "📦", text: "Sync brand collections to Shopify", link: "https://admin.shopify.com/store/veloxautomotive/apps/d4a-turn14/app/managebrand" },
// { icon: "🔐", text: "Handle secure Turn14 login credentials", link: "https://admin.shopify.com/store/veloxautomotive/apps/d4a-turn14/app/help" },
// ];
const shopDomain = (shop || "").split(".")[0];; // from the GraphQL query above
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` },
];
return (
<Page>
<TitleBar title="Data4Autos Turn14 Integration" />
<Layout>
<Layout.Section>
<Card padding="500">
<BlockStack gap="400">
<BlockStack gap="400" align="center">
{/* Centered Heading */}
<Text variant="headingLg" as="h1" alignment="center">
Welcome to your Turn14 Dashboard
</Text>
{/* Logos Row */}
<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" // top padding
paddingBlockEnd="800" // bottom padding
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" style={{ textDecoration: "none", color: "primary" }}>
<Text as="h6" alignment="center" fontWeight="bold" variant="headingMd">
{item.text}
</Text>
</a>
</BlockStack>
</Card>
))}
</Box>
</InlineStack>
</BlockStack>
<Divider />
<BlockStack gap="400">
{/* Status Badge */}
<InlineStack align="center" gap="400">
<Badge tone="success">Status: Connected</Badge>
<Text tone="subdued">Shopify × Turn14</Text>
</InlineStack>
{/* Support Info */}
<Text tone="subdued" alignment="center">
Need help? Contact us at{" "}
<a href="mailto:support@data4autos.com">
support@data4autos.com
</a>
</Text>
{/* CTA Button */}
<Button
size="large"
variant="primary"
onClick={openModal}
fullWidth
>
{loaderData?.redirectToBilling
? "Proceed to Billing"
: "View Subscription Details"}
</Button>
</BlockStack>
</BlockStack>
</Card>
</Layout.Section>
</Layout>
{/* Modal for Subscription Info */}
<Modal
open={activeModal}
onClose={closeModal}
title="Subscription Details"
// primaryAction={{
// content: "Proceed to Billing",
// onAction: () => {
// submit(null, { method: "post", form: document.getElementById("billing-form") });
// },
// }}
primaryAction={{
content: "Proceed to Billing",
onAction: () => {
submit(null, { method: "post", form: document.getElementById("billing-form") });
},
}}
secondaryActions={[{ content: "Close", onAction: closeModal }]}
>
<Form id="billing-form" method="post">
<Modal.Section>
<BlockStack gap="100">
<TextField
label="Subscription Status"
value={subscription ? subscription.status : "No active subscription"}
disabled
/>
<TextField
label="Trial Days Left"
value={subscription?.trialDays ? `${subscription.trialDays} days left` : "No trial available"}
disabled
/>
<TextField
label="Subscription Plan"
value={subscription?.id ? "Pro Plan" : "Not subscribed"}
disabled
/>
<TextField
label="Trial Expiration Date"
value={subscription?.currentPeriodEnd ? new Date(subscription.currentPeriodEnd).toLocaleDateString() : "N/A"}
disabled
/>
</BlockStack>
</Modal.Section>
</Form>
</Modal>
</Page>
);
}

View File

@ -1,13 +1,12 @@
import React, { useState, useEffect } from "react"; import React, { useMemo, useState } from "react";
import { json } from "@remix-run/node"; import { json } from "@remix-run/node";
import { useLoaderData, useActionData, useSubmit } from "@remix-run/react"; import { useLoaderData, useActionData, useSubmit, Form } from "@remix-run/react";
import { import {
Page, Page,
Layout, Layout,
Card, Card,
BlockStack, BlockStack,
Text, Text,
Badge,
InlineStack, InlineStack,
Image, Image,
Divider, Divider,
@ -15,22 +14,28 @@ import {
Modal, Modal,
TextField, TextField,
Box, Box,
Link, Banner,
ChoiceList,
} 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"; // make sure this exists import data4autosLogo from "../assets/data4autos_logo.png"; // ensure this exists
import turn14DistributorLogo from "../assets/turn14-logo.png"; import turn14DistributorLogo from "../assets/turn14-logo.png";
import { authenticate } from "../shopify.server"; // Shopify server authentication import { authenticate } from "../shopify.server";
import { Form } from "@remix-run/react"; /* ===========================
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
// Loader to check subscription status =========================== */
export const loader = async ({ request }) => { export const loader = async ({ request }) => {
const { admin } = await authenticate.admin(request); const { admin } = await authenticate.admin(request);
// Query the current subscription status
const resp = await admin.graphql(` const resp = await admin.graphql(`
query { query {
currentAppInstallation { currentAppInstallation {
@ -46,119 +51,196 @@ export const loader = async ({ request }) => {
`); `);
const result = await resp.json(); const result = await resp.json();
const subscription = result.data.currentAppInstallation.activeSubscriptions[0] || null; 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;
if (!subscription) {
return json({ redirectToBilling: true, subscription: null, 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 { session } = await authenticate.admin(request);
const shop = session.shop; const shop = session.shop;
// For new users, there's no subscription. We will show a "Not subscribed" message. const shopDomain = (shop || "").split(".")[0];
if (!subscription) {
return json({ redirectToBilling: true, subscription: null,shop });
}
// If no active or trial subscription, return redirect signal
if (subscription.status !== "ACTIVE" && subscription.status !== "TRIAL") {
return json({ redirectToBilling: true, subscription ,shop });
}
return json({ redirectToBilling: false, subscription,shop });
};
// Action to create subscription const origin = new URL(request.url).origin;
export const action = async ({ request }) => { const returnUrl = `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app`;
console.log("Creating subscription...");
const { admin } = await authenticate.admin(request);
const createRes = await admin.graphql(` const createRes = await admin.graphql(`
mutation { mutation {
appSubscriptionCreate( appSubscriptionCreate(
name: "Pro Plan", name: "${PLAN_NAME}"
returnUrl: "https://your-app.com/after-billing", returnUrl: "${returnUrl}"
lineItems: [ lineItems: [
{ {
plan: { plan: {
appRecurringPricingDetails: { appRecurringPricingDetails: {
price: { amount: 19.99, currencyCode: USD }, price: { amount: ${amount}, currencyCode: USD }
interval: EVERY_30_DAYS interval: ${interval}
}
}
} }
} ]
trialDays: ${TRIAL_DAYS}
test: false
) {
confirmationUrl
appSubscription { id status trialDays }
userErrors { field message }
} }
],
trialDays: 7, # trialDays is a top-level argument!
test: true
) {
confirmationUrl
appSubscription {
id
status
trialDays
} }
userErrors {
field
message
}
}
}
`); `);
const data = await createRes.json(); const data = await createRes.json();
console.log("Subscription creation response:", data);
if (data.errors || !data.data.appSubscriptionCreate.confirmationUrl) { const url =
return json({ errors: ["Failed to create subscription."] }, { status: 400 }); 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 }
);
} }
console.log("Subscription created successfully:", data.data.appSubscriptionCreate.confirmationUrl);
return json({ return json({ confirmationUrl: url });
confirmationUrl: data.data.appSubscriptionCreate.confirmationUrl
});
}; };
/* ===========================
PAGE
=========================== */
export default function Index() { export default function Index() {
const actionData = useActionData(); const actionData = useActionData();
const loaderData = useLoaderData(); const loaderData = useLoaderData();
const submit = useSubmit(); // Use submit to trigger the action const submit = useSubmit();
const [activeModal, setActiveModal] = useState(false); const [activeModal, setActiveModal] = useState(false);
const subscription = loaderData?.subscription; const subscription = loaderData?.subscription || null;
const shop = loaderData?.shop; const shop = loaderData?.shop || "";
// useEffect(() => { // Cadence selection for the billing action
// console.log("Action data:", actionData); const [cadence, setCadence] = useState("MONTHLY");
// // If we have a confirmation URL, redirect to it const hasConfirmationUrl = Boolean(actionData && actionData.confirmationUrl);
// if (actionData?.confirmationUrl) { const errors = (actionData && actionData.errors) || [];
// window.location.href = actionData.confirmationUrl; // Redirect to Shopify's billing confirmation page
// }
// }, [actionData]);
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`,
},
];
useEffect(() => {
if (actionData?.confirmationUrl) {
window.open(actionData.confirmationUrl, "_blank", "noopener,noreferrer");
setActiveModal(false); // close the modal
}
}, [actionData]);
const openModal = () => setActiveModal(true); const openModal = () => setActiveModal(true);
const closeModal = () => setActiveModal(false); const closeModal = () => setActiveModal(false);
// const items = [ /* ===========================
// { icon: "", text: "Manage API settings", link: "https://admin.shopify.com/store/veloxautomotive/apps/d4a-turn14/app/settings" }, Helpers & preview model
// { icon: "🏷", text: "Browse and import available brands", link: "https://admin.shopify.com/store/veloxautomotive/apps/d4a-turn14/app/brands" }, =========================== */
// { icon: "📦", text: "Sync brand collections to Shopify", link: "https://admin.shopify.com/store/veloxautomotive/apps/d4a-turn14/app/managebrand" }, const formatDate = (d) =>
// { icon: "🔐", text: "Handle secure Turn14 login credentials", link: "https://admin.shopify.com/store/veloxautomotive/apps/d4a-turn14/app/help" }, new Date(d).toLocaleDateString(undefined, {
// ]; year: "numeric",
month: "short",
day: "numeric",
});
const shopDomain = (shop || "").split(".")[0];; // from the GraphQL query above // 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;
const items = [ // Preview trial end is TRIAL_DAYS from "now" (or from subscription.createdAt if present)
{ icon: "⚙️", text: "Manage API settings", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/settings` }, const previewBase = subscription?.createdAt ? new Date(subscription.createdAt) : new Date();
{ icon: "🏷️", text: "Browse and import available brands", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/brands` }, const previewTrialEnd = new Date(previewBase);
{ icon: "📦", text: "Sync brand collections to Shopify", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/managebrand` }, previewTrialEnd.setDate(previewTrialEnd.getDate() + TRIAL_DAYS);
{ icon: "🔐", text: "Handle secure Turn14 login credentials", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/help` },
]; // 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 ( return (
<Page> <Page>
@ -167,20 +249,13 @@ const items = [
<Layout.Section> <Layout.Section>
<Card padding="500"> <Card padding="500">
<BlockStack gap="400"> <BlockStack gap="400">
<BlockStack gap="400" align="center"> <BlockStack gap="400" align="center">
{/* Centered Heading */}
<Text variant="headingLg" as="h1" alignment="center"> <Text variant="headingLg" as="h1" alignment="center">
Welcome to your Turn14 Dashboard Welcome to your Turn14 Dashboard
</Text> </Text>
{/* Logos Row */}
<InlineStack gap="800" align="center" blockAlign="center"> <InlineStack gap="800" align="center" blockAlign="center">
<Image <Image source={data4autosLogo} alt="Data4Autos Logo" width={120} />
source={data4autosLogo}
alt="Data4Autos Logo"
width={120}
/>
<Image <Image
source={turn14DistributorLogo} source={turn14DistributorLogo}
alt="Turn14 Distributors Logo" alt="Turn14 Distributors Logo"
@ -194,18 +269,18 @@ const items = [
<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
product brands, manage collections, and automate catalog setup directly from product brands, manage collections, and automate catalog setup
Turn14 to your Shopify store. directly from Turn14 to your Shopify store.
</Text> </Text>
<InlineStack gap="400"> <InlineStack gap="400">
<Text as="h3" variant="headingLg" fontWeight="medium"> <Text as="h3" variant="headingLg" fontWeight="medium">
{/* 🔧 */}
Use the left sidebar to: Use the left sidebar to:
</Text> </Text>
<Box
paddingBlockStart="800" // top padding
paddingBlockEnd="800" // bottom padding
<Box
paddingBlockStart="800"
paddingBlockEnd="800"
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "repeat(4, 1fr)", gridTemplateColumns: "repeat(4, 1fr)",
@ -215,10 +290,21 @@ const items = [
{items.map((item, index) => ( {items.map((item, index) => (
<Card key={index} padding="500" background="bg-surface-secondary"> <Card key={index} padding="500" background="bg-surface-secondary">
<BlockStack align="center" gap="200"> <BlockStack align="center" gap="200">
<Text as="p" fontWeight="bold" alignment="center" tone="subdued" variant="bodyMd"> <Text
as="p"
fontWeight="bold"
alignment="center"
tone="subdued"
variant="bodyMd"
>
<span style={{ fontSize: "2rem" }}>{item.icon}</span> <span style={{ fontSize: "2rem" }}>{item.icon}</span>
</Text> </Text>
<a href={item?.link} target="_blank" style={{ textDecoration: "none", color: "primary" }}> <a
href={item.link}
target="_blank"
rel="noopener noreferrer"
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>
@ -233,82 +319,185 @@ const items = [
<Divider /> <Divider />
<BlockStack gap="400"> <BlockStack gap="400">
{/* Status Badge */}
<InlineStack align="center" gap="400">
<Badge tone="success">Status: Connected</Badge>
<Text tone="subdued">Shopify × Turn14</Text>
</InlineStack>
{/* Support Info */}
<Text tone="subdued" alignment="center"> <Text tone="subdued" alignment="center">
Need help? Contact us at{" "} Need help? Contact us at{" "}
<a href="mailto:support@data4autos.com"> <a href="mailto:support@data4autos.com">support@data4autos.com</a>
support@data4autos.com
</a>
</Text> </Text>
{/* CTA Button */} <Button size="large" variant="primary" onClick={openModal} fullWidth>
<Button {loaderData && loaderData.redirectToBilling
size="large"
variant="primary"
onClick={openModal}
fullWidth
>
{loaderData?.redirectToBilling
? "Proceed to Billing" ? "Proceed to Billing"
: "View Subscription Details"} : "View Subscription Details"}
</Button> </Button>
</BlockStack> </BlockStack>
</BlockStack> </BlockStack>
</Card> </Card>
</Layout.Section> </Layout.Section>
</Layout> </Layout>
{/* Modal for Subscription Info */} {/* ===========================
MODAL
=========================== */}
<Modal <Modal
open={activeModal} open={activeModal}
onClose={closeModal} onClose={closeModal}
title="Subscription Details" title="Subscription Details"
// primaryAction={{ primaryAction={
// content: "Proceed to Billing", hasConfirmationUrl
// onAction: () => { ? undefined
// submit(null, { method: "post", form: document.getElementById("billing-form") }); : {
// }, content:
// }} cadence === "ANNUAL"
primaryAction={{ ? `Start ${TRIAL_DAYS}-day Trial — $${ANNUAL_AMOUNT}/yr`
content: "Proceed to Billing", : `Start ${TRIAL_DAYS}-day Trial — $${MONTHLY_AMOUNT}/mo`,
onAction: () => { onAction: () => {
submit(null, { method: "post", form: document.getElementById("billing-form") }); 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 }]} 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 />
<Modal.Section> <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>
)}
<BlockStack gap="100"> {!hasConfirmationUrl && (
<TextField <>
label="Subscription Status" {/* ===== Top read-only fields (now preview-aware) ===== */}
value={subscription ? subscription.status : "No active subscription"} <TextField label="Subscription Status" value={displayStatus} readOnly />
disabled
/> <TextField
<TextField label="Plan"
label="Trial Days Left" value={displayPlan}
value={subscription?.trialDays ? `${subscription.trialDays} days left` : "No trial available"} helpText={
disabled subscription
/> ? "Showing your selection; actual plan updates after checkout."
<TextField : "Preview of the plan that will be created at checkout."
label="Subscription Plan" }
value={subscription?.id ? "Pro Plan" : "Not subscribed"} readOnly
disabled />
/>
<TextField <TextField
label="Trial Expiration Date" label="Billing Interval"
value={subscription?.currentPeriodEnd ? new Date(subscription.currentPeriodEnd).toLocaleDateString() : "N/A"} value={cadence === "ANNUAL" ? "Every 12 months" : "Every 30 days"}
disabled 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> </BlockStack>
</Modal.Section> </Modal.Section>
</Form> </Form>

View File

@ -0,0 +1,568 @@
import React, { useMemo, useState } from "react";
import { json } from "@remix-run/node";
import { useLoaderData, useActionData, useSubmit } from "@remix-run/react";
import {
Page,
Layout,
Card,
BlockStack,
Text,
InlineStack,
Image,
Divider,
Button,
Modal,
Box,
Banner,
Badge,
Tooltip,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import data4autosLogo from "../assets/data4autos_logo.png";
import turn14DistributorLogo from "../assets/turn14-logo.png";
import { authenticate } from "../shopify.server";
import { Form } from "@remix-run/react";
/* ===========================
PLAN CATALOG (edit freely)
=========================== */
const PLANS = [
{
id: "starter",
name: "Starter Sync",
badge: "New",
highlight: false,
features: [
"Guided Turn14 → Shopify import",
"Auto inventory & price updates",
"Editable title/description helpers",
"Email support",
],
periods: [
{ id: "monthly", label: "Monthly", interval: "EVERY_30_DAYS", amount: 79, currencyCode: "USD" },
{ id: "annual", label: "Annual", interval: "ANNUAL", amount: 790, currencyCode: "USD", sublabel: "2 months free" },
],
},
{
id: "growth",
name: "Growth",
badge: "Popular",
highlight: true,
features: [
"Everything in Starter",
"Bulk brand imports",
"Smart collection sync",
"Basic error diagnostics",
],
periods: [
{ id: "monthly", label: "Monthly", interval: "EVERY_30_DAYS", amount: 129, currencyCode: "USD" },
{ id: "annual", label: "Annual", interval: "ANNUAL", amount: 1290, currencyCode: "USD", sublabel: "2 months free" },
],
},
{
id: "pro",
name: "Pro",
badge: "For Teams",
highlight: false,
features: [
"Everything in Growth",
"Automated brand metadata",
"Advanced mapping rules",
"Priority email support",
],
periods: [
{ id: "monthly", label: "Monthly", interval: "EVERY_30_DAYS", amount: 199, currencyCode: "USD" },
{ id: "annual", label: "Annual", interval: "ANNUAL", amount: 1990, currencyCode: "USD", sublabel: "2 months free" },
],
},
{
id: "scale",
name: "Scale",
badge: "Best Value",
highlight: false,
features: [
"Everything in Pro",
"Unlimited brand sync",
"Enhanced audit logs",
"Slack alerts",
],
periods: [
{ id: "monthly", label: "Monthly", interval: "EVERY_30_DAYS", amount: 399, currencyCode: "USD" },
{ id: "annual", label: "Annual", interval: "ANNUAL", amount: 3990, currencyCode: "USD", sublabel: "2 months free" },
],
},
{
id: "enterprise",
name: "Enterprise",
badge: "Custom",
highlight: false,
features: [
"Everything in Scale",
"SLA & dedicated onboarding",
"Solution architect sessions",
"Custom integrations",
],
periods: [
{ id: "monthly", label: "Monthly", interval: "EVERY_30_DAYS", amount: 799, currencyCode: "USD" },
{ id: "annual", label: "Annual", interval: "ANNUAL", amount: 7990, currencyCode: "USD", sublabel: "2 months free" },
],
},
];
/* ===========================
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?.data?.currentAppInstallation?.activeSubscriptions?.[0] || null;
const { session } = await authenticate.admin(request);
const shop = session.shop;
if (!subscription) {
return json({ redirectToBilling: true, subscription: null, shop, plans: PLANS });
}
if (subscription.status !== "ACTIVE" && subscription.status !== "TRIAL") {
return json({ redirectToBilling: true, subscription, shop, plans: PLANS });
}
return json({ redirectToBilling: false, subscription, shop, plans: PLANS });
};
/* ===========================
ACTION: create subscription
=========================== */
export const action = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const formData = await request.formData();
const planId = String(formData.get("planId") || "");
const periodId = String(formData.get("periodId") || "");
const trialDays = Number(formData.get("trialDays") || 14); // change default if needed
const plan = PLANS.find((p) => p.id === planId);
const period = plan?.periods.find((pr) => pr.id === periodId);
if (!plan || !period) {
return json(
{ errors: ["Invalid plan or billing period selection. Please try again."] },
{ status: 400 }
);
}
// Build a safe returnUrl from the incoming request origin
const urlObj = new URL(request.url);
const origin = `${urlObj.protocol}//${urlObj.host}`;
const returnUrl = `${origin}/app/after-billing`; // adjust if your route differs
const gql = `
mutation appSubscriptionCreate(
$name: String!,
$returnUrl: URL!,
$price: MoneyInput!,
$interval: AppBillingInterval!,
$trialDays: Int,
$test: Boolean
) {
appSubscriptionCreate(
name: $name
returnUrl: $returnUrl
lineItems: [
{
plan: {
appRecurringPricingDetails: {
price: $price
interval: $interval
}
}
}
]
trialDays: $trialDays
test: $test
) {
confirmationUrl
appSubscription { id status trialDays }
userErrors { field message }
}
}
`;
const variables = {
name: `${plan.name} (${period.label})`,
returnUrl,
price: { amount: period.amount, currencyCode: period.currencyCode },
interval: period.interval, // "EVERY_30_DAYS" | "ANNUAL"
trialDays,
test: true, // flip to false in production
};
const createRes = await admin.graphql(gql, { variables });
const data = await createRes.json();
const confirmationUrl = data?.data?.appSubscriptionCreate?.confirmationUrl;
const userErrors = data?.data?.appSubscriptionCreate?.userErrors || [];
const topLevelErrors = data?.errors || [];
if (!confirmationUrl || 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 });
};
/* ===========================
PAGE
=========================== */
export default function Index() {
const actionData = useActionData();
const loaderData = useLoaderData();
const submit = useSubmit();
const [activeModal, setActiveModal] = useState(false);
const subscription = loaderData?.subscription;
const shop = loaderData?.shop;
const plans = loaderData?.plans || PLANS;
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 openModal = () => setActiveModal(true);
const closeModal = () => setActiveModal(false);
const hasConfirmationUrl = Boolean(actionData?.confirmationUrl);
const errors = actionData?.errors || [];
// Selection state (for the beautiful plan picker)
const [selectedPlanId, setSelectedPlanId] = useState(plans[0]?.id ?? "starter");
const selectedPlan = useMemo(
() => plans.find((p) => p.id === selectedPlanId),
[plans, selectedPlanId]
);
// default to monthly
const [selectedPeriodId, setSelectedPeriodId] = useState(
selectedPlan?.periods?.[0]?.id ?? "monthly"
);
// keep period valid when switching plans
React.useEffect(() => {
if (!selectedPlan) return;
const exists = selectedPlan.periods.some((p) => p.id === selectedPeriodId);
if (!exists) setSelectedPeriodId(selectedPlan.periods[0].id);
}, [selectedPlanId]);
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 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">
Quick links
</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" 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>
<InlineStack align="center" blockAlign="center">
{subscription ? (
<Badge tone="success">
{subscription.status} Trial: {subscription.trialDays ?? 0}d
</Badge>
) : (
<Badge tone="attention">No active subscription</Badge>
)}
</InlineStack>
<Button size="large" variant="primary" onClick={openModal} fullWidth>
{loaderData?.redirectToBilling ? "Choose a Plan" : "View/Change Plan"}
</Button>
</BlockStack>
</BlockStack>
</Card>
</Layout.Section>
</Layout>
{/* ===========================
BEAUTIFUL PLAN PICKER MODAL
=========================== */}
<Modal
open={activeModal}
onClose={closeModal}
title={hasConfirmationUrl ? "Confirm Billing" : "Choose your plan"}
primaryAction={
hasConfirmationUrl
? undefined
: {
content: "Proceed to Billing",
onAction: () => {
const form = document.getElementById("billing-form") || null;
if (form) submit(form);
},
disabled: !selectedPlan || !selectedPeriodId,
}
}
secondaryActions={[{ content: "Close", onAction: closeModal }]}
large
>
<Form id="billing-form" method="post">
<Modal.Section>
<BlockStack gap="500">
{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 && (
<>
{/* Hidden fields sent to the action */}
<input type="hidden" name="planId" value={selectedPlanId} />
<input type="hidden" name="periodId" value={selectedPeriodId} />
<input type="hidden" name="trialDays" value={14} />
{/* Period toggle (Monthly / Annual) */}
<InlineStack gap="300" align="center" blockAlign="center">
{selectedPlan?.periods.map((period) => {
const isActive = selectedPeriodId === period.id;
return (
<Button
key={period.id}
variant={isActive ? "primary" : "secondary"}
onClick={() => setSelectedPeriodId(period.id)}
>
<InlineStack gap="150" align="center">
<span>{period.label}</span>
{period.sublabel ? (
<Tooltip content={period.sublabel}>
<Badge tone="success">Save</Badge>
</Tooltip>
) : null}
</InlineStack>
</Button>
);
})}
</InlineStack>
{/* Plan tiles */}
<Box
paddingBlockStart="400"
style={{
display: "grid",
gridTemplateColumns: "repeat(12, 1fr)",
gap: "1rem",
}}
>
{plans.map((plan) => {
const active = selectedPlanId === plan.id;
const p = plan.periods.find((pp) => pp.id === selectedPeriodId) ?? plan.periods[0];
return (
<Card
key={plan.id}
onClick={() => setSelectedPlanId(plan.id)}
padding="500"
background={active ? "bg-surface" : "bg-surface-secondary"}
sectioned
roundedAbove="sm"
style={{
gridColumn: "span 6",
cursor: "pointer",
border: active ? "2px solid var(--p-color-border-interactive)" : "1px solid var(--p-color-border-subdued)",
boxShadow: active ? "var(--p-shadow-lg)" : "var(--p-shadow-sm)",
transition: "all .2s ease",
}}
>
<BlockStack gap="300">
<InlineStack align="space-between" blockAlign="center">
<Text as="h3" variant="headingLg">
{plan.name}
</Text>
{plan.badge && (
<Badge tone={plan.highlight ? "success" : "attention"}>{plan.badge}</Badge>
)}
</InlineStack>
<InlineStack gap="100" blockAlign="baseline">
<Text as="p" variant="heading2xl" fontWeight="bold">
${p.amount}
</Text>
<Text as="p" tone="subdued">
{p.interval === "ANNUAL" ? "/yr" : "/mo"} {p.currencyCode}
</Text>
</InlineStack>
<Divider />
<BlockStack gap="200">
{plan.features.map((f) => (
<InlineStack key={f} gap="200" blockAlign="center">
<span aria-hidden></span>
<Text as="p">{f}</Text>
</InlineStack>
))}
</BlockStack>
<Divider />
<InlineStack align="space-between" blockAlign="center">
<Text tone="subdued">14-day free trial</Text>
<Button variant={active ? "primary" : "secondary"}>Select</Button>
</InlineStack>
</BlockStack>
</Card>
);
})}
</Box>
</>
)}
{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

@ -0,0 +1,364 @@
import React, { useState } from "react";
import { json } from "@remix-run/node";
import { useLoaderData, useActionData, useSubmit } from "@remix-run/react";
import {
Page,
Layout,
Card,
BlockStack,
Text,
Badge,
InlineStack,
Image,
Divider,
Button,
Modal,
TextField,
Box,
Banner,
} 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";
import { Form } from "@remix-run/react";
/* ===========================
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?.data?.currentAppInstallation?.activeSubscriptions?.[0] || null;
const { session } = await authenticate.admin(request);
const shop = session.shop;
if (!subscription) {
return json({ redirectToBilling: true, subscription: null, shop });
}
if (subscription.status !== "ACTIVE" && subscription.status !== "TRIAL") {
return json({ redirectToBilling: true, subscription, shop });
}
return json({ redirectToBilling: false, subscription, shop });
};
/* ===========================
ACTION: create subscription
=========================== */
export const action = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const createRes = await admin.graphql(`
mutation {
appSubscriptionCreate(
name: "Pro Plan"
returnUrl: "https://your-app.com/after-billing"
lineItems: [
{
plan: {
appRecurringPricingDetails: {
price: { amount: 79, currencyCode: USD }
interval: EVERY_30_DAYS
}
}
}
]
trialDays: 7
test: true
) {
confirmationUrl
appSubscription { id status trialDays }
userErrors { field message }
}
}
`);
const data = await createRes.json();
const url = data?.data?.appSubscriptionCreate?.confirmationUrl;
const userErrors = data?.data?.appSubscriptionCreate?.userErrors || [];
const topLevelErrors = data?.errors || [];
if (!url || userErrors.length || topLevelErrors.length) {
return json(
{
errors: [
"Failed to create subscription.",
...userErrors.map((e) => e.message),
...topLevelErrors.map((e) => e.message || String(e)),
],
},
{ status: 400 }
);
}
return json({ confirmationUrl: url });
};
/* ===========================
PAGE
=========================== */
export default function Index() {
const actionData = useActionData();
const loaderData = useLoaderData();
const submit = useSubmit();
const [activeModal, setActiveModal] = useState(false);
const subscription = loaderData?.subscription;
const shop = loaderData?.shop;
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 openModal = () => setActiveModal(true);
const closeModal = () => setActiveModal(false);
const hasConfirmationUrl = Boolean(actionData?.confirmationUrl);
const errors = actionData?.errors || [];
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>
{/* Real anchor to avoid double-URL issues */}
<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">
{/* <InlineStack align="center" gap="400">
<Badge tone="success">Status: Connected</Badge>
<Text tone="subdued">Shopify × Turn14</Text>
</InlineStack> */}
<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?.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 // once we have URL, we swap primary action out (we show a link button instead)
: {
content: "Proceed to Billing",
onAction: () => {
const form = document.getElementById("billing-form");
submit(form);
},
}
}
secondaryActions={[{ content: "Close", onAction: closeModal }]}
>
<Form id="billing-form" method="post">
<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 && (
<>
<TextField
label="Subscription Status"
value={subscription ? subscription.status : "No active subscription"}
disabled
/>
<TextField
label="Trial Days Left"
value={
subscription?.trialDays
? `${subscription.trialDays} days left`
: "No trial available"
}
disabled
/>
<TextField
label="Subscription Plan"
value={subscription?.id ? "Pro Plan" : "Not subscribed"}
disabled
/>
<TextField
label="Trial Expiration Date"
value={
subscription?.currentPeriodEnd
? new Date(subscription.currentPeriodEnd).toLocaleDateString()
: "N/A"
}
disabled
/>
</>
)}
{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>
{/* Polaris Button-as-link opens absolute URL in a new tab */}
<Button
url={actionData.confirmationUrl}
target="_blank"
external
onClick={() => {
// optional: close the modal after user clicks
setActiveModal(false);
}}
variant="primary"
size="large"
>
Open Billing Confirmation
</Button>
{/* Plain anchor fallback (optional) */}
<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

@ -0,0 +1,377 @@
// app/routes/app.billing.success.jsx
import React, { useMemo } from "react";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import {
Page,
Layout,
Card,
BlockStack,
InlineStack,
Text,
Badge,
Divider,
Button,
Banner,
Box,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
/** ===========================
* LOADER
* =========================== */
export const loader = async ({ request }) => {
const { admin, session } = await authenticate.admin(request);
const shop = session.shop;
const shopDomain = shop.split(".")[0];
// Pull richer subscription details: interval + price
const resp = await admin.graphql(`
query ActiveSubForSuccess {
currentAppInstallation {
activeSubscriptions {
id
name
status
trialDays
createdAt
currentPeriodEnd
test
lineItems {
plan {
appRecurringPricingDetails {
interval
price { amount currencyCode }
}
}
}
}
}
}
`);
const result = await resp.json();
const subscription =
result?.data?.currentAppInstallation?.activeSubscriptions?.[0] || null;
// Detect recent activation (today or last 2 days)
let recentActivation = false;
if (subscription?.createdAt) {
const created = new Date(subscription.createdAt);
const now = new Date();
const diffMs = now.getTime() - created.getTime();
const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
recentActivation =
diffMs >= 0 &&
diffMs <= TWO_DAYS_MS &&
(subscription.status === "ACTIVE" || subscription.status === "TRIAL");
}
return json({ subscription, shop, shopDomain, recentActivation });
};
/** ===========================
* HELPERS
* =========================== */
function formatDate(d) {
if (!d) return "N/A";
return new Date(d).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function getRecurringLine(subscription) {
const items = subscription?.lineItems || [];
for (const li of items) {
const r = li?.plan?.appRecurringPricingDetails;
if (r) return r;
}
return null;
}
function intervalToLabel(interval) {
switch (interval) {
case "ANNUAL":
return "Annual";
case "EVERY_30_DAYS":
return "Monthly";
default:
return interval || "N/A";
}
}
/** ===========================
* PAGE
* =========================== */
export default function BillingSuccess() {
const { subscription, shop, shopDomain, recentActivation } = useLoaderData();
const recurring = getRecurringLine(subscription);
const cadenceLabel = intervalToLabel(recurring?.interval);
const priceText =
recurring?.price?.amount && recurring?.price?.currencyCode
? `${recurring.price.amount} ${recurring.price.currencyCode}`
: "N/A";
// Trial end
const trialEndStr = useMemo(() => {
if (!subscription?.trialDays || !subscription?.createdAt) return "N/A";
const start = new Date(subscription.createdAt);
const end = new Date(start);
end.setDate(end.getDate() + subscription.trialDays);
return formatDate(end);
}, [subscription?.trialDays, subscription?.createdAt]);
// Trial days left (if still on TRIAL)
const trialDaysLeft = useMemo(() => {
if (
subscription?.status !== "TRIAL" ||
!subscription?.trialDays ||
!subscription?.createdAt
)
return null;
const start = new Date(subscription.createdAt);
const trialEnd = new Date(start);
trialEnd.setDate(trialEnd.getDate() + subscription.trialDays);
const now = new Date();
const left = Math.ceil(
(trialEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
);
return Math.max(0, left);
}, [subscription?.status, subscription?.trialDays, subscription?.createdAt]);
const showError = !subscription;
const nextRenewal = formatDate(subscription?.currentPeriodEnd);
const createdAt = formatDate(subscription?.createdAt);
return (
<Page>
<TitleBar title="Payment & Subscription" />
<Layout>
<Layout.Section>
{showError ? (
<Banner title="No active subscription found" tone="critical">
<p>
We couldnt find an active subscription for this shop. If you just
approved billing, it may not be visible yet. You can proceed to the
billing screen to create or refresh your subscription.
</p>
<InlineStack gap="400" wrap={false}>
<Button url={`/app`} variant="primary">
Go to Dashboard
</Button>
<Button url={`/app`} variant="secondary">
Proceed to Billing
</Button>
</InlineStack>
</Banner>
) : recentActivation ? (
<Banner title="🎉 Subscription Activated" tone="success">
<p>
Congratulations! Your plan is now active. Youre all set to sync brands,
build collections, and automate your Turn14 catalog.
</p>
<p style={{ marginTop: 8 }}>
Activated: <strong>{createdAt}</strong> &nbsp;&nbsp; Status:{" "}
<Badge tone="success">{subscription.status}</Badge>
</p>
<InlineStack gap="400" wrap={false}>
<Button
url={`https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app`}
target="_blank"
external
variant="primary"
>
Open App Dashboard
</Button>
<Button
url={`https://admin.shopify.com/store/${shopDomain}/settings/billing`}
target="_blank"
external
variant="secondary"
>
View Billing & Invoices
</Button>
</InlineStack>
</Banner>
) : (
<Banner title="Your plan is active" tone="info">
<p>
Your subscription is active. Below are the full details of your plan,
trial, and renewal.
</p>
</Banner>
)}
</Layout.Section>
<Layout.Section>
<Card padding="500">
<BlockStack gap="500">
<Text variant="headingLg" as="h2">
Plan Overview
</Text>
<Box
padding="400"
style={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: "1rem",
}}
>
<Card padding="400" background="bg-surface-secondary">
<BlockStack gap="200">
<Text as="p" tone="subdued">
Plan Name
</Text>
<Text as="p" variant="headingMd" fontWeight="bold">
{subscription?.name || "Starter Sync"}
</Text>
</BlockStack>
</Card>
<Card padding="400" background="bg-surface-secondary">
<BlockStack gap="200">
<Text as="p" tone="subdued">
Billing Cadence
</Text>
<Text as="p" variant="headingMd" fontWeight="bold">
{cadenceLabel}
</Text>
</BlockStack>
</Card>
<Card padding="400" background="bg-surface-secondary">
<BlockStack gap="200">
<Text as="p" tone="subdued">
Price
</Text>
<Text as="p" variant="headingMd" fontWeight="bold">
{priceText}
</Text>
</BlockStack>
</Card>
</Box>
<Divider />
<Text variant="headingMd" as="h3">
Billing & Trial
</Text>
<Box
padding="400"
style={{
display: "grid",
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
gap: "1rem",
}}
>
<Card padding="400" background="bg-surface-secondary">
<BlockStack gap="150">
<Text tone="subdued">Status</Text>
<InlineStack gap="200">
<Badge
tone={subscription?.status === "ACTIVE" ? "success" : "attention"}
>
{subscription?.status || "N/A"}
</Badge>
{subscription?.test && <Badge tone="warning">Test</Badge>}
</InlineStack>
</BlockStack>
</Card>
<Card padding="400" background="bg-surface-secondary">
<BlockStack gap="150">
<Text tone="subdued">Trial</Text>
<Text fontWeight="bold">
{subscription?.trialDays ? `${subscription.trialDays} days` : "N/A"}
{trialDaysLeft != null && <span> {trialDaysLeft} day(s) left</span>}
</Text>
</BlockStack>
</Card>
<Card padding="400" background="bg-surface-secondary">
<BlockStack gap="150">
<Text tone="subdued">Trial Ends</Text>
<Text fontWeight="bold">{trialEndStr}</Text>
</BlockStack>
</Card>
<Card padding="400" background="bg-surface-secondary">
<BlockStack gap="150">
<Text tone="subdued">Next Renewal / Period End</Text>
<Text fontWeight="bold">{nextRenewal}</Text>
</BlockStack>
</Card>
</Box>
<Divider />
<Text variant="headingMd" as="h3">
Subscription Metadata
</Text>
<Box
padding="400"
style={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: "1rem",
}}
>
<Card padding="400" background="bg-surface-secondary">
<BlockStack gap="150">
<Text tone="subdued">Subscription ID</Text>
<Text fontWeight="bold" breakWord>
{subscription?.id || "N/A"}
</Text>
</BlockStack>
</Card>
<Card padding="400" background="bg-surface-secondary">
<BlockStack gap="150">
<Text tone="subdued">Created / Activated</Text>
<Text fontWeight="bold">{createdAt}</Text>
</BlockStack>
</Card>
<Card padding="400" background="bg-surface-secondary">
<BlockStack gap="150">
<Text tone="subdued">Shop</Text>
<Text fontWeight="bold">{shop}</Text>
</BlockStack>
</Card>
</Box>
<Divider />
<InlineStack gap="400" align="center">
<Button
url={`https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app`}
target="_blank"
external
variant="primary"
>
Go to App Dashboard
</Button>
<Button
url={`https://admin.shopify.com/store/${shopDomain}/settings/billing`}
target="_blank"
external
variant="secondary"
>
Manage Billing / Invoices
</Button>
<Button url="mailto:support@data4autos.com" variant="tertiary">
Contact Support
</Button>
</InlineStack>
</BlockStack>
</Card>
</Layout.Section>
</Layout>
</Page>
);
}

View File

@ -38,68 +38,81 @@ async function checkShopExists(shop) {
} }
export const loader = async ({ request }) => { export const loader = async ({ request }) => {
// const accessToken = await getTurn14AccessTokenFromMetafield(request); // const accessToken = await getTurn14AccessTokenFromMetafield(request);
const { admin } = await authenticate.admin(request); const { admin } = await authenticate.admin(request);
// // 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);
// }
const { session } = await authenticate.admin(request); const { session } = await authenticate.admin(request);
const shop = session.shop; const shop = session.shop;
return json({ brands: [], collections : [], selectedBrandsFromShopify: [], 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 action = async ({ request }) => { export const action = async ({ request }) => {
@ -137,9 +150,15 @@ export const action = async ({ request }) => {
}; };
export default function BrandsPage() { export default function BrandsPage() {
const { brands, collections, selectedBrandsFromShopify, shop } = useLoaderData(); const { brands, collections, selectedBrandsFromShopify, shop ,err} = useLoaderData();
console.log(err)
// console.log(`selectedBrandsFromShopify: ${JSON.stringify(selectedBrandsFromShopify)}`); // console.log(`selectedBrandsFromShopify: ${JSON.stringify(selectedBrandsFromShopify)}`);
const actionData = useActionData() || {}; const actionData = useActionData() || {};
const [selectedIdsold, setSelectedIdsold] = useState([]) const [selectedIdsold, setSelectedIdsold] = useState([])
// const [selectedIds, setSelectedIds] = useState(() => { // const [selectedIds, setSelectedIds] = useState(() => {
// const titles = new Set(collections.map(c => c.title.toLowerCase())); // const titles = new Set(collections.map(c => c.title.toLowerCase()));
@ -265,65 +284,65 @@ export default function BrandsPage() {
]; ];
// If Turn14 is explicitly NOT connected, show a lightweight call-to-action screen // If Turn14 is explicitly NOT connected, show a lightweight call-to-action screen
if (Turn14Enabled === false) { if (Turn14Enabled === false) {
return ( return (
<Frame> <Frame>
<Page fullWidth> <Page fullWidth>
<TitleBar title="Data4Autos Turn14 Integration" background="critical" /> <TitleBar title="Data4Autos Turn14 Integration" background="critical" />
<Layout> <Layout>
<Layout.Section> <Layout.Section>
<Card> <Card>
<div style={{ padding: 24, textAlign: "center" }}> <div style={{ padding: 24, textAlign: "center" }}>
<Text as="h1" variant="headingLg"> <Text as="h1" variant="headingLg">
Turn14 isnt connected yet 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> </Text>
</div> <div style={{ marginTop: 8 }}>
{/* 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"> <Text as="p" variant="bodyMd">
{items[1].icon} {items[1].text} This shop hasnt been configured with Turn14 / Data4Autos. To get started, open Settings and complete the connection.
</Text> </Text>
</a> </div>
<a href={items[2].link} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
<Text as="p" variant="bodyMd"> {/* Primary actions */}
{items[2].icon} {items[2].text} <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> </Text>
</a> </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> </div>
</div> </Card>
</Card> </Layout.Section>
</Layout.Section> </Layout>
</Layout> </Page>
</Page> </Frame>
</Frame> );
); }
}

View File

@ -0,0 +1,457 @@
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);
// // 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);
// }
const { session } = await authenticate.admin(request);
const shop = session.shop;
return json({ brands: [], collections : [], selectedBrandsFromShopify: [], 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 } = useLoaderData();
// console.log(`selectedBrandsFromShopify: ${JSON.stringify(selectedBrandsFromShopify)}`);
const actionData = useActionData() || {};
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 [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

@ -0,0 +1,457 @@
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);
// 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);
}
const { session } = await authenticate.admin(request);
const shop = session.shop;
return json({ brands: brandJson.data, collections, selectedBrandsFromShopify: brands || [], 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 } = useLoaderData();
// console.log(`selectedBrandsFromShopify: ${JSON.stringify(selectedBrandsFromShopify)}`);
const actionData = useActionData() || {};
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 [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

@ -19,10 +19,30 @@ import {
ProgressBar, ProgressBar,
Checkbox, Checkbox,
Text, Text,
ChoiceList,
Popover,
OptionList,
} from "@shopify/polaris"; } from "@shopify/polaris";
import { authenticate } from "../shopify.server"; import { authenticate } from "../shopify.server";
import { TitleBar } from "@shopify/app-bridge-react"; import { TitleBar } from "@shopify/app-bridge-react";
const styles = {
gridContainer: {
display: 'grid',
gridTemplateColumns: '1fr 1fr', // Two equal columns
gap: '10px', // Space between items
},
gridItem: {
display: 'flex',
flexDirection: 'column',
},
gridFullWidthItem: {
gridColumn: 'span 2', // This takes up the full width (for Description)
display: 'flex',
flexDirection: 'column',
},
};
async function checkShopExists(shop) { async function checkShopExists(shop) {
try { try {
@ -40,9 +60,29 @@ async function checkShopExists(shop) {
export const loader = async ({ request }) => { export const loader = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const { getTurn14AccessTokenFromMetafield } = await import("../utils/turn14Token.server"); const { getTurn14AccessTokenFromMetafield } = await import("../utils/turn14Token.server");
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 });
console.error("Error getting Turn14 access token:", err);
// Proceeding with empty accessToken
}
const res = await admin.graphql(`{ const res = await admin.graphql(`{
shop { shop {
@ -62,8 +102,7 @@ export const loader = async ({ request }) => {
} }
const { session } = await authenticate.admin(request);
const shop = session.shop;
return json({ brands, accessToken, shop }); return json({ brands, accessToken, shop });
}; };
@ -280,7 +319,6 @@ export default function ManageBrandProducts() {
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [detail, setDetail] = useState(""); const [detail, setDetail] = useState("");
const [filterregulatstock, setfilterregulatstock] = useState(false)
@ -409,6 +447,13 @@ export default function ManageBrandProducts() {
const [filters, setFilters] = useState({ make: '', model: '', year: '', drive: '', baseModel: '' }); const [filters, setFilters] = useState({ make: '', model: '', year: '', drive: '', baseModel: '' });
const [filterregulatstock, setfilterregulatstock] = useState(false)
const [isFilter_EnableZeroStock, set_isFilter_EnableZeroStock] = useState(true)
const [isFilter_IncludeLtlFreightRequired, setisFilter_IncludeLtlFreightRequired] = useState(true)
const [isFilter_Excludeclearance_item, setisFilter_Excludeclearance_item] = useState(false)
const [isFilter_Excludeair_freight_prohibited, setisFilter_Excludeair_freight_prohibited] = useState(false)
const [isFilter_IncludeProductWithNoImages, setisFilter_IncludeProductWithNoImages] = useState(true)
const handleFilterChange = (field) => (value) => { const handleFilterChange = (field) => (value) => {
setFilters((prev) => ({ ...prev, [field]: value })); setFilters((prev) => ({ ...prev, [field]: value }));
@ -445,6 +490,26 @@ export default function ManageBrandProducts() {
isMatch = isMatch && item?.attributes?.regular_stock isMatch = isMatch && item?.attributes?.regular_stock
} }
if (!isFilter_EnableZeroStock) {
isMatch = isMatch && item?.inventoryQuantity > 0;
}
if (!isFilter_IncludeLtlFreightRequired) {
isMatch = isMatch && item?.attributes?.ltl_freight_required !== true;
}
if (isFilter_Excludeclearance_item) {
isMatch = isMatch && item?.attributes?.clearance_item !== true;
}
if (isFilter_Excludeair_freight_prohibited) {
isMatch = isMatch && item?.attributes?.air_freight_prohibited !== true;
}
if (!isFilter_IncludeProductWithNoImages) {
isMatch = isMatch && item?.attributes?.files && item?.attributes?.files.length > 0;
}
return isMatch; return isMatch;
}); });
}; };
@ -456,17 +521,38 @@ export default function ManageBrandProducts() {
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: "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: "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: "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` }, { 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 [popoverActive, setPopoverActive] = useState(false);
const togglePopover = () => setPopoverActive((active) => !active);
const activator = (
<Button onClick={togglePopover} disclosure>
{filters.make.length > 0
? `Selected (${filters.make.length})`
: "Select Makes"}
</Button>
);
// If Turn14 is explicitly NOT connected, show a lightweight call-to-action screen
if (Turn14Enabled === false) { if (Turn14Enabled === false) {
// Fallback if items array is not loaded yet
const safeItems = Array.isArray(items) && items.length >= 4 ? items : [
{ link: "#", icon: "🔗", text: "Connect Turn14" },
{ link: "#", icon: "⚙️", text: "Settings" },
{ link: "#", icon: "❓", text: "Help" },
{ link: "#", icon: "📄", text: "Documentation" }
];
return ( return (
<Frame> <Frame>
<Page fullWidth> <Page fullWidth>
@ -505,18 +591,18 @@ export default function ManageBrandProducts() {
</div> </div>
{/* Secondary links */} {/* 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={safeItems[1].link} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
<Text as="p" variant="bodyMd"> <Text as="p" variant="bodyMd">
{items[1].icon} {items[1].text} {safeItems[1].icon} {safeItems[1].text}
</Text> </Text>
</a> </a>
<a href={items[2].link} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}> <a href={safeItems[2].link} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
<Text as="p" variant="bodyMd"> <Text as="p" variant="bodyMd">
{items[2].icon} {items[2].text} {safeItems[2].icon} {safeItems[2].text}
</Text> </Text>
</a> </a>
</div> </div>
</div> </div>
</Card> </Card>
</Layout.Section> </Layout.Section>
@ -532,7 +618,7 @@ export default function ManageBrandProducts() {
<Page title="Data4Autos Turn14 Manage Brand Products" fullWidth> <Page title="Data4Autos Turn14 Manage Brand Products" fullWidth>
<TitleBar title="Data4Autos Turn14 Integration" /> <TitleBar title="Data4Autos Turn14 Integration" />
<Layout> <Layout>
{/* <p> {/* <p>
<strong>Turn 14 Status:</strong>{" "} <strong>Turn 14 Status:</strong>{" "}
{Turn14Enabled === true {Turn14Enabled === true
? "✅ Turn14 x Shopify Connected!" ? "✅ Turn14 x Shopify Connected!"
@ -611,7 +697,7 @@ export default function ManageBrandProducts() {
{brands.map((brand) => { {brands.map((brand) => {
const filteredItems = applyFitmentFilters(itemsMap[brand.id] || []); const filteredItems = applyFitmentFilters(itemsMap[brand.id] || []);
// console.log("Filtered items for brand", brand.id, ":", filteredItems.map((item) => item.id)); // console.log("Filtered items for brand", brand.id, ":", filteredItems.map((item) => item.id));
const uniqueTags = { const uniqueTags = {
make: new Set(), make: new Set(),
model: new Set(), model: new Set(),
@ -708,11 +794,47 @@ export default function ManageBrandProducts() {
autoComplete="off" autoComplete="off"
/> />
<Checkbox
label="Enable Zero Stock"
checked={isFilter_EnableZeroStock}
onChange={() => set_isFilter_EnableZeroStock(!isFilter_EnableZeroStock)}
/>
<Checkbox <Checkbox
label="Filter Only the Regular Stock" label="Filter Only the Regular Stock"
checked={filterregulatstock} checked={filterregulatstock}
onChange={() => { setfilterregulatstock(!filterregulatstock) }} onChange={() => { setfilterregulatstock(!filterregulatstock) }}
/> />
<Checkbox
label="Include LTL Freight Required"
checked={isFilter_IncludeLtlFreightRequired}
onChange={() => setisFilter_IncludeLtlFreightRequired(!isFilter_IncludeLtlFreightRequired)}
/>
<Checkbox
label="Exclude Clearance Item"
checked={isFilter_Excludeclearance_item}
onChange={() => setisFilter_Excludeclearance_item(!isFilter_Excludeclearance_item)}
/>
<Checkbox
label="Exclude Air Freight Prohibited"
checked={isFilter_Excludeair_freight_prohibited}
onChange={() => setisFilter_Excludeair_freight_prohibited(!isFilter_Excludeair_freight_prohibited)}
/>
<Checkbox
label="Include Products With No Images"
checked={isFilter_IncludeProductWithNoImages}
onChange={() => setisFilter_IncludeProductWithNoImages(!isFilter_IncludeProductWithNoImages)}
/>
<Button <Button
submit submit
primary variant="primary" size="large" primary variant="primary" size="large"
@ -740,6 +862,35 @@ export default function ManageBrandProducts() {
</Layout> </Layout>
</Card> </Card>
<Card title="Filter Products by Fitment Tags" sectioned>
<Layout>
<Layout.Section oneThird>
<Popover
active={popoverActive}
activator={activator}
onClose={togglePopover}
>
<OptionList
title="Make"
onChange={handleFilterChange}
options={[
{ label: "All", value: "ALL" },
...Array.from(makes_list).map((m) => ({
label: m,
value: m,
})),
]}
selected={filters.make}
allowMultiple
/>
</Popover>
</Layout.Section>
</Layout>
</Card>
</div> </div>
<div style={{ <div style={{
display: "grid", display: "grid",
@ -759,14 +910,77 @@ export default function ManageBrandProducts() {
size="large" size="large"
/> />
</Layout.Section> </Layout.Section>
<Layout.Section> {/* <Layout.Section>
<TextContainer spacing="tight"> <TextContainer spacing="tight">
<p><strong>Part Number:</strong> {item?.attributes?.part_number || 'N/A'}</p> <p><strong>Part Number:</strong> {item?.attributes?.part_number || 'N/A'}</p>
<p><strong>Category:</strong> {item?.attributes?.category || 'N/A'} &gt; {item?.attributes?.subcategory || 'N/A'}</p> <p><strong>Category:</strong> {item?.attributes?.category || 'N/A'} &gt; {item?.attributes?.subcategory || 'N/A'}</p>
<p><strong>Price:</strong> ${item?.attributes?.price || '0.00'}</p> <p><strong>Price:</strong> ${item?.attributes?.price || '0.00'}</p>
<p><strong>Description:</strong> {item?.attributes?.part_description || 'No description available'}</p> <p><strong>Description:</strong> {item?.attributes?.part_description || 'No description available'}</p>
<p><strong>Inventory Quantity:</strong> {item?.inventoryQuantity || 'N/A'}</p>
<p><strong>Regular Stock : </strong> {item?.attributes?.regular_stock === true ? "YES" : "NO" || 'N/A'}</p>
<p><strong>LTL Freight Required :</strong> {item?.attributes?.ltl_freight_required === true ? "YES" : "NO" || 'N/A'}</p>
<p><strong>Is Clearance Item :</strong> {item?.attributes?.is_clearance_item === true ? "YES" : "NO" || 'N/A'}</p>
<p><strong>Is Air Freight Prohibited :</strong> {item?.attributes?.is_air_freight_prohibited === true ? "YES" : "NO" || 'N/A'}</p>
<p><strong>No. Of Images :</strong> {item?.attributes?.files.length || 'N/A'}</p>
</TextContainer>
</Layout.Section> */}
<Layout.Section>
<TextContainer spacing="tight">
<div style={styles.gridContainer}>
{/* Part Number */}
<div style={styles.gridItem}>
<strong>Part Number:</strong> {item?.attributes?.part_number || 'N/A'}
</div>
{/* Category & Subcategory */}
<div style={styles.gridItem}>
<strong>Category:</strong> {item?.attributes?.category || 'N/A'} &gt; {item?.attributes?.subcategory || 'N/A'}
</div>
{/* Price */}
<div style={styles.gridItem}>
<strong>Price:</strong> ${item?.attributes?.price || '0.00'}
</div>
{/* Description (1 column) */}
<div style={styles.gridFullWidthItem}>
<strong>Description:</strong> {item?.attributes?.part_description || 'No description available'}
</div>
{/* Inventory Quantity */}
<div style={styles.gridItem}>
<strong>Inventory Quantity:</strong> {item?.inventoryQuantity || 'N/A'}
</div>
{/* Regular Stock */}
<div style={styles.gridItem}>
<strong>Regular Stock:</strong> {item?.attributes?.regular_stock === true ? "YES" : "NO" || 'N/A'}
</div>
{/* LTL Freight Required */}
<div style={styles.gridItem}>
<strong>LTL Freight Required:</strong> {item?.attributes?.ltl_freight_required === true ? "YES" : "NO" || 'N/A'}
</div>
{/* Clearance Item */}
<div style={styles.gridItem}>
<strong>Is Clearance Item:</strong> {item?.attributes?.is_clearance_item === true ? "YES" : "NO" || 'N/A'}
</div>
{/* Air Freight Prohibited */}
<div style={styles.gridItem}>
<strong>Is Air Freight Prohibited:</strong> {item?.attributes?.is_air_freight_prohibited === true ? "YES" : "NO" || 'N/A'}
</div>
{/* Number of Images */}
<div style={styles.gridItem}>
<strong>No. Of Images:</strong> {item?.attributes?.files.length || 'N/A'}
</div>
</div>
</TextContainer> </TextContainer>
</Layout.Section> </Layout.Section>
</Layout> </Layout>
</Card> </Card>
))} ))}

View File

@ -0,0 +1,805 @@
import React, { useEffect, useState } from "react";
import { json } from "@remix-run/node";
import { useLoaderData, Form, useActionData } from "@remix-run/react";
import {
Page,
Layout,
IndexTable,
Card,
Thumbnail,
TextContainer,
Spinner,
Button,
TextField,
Banner,
InlineError,
Toast,
Frame,
Select,
ProgressBar,
Checkbox,
Text,
} from "@shopify/polaris";
import { authenticate } from "../shopify.server";
import { TitleBar } from "@shopify/app-bridge-react";
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 { getTurn14AccessTokenFromMetafield } = await import("../utils/turn14Token.server");
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 });
console.error("Error getting Turn14 access token:", err);
// Proceeding with empty accessToken
}
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, accessToken, shop });
};
const makes_list_raw = [
'Alfa Romeo',
'Ferrari',
'Dodge',
'Subaru',
'Toyota',
'Volkswagen',
'Volvo',
'Audi',
'BMW',
'Buick',
'Cadillac',
'Chevrolet',
'Chrysler',
'CX Automotive',
'Nissan',
'Ford',
'Hyundai',
'Infiniti',
'Lexus',
'Mercury',
'Mazda',
'Oldsmobile',
'Plymouth',
'Pontiac',
'Rolls-Royce',
'Eagle',
'Lincoln',
'Mercedes-Benz',
'GMC',
'Saab',
'Honda',
'Saturn',
'Mitsubishi',
'Isuzu',
'Jeep',
'AM General',
'Geo',
'Suzuki',
'E. P. Dutton, Inc.',
'Land Rover',
'PAS, Inc',
'Acura',
'Jaguar',
'Lotus',
'Grumman Olson',
'Porsche',
'American Motors Corporation',
'Kia',
'Lamborghini',
'Panoz Auto-Development',
'Maserati',
'Saleen',
'Aston Martin',
'Dabryan Coach Builders Inc',
'Federal Coach',
'Vector',
'Bentley',
'Daewoo',
'Qvale',
'Roush Performance',
'Autokraft Limited',
'Bertone',
'Panther Car Company Limited',
'Texas Coach Company',
'TVR Engineering Ltd',
'Morgan',
'MINI',
'Yugo',
'BMW Alpina',
'Renault',
'Bitter Gmbh and Co. Kg',
'Scion',
'Maybach',
'Lambda Control Systems',
'Merkur',
'Peugeot',
'Spyker',
'London Coach Co Inc',
'Hummer',
'Bugatti',
'Pininfarina',
'Shelby',
'Saleen Performance',
'smart',
'Tecstar, LP',
'Kenyon Corporation Of America',
'Avanti Motor Corporation',
'Bill Dovell Motor Car Company',
'Import Foreign Auto Sales Inc',
'S and S Coach Company E.p. Dutton',
'Superior Coaches Div E.p. Dutton',
'Vixen Motor Company',
'Volga Associated Automobile',
'Wallace Environmental',
'Import Trade Services',
'J.K. Motors',
'Panos',
'Quantum Technologies',
'London Taxi',
'Red Shift Ltd.',
'Ruf Automobile Gmbh',
'Excalibur Autos',
'Mahindra',
'VPG',
'Fiat',
'Sterling',
'Azure Dynamics',
'McLaren Automotive',
'Ram',
'CODA Automotive',
'Fisker',
'Tesla',
'Mcevoy Motors',
'BYD',
'ASC Incorporated',
'SRT',
'CCC Engineering',
'Mobility Ventures LLC',
'Pagani',
'Genesis',
'Karma',
'Koenigsegg',
'Aurora Cars Ltd',
'RUF Automobile',
'Dacia',
'STI',
'Daihatsu',
'Polestar',
'Kandi',
'Rivian',
'Lucid',
'JBA Motorcars, Inc.',
'Lordstown',
'Vinfast',
'INEOS Automotive',
'Bugatti Rimac',
'Grumman Allied Industries',
'Environmental Rsch and Devp Corp',
'Evans Automobiles',
'Laforza Automobile Inc',
'General Motors',
'Consulier Industries Inc',
'Goldacre',
'Isis Imports Ltd',
'PAS Inc - GMC'
];
const makes_list = makes_list_raw.sort();
export const action = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const formData = await request.formData();
const brandId = formData.get("brandId");
const rawCount = formData.get("productCount");
const selectedProductIds = JSON.parse(formData.get("selectedProductIds") || "[]");
const productCount = parseInt(rawCount, 10) || 10;
const { getTurn14AccessTokenFromMetafield } = await import("../utils/turn14Token.server");
const accessToken = await getTurn14AccessTokenFromMetafield(request);
const { session } = await authenticate.admin(request);
const shop = session.shop;
const resp = await fetch("https://backend.data4autos.com/manageProducts", {
method: "POST",
headers: {
"Content-Type": "application/json",
"shop-domain": shop,
},
body: JSON.stringify({
shop,
brandID: brandId,
turn14accessToken: accessToken,
productCount,
selectedProductIds
}),
});
console.log("Response from manageProducts:", resp.status, resp.statusText);
if (!resp.ok) {
const err = await resp.text();
return json({ error: err }, { status: resp.status });
}
const { processId, status } = await resp.json();
console.log("Process ID:", processId, "Status:", status);
return json({ success: true, processId, status });
};
export default function ManageBrandProducts() {
const actionData = useActionData();
const { shop, brands, accessToken } = useLoaderData();
const [expandedBrand, setExpandedBrand] = useState(null);
const [itemsMap, setItemsMap] = useState({});
const [loadingMap, setLoadingMap] = useState({});
const [productCount, setProductCount] = useState("10");
const [initialLoad, setInitialLoad] = useState(true);
const [toastActive, setToastActive] = useState(false);
const [polling, setPolling] = useState(false);
const [status, setStatus] = useState(actionData?.status || "");
const [processId, setProcessId] = useState(actionData?.processId || null);
const [progress, setProgress] = useState(0);
const [totalProducts, setTotalProducts] = useState(0);
const [processedProducts, setProcessedProducts] = useState(0);
const [currentProduct, setCurrentProduct] = useState(null);
const [results, setResults] = useState([]);
const [detail, setDetail] = useState("");
const [filterregulatstock, setfilterregulatstock] = useState(false)
const [Turn14Enabled, setTurn14Enabled] = useState("12345"); // 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(() => {
if (actionData?.processId) {
setProcessId(actionData.processId);
setStatus(actionData.status || "processing");
setToastActive(true);
}
}, [actionData]);
const checkStatus = async () => {
setPolling(true);
try {
const response = await fetch(`https://backend.data4autos.com/manageProducts/status/${processId}`);
const data = await response.json();
setStatus(data.status);
setDetail(data.detail);
setProgress(data.progress);
setTotalProducts(data.stats.total);
setProcessedProducts(data.stats.processed);
setCurrentProduct(data.current);
if (data.results) {
setResults(data.results);
}
// Continue polling if still processing
if (data.status !== 'done' && data.status !== 'error') {
setTimeout(checkStatus, 2000);
} else {
setPolling(false);
}
} catch (error) {
setPolling(false);
setStatus('error');
setDetail('Failed to check status');
console.error('Error checking status:', error);
}
};
useEffect(() => {
let interval;
if (status?.includes("processing") && processId) {
interval = setInterval(checkStatus, 5000);
}
return () => clearInterval(interval);
}, [status, processId]);
const toggleAllBrands = async () => {
for (const brand of brands) {
await toggleBrandItems(brand.id);
}
};
useEffect(() => {
if (initialLoad && brands.length > 0) {
toggleAllBrands();
setInitialLoad(false);
}
}, [brands, initialLoad]);
const toggleBrandItems = async (brandId) => {
const isExpanded = expandedBrand === brandId;
if (isExpanded) {
setExpandedBrand(null);
} else {
setExpandedBrand(brandId);
if (!itemsMap[brandId]) {
setLoadingMap((prev) => ({ ...prev, [brandId]: true }));
try {
const res = await fetch(`https://turn14.data4autos.com/v1/items/brandallitemswithfitment/${brandId}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
const data = await res.json();
const dataitems = data.items
const validItems = Array.isArray(dataitems)
? dataitems.filter(item => item && item.id && item.attributes)
: [];
setItemsMap((prev) => ({ ...prev, [brandId]: validItems }));
} catch (err) {
console.error("Error fetching items:", err);
setItemsMap((prev) => ({ ...prev, [brandId]: [] }));
}
setLoadingMap((prev) => ({ ...prev, [brandId]: false }));
}
}
};
const toastMarkup = toastActive ? (
<Toast
content={status.includes("completed") ?
"Products imported successfully!" :
`Status: ${status}`}
onDismiss={() => setToastActive(false)}
/>
) : null;
const [filters, setFilters] = useState({ make: '', model: '', year: '', drive: '', baseModel: '' });
const handleFilterChange = (field) => (value) => {
setFilters((prev) => ({ ...prev, [field]: value }));
};
const applyFitmentFilters = (items) => {
return items.filter((item) => {
const tags = item?.attributes?.fitmmentTags || {};
const productName = item?.attributes?.product_name || '';
const brand = item?.attributes?.brand || '';
const partDescription = item?.attributes?.part_description || '';
const descriptions = item?.attributes?.descriptions || [];
const makeMatch = !filters.make || tags.make?.includes(filters.make) || productName.includes(filters.make) || brand.includes(filters.make) || descriptions.some((desc) => desc.description.includes(filters.make));
const modelMatch = !filters.model || tags.model?.includes(filters.model) || productName.includes(filters.model) || brand.includes(filters.model) || descriptions.some((desc) => desc.description.includes(filters.model));
// console.log(`Model check result: ${modelMatch}`);
const yearMatch = !filters.year || tags.year?.includes(filters.year) || productName.includes(filters.year) || brand.includes(filters.year) || descriptions.some((desc) => desc.description.includes(filters.year));
/// console.log(`Year check result: ${yearMatch}`);
const driveMatch = !filters.drive || tags.drive?.includes(filters.drive) || productName.includes(filters.drive) || brand.includes(filters.drive) || descriptions.some((desc) => desc.description.includes(filters.drive));
// console.log(`Drive check result: ${driveMatch}`);
const baseModelMatch = !filters.baseModel || tags.baseModel?.includes(filters.baseModel) || productName.includes(filters.baseModel) || brand.includes(filters.baseModel) || descriptions.some((desc) => desc.description.includes(filters.baseModel));
// console.log(`Base Model check result: ${baseModelMatch}`);
// Combine all the conditions
var isMatch = makeMatch && modelMatch && yearMatch && driveMatch && baseModelMatch// && item.attributes.regular_stock
if (filterregulatstock) {
isMatch = isMatch && item?.attributes?.regular_stock
}
return isMatch;
});
};
const selectedProductIds = []
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>
);
}
return (
<Frame>
<Page title="Data4Autos Turn14 Manage Brand Products" fullWidth>
<TitleBar title="Data4Autos Turn14 Integration" />
<Layout>
{/* <p>
<strong>Turn 14 Status:</strong>{" "}
{Turn14Enabled === true
? "✅ Turn14 x Shopify Connected!"
: Turn14Enabled === false
? "❌ Turn14 x Shopify Connection Doesn't Exists"
: "Checking..."}
</p> */}
{brands.length === 0 ? (
<Layout.Section>
<Card sectioned>
<p>No brands selected yet.</p>
</Card>
</Layout.Section>
) : (
<Layout.Section>
<Card>
<IndexTable
resourceName={{ singular: "brand", plural: "brands" }}
itemCount={brands.length}
headings={[
{ title: <div style={{ fontWeight: 600, fontSize: "16px", background: "#f4f6f8", padding: "15px 8px", borderRadius: "4px" }}>Brand ID</div> },
{ title: <div style={{ fontWeight: 600, fontSize: "16px", background: "#f4f6f8", padding: "15px 8px", borderRadius: "4px" }}>Brand Name</div> },
{ title: <div style={{ fontWeight: 600, fontSize: "16px", background: "#f4f6f8", padding: "15px 8px", borderRadius: "4px" }}>Brand Logo</div> },
{ title: <div style={{ fontWeight: 600, fontSize: "16px", background: "#f4f6f8", padding: "15px 8px", borderRadius: "4px" }}>Action</div> },
{ title: <div style={{ fontWeight: 600, fontSize: "16px", background: "#f4f6f8", padding: "15px 8px", borderRadius: "4px" }}>Products Count</div> },
]}
selectable={false}
>
{brands.map((brand, index) => {
return (
<IndexTable.Row id={brand.id.toString()} key={brand.id} position={index}>
<IndexTable.Cell>{brand.id}</IndexTable.Cell>
<IndexTable.Cell>{brand.name}</IndexTable.Cell>
<IndexTable.Cell>
<Thumbnail
source={
brand.logo ||
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
}
alt={brand.name}
size="medium"
/>
</IndexTable.Cell>
<IndexTable.Cell>
<Button onClick={() => toggleBrandItems(brand.id)} variant="primary">
{expandedBrand === brand.id ? "Hide Products" : "Show Products"}
</Button>
</IndexTable.Cell>
<IndexTable.Cell>
<span
style={{
display: "inline-block",
background: "#00d1ff29", // light teal background
color: "#00d1ff", // dark teal text
padding: "4px 8px",
borderRadius: "12px",
fontWeight: "600",
fontSize: "14px",
minWidth: "28px",
textAlign: "center"
}}
>
{itemsMap[brand.id]?.length || 0}
</span>
</IndexTable.Cell>
</IndexTable.Row>
)
})}
</IndexTable>
</Card>
</Layout.Section>
)}
{brands.map((brand) => {
const filteredItems = applyFitmentFilters(itemsMap[brand.id] || []);
// console.log("Filtered items for brand", brand.id, ":", filteredItems.map((item) => item.id));
const uniqueTags = {
make: new Set(),
model: new Set(),
year: new Set(),
drive: new Set(),
baseModel: new Set(),
};
(itemsMap[brand.id] || []).forEach(item => {
const tags = item?.attributes?.fitmmentTags || {};
Object.keys(uniqueTags).forEach(key => {
(tags[key] || []).forEach(val => uniqueTags[key].add(val));
});
});
return (
expandedBrand === brand.id &&
(
<Layout.Section fullWidth key={brand.id + "-expanded"}>
{processId && (
<Card sectioned>
<div style={{ marginBottom: "1rem", padding: "1rem", border: "1px solid #e1e1e1", borderRadius: "4px" }}>
<p>
<strong>Process ID:</strong> {processId}
</p>
<div style={{ margin: "1rem 0" }}>
<p>
<strong>Status:</strong> {status || "—"}
</p>
{progress > 0 && (
<div style={{ marginTop: "0.5rem" }}>
<ProgressBar
progress={progress}
color={
status === 'error' ? 'critical' :
status === 'done' ? 'success' : 'highlight'
}
/>
<p style={{ marginTop: "0.25rem", fontSize: "0.85rem" }}>
{processedProducts} of {totalProducts} products processed
{currentProduct && ` - Current: ${currentProduct.name} (${currentProduct.number}/${currentProduct.total})`}
</p>
</div>
)}
</div>
{status === 'done' && results.length > 0 && (
<div style={{ marginTop: "1rem" }}>
<p>
<strong>Results:</strong> {results.length} products processed successfully
</p>
</div>
)}
{status === 'error' && (
<div style={{ marginTop: "1rem", color: "#ff4d4f" }}>
<strong>Error:</strong> {detail}
</div>
)}
<Button
onClick={checkStatus}
loading={polling} variant="primary" size="large"
style={{ marginTop: "1rem" }}
>
{status === 'done' ? 'View Results' : 'Check Status'}
</Button>
</div>
</Card>
)}
<Card title={`Items from ${brand.name}`} sectioned>
{loadingMap[brand.id] ? (
<Spinner accessibilityLabel="Loading items" size="small" />
) : (
<div style={{ paddingTop: "1rem" }}>
<Form method="post">
<input
type="hidden"
name="selectedProductIds"
value={JSON.stringify(filteredItems.map((item) => item.id))}
/>
<input type="hidden" name="brandId" value={brand.id} />
<div style={{ display: "flex", gap: "1rem", alignItems: "end" }}>
<TextField
label="Number of products in Selected Filter Make"
type="number"
name="productCount"
value={filteredItems.length}
onChange={(value) => setProductCount(value)}
autoComplete="off"
/>
<Checkbox
label="Filter Only the Regular Stock"
checked={filterregulatstock}
onChange={() => { setfilterregulatstock(!filterregulatstock) }}
/>
<Button
submit
primary variant="primary" size="large"
style={{ marginTop: "1rem" }}
loading={status?.includes("processing")}
>
Add First {filteredItems.length} Products from {filters.make} to Store
</Button>
</div>
</Form>
<div style={{ padding: "20px 0px" }}>
<Card title="Filter Products by Fitment Tags" sectioned >
<Layout>
<Layout.Section oneThird>
<Select
label="Make"
options={[{ label: 'All', value: '' }, ...Array.from(makes_list).map(m => ({ label: m, value: m }))]}
onChange={handleFilterChange('make')}
value={filters.make}
/>
</Layout.Section>
</Layout>
</Card>
</div>
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
gap: 16,
}}>
{filteredItems.map((item) => (
<Card key={item.id} title={item?.attributes?.product_name || 'Untitled Product'} sectioned>
<Layout>
<Layout.Section oneThird>
<Thumbnail
source={
item?.attributes?.thumbnail ||
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
}
alt={item?.attributes?.product_name || 'Product image'}
size="large"
/>
</Layout.Section>
<Layout.Section>
<TextContainer spacing="tight">
<p><strong>Part Number:</strong> {item?.attributes?.part_number || 'N/A'}</p>
<p><strong>Category:</strong> {item?.attributes?.category || 'N/A'} &gt; {item?.attributes?.subcategory || 'N/A'}</p>
<p><strong>Price:</strong> ${item?.attributes?.price || '0.00'}</p>
<p><strong>Description:</strong> {item?.attributes?.part_description || 'No description available'}</p>
</TextContainer>
</Layout.Section>
</Layout>
</Card>
))}
</div>
</div>
)}
</Card>
</Layout.Section>
)
)
})}
</Layout>
{toastMarkup}
</Page>
</Frame>
);
}

View File

@ -42,6 +42,7 @@ export const loader = async ({ request }) => {
shop { shop {
id id
name name
myshopifyDomain
metafield(namespace: "turn14", key: "credentials") { value } metafield(namespace: "turn14", key: "credentials") { value }
pricing: metafield(namespace: "turn14", key: "pricing_config") { value } pricing: metafield(namespace: "turn14", key: "pricing_config") { value }
} }
@ -65,6 +66,7 @@ export const loader = async ({ request }) => {
return json({ return json({
shopName: data.shop.name, shopName: data.shop.name,
shopDomain: data.shop.myshopifyDomain,
shopId: data.shop.id, shopId: data.shop.id,
savedCreds: creds, savedCreds: creds,
savedPricing, savedPricing,
@ -78,10 +80,11 @@ export const action = async ({ request }) => {
const { admin } = await authenticate.admin(request); const { admin } = await authenticate.admin(request);
// we need shop id either way // we need shop id either way
const shopResp = await admin.graphql(`{ shop { id name } }`); const shopResp = await admin.graphql(`{ shop { id name myshopifyDomain } }`);
const shopJson = await shopResp.json(); const shopJson = await shopResp.json();
const shopId = shopJson.data.shop.id; const shopId = shopJson.data.shop.id;
const shopName = shopJson.data.shop.name; const shopName = shopJson.data.shop.name;
const shopDomain = shopJson.data.shop.myshopifyDomain;
if (intent === "save_pricing") { if (intent === "save_pricing") {
// --- save pricing_config metafield directly --- // --- save pricing_config metafield directly ---
@ -168,7 +171,7 @@ export const action = async ({ request }) => {
const stateNonce = Math.random().toString(36).slice(2); const stateNonce = Math.random().toString(36).slice(2);
const installUrl = const installUrl =
`https://${shopName}.myshopify.com/admin/oauth/authorize` + `https://${shopDomain}/admin/oauth/authorize` +
`?client_id=${CLIENT_ID}` + `?client_id=${CLIENT_ID}` +
`&scope=${SCOPES}` + `&scope=${SCOPES}` +
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` + `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
@ -183,7 +186,7 @@ export const action = async ({ request }) => {
// ===== COMPONENT ===== // ===== COMPONENT =====
export default function StoreCredentials() { export default function StoreCredentials() {
const { shopName, savedCreds, savedPricing } = useLoaderData(); const { shopName, savedCreds, savedPricing, shopDomain } = useLoaderData();
const actionData = useActionData(); const actionData = useActionData();
// open Shopify install after Connect Turn14 // open Shopify install after Connect Turn14

View File

@ -0,0 +1,347 @@
// 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_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
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,
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 } }`);
const shopJson = await shopResp.json();
const shopId = shopJson.data.shop.id;
const shopName = shopJson.data.shop.name;
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://${shopName}.myshopify.com/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 } = 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>
);
}

18
package-lock.json generated
View File

@ -14,7 +14,7 @@
"@remix-run/fs-routes": "^2.16.1", "@remix-run/fs-routes": "^2.16.1",
"@remix-run/node": "^2.16.1", "@remix-run/node": "^2.16.1",
"@remix-run/react": "^2.16.1", "@remix-run/react": "^2.16.1",
"@shopify/app-bridge-react": "^4.1.6", "@shopify/app-bridge-react": "^4.2.3",
"@shopify/polaris": "^12.27.0", "@shopify/polaris": "^12.27.0",
"@shopify/shopify-app-remix": "^3.7.0", "@shopify/shopify-app-remix": "^3.7.0",
"@shopify/shopify-app-session-storage-prisma": "^6.0.0", "@shopify/shopify-app-session-storage-prisma": "^6.0.0",
@ -3532,11 +3532,12 @@
} }
}, },
"node_modules/@shopify/app-bridge-react": { "node_modules/@shopify/app-bridge-react": {
"version": "4.1.10", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/@shopify/app-bridge-react/-/app-bridge-react-4.1.10.tgz", "resolved": "https://registry.npmjs.org/@shopify/app-bridge-react/-/app-bridge-react-4.2.3.tgz",
"integrity": "sha512-qtyWzC/9UzKAHV4+HtHfPhZdKpHlVl671tTpzCM2EqLNv0J8pABFYKTChoDvbqI4IFcKaRQpZcM+Waq2NtNy4g==", "integrity": "sha512-9Ijs/S3/y+zTRCID9CZbPmbWWYL044Jp+81n2iHOC+NIHrZkYyO6BVBaksmvlLHZyKjdU8VeTuziMUMLKEupRA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@shopify/app-bridge-types": "0.0.18" "@shopify/app-bridge-types": "0.4.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "*", "react": "*",
@ -3544,9 +3545,10 @@
} }
}, },
"node_modules/@shopify/app-bridge-types": { "node_modules/@shopify/app-bridge-types": {
"version": "0.0.18", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@shopify/app-bridge-types/-/app-bridge-types-0.0.18.tgz", "resolved": "https://registry.npmjs.org/@shopify/app-bridge-types/-/app-bridge-types-0.4.0.tgz",
"integrity": "sha512-02AiWgn1op7qCKx6HXpFqBwJNPoqZ9g47hltch1ZD4bC+2vVuCgePlmJ+yEFWcN5tW9AG/a27Igsbi0LnY01gA==" "integrity": "sha512-JhyNu0n4381ZqxbsyRU+v2mahePn39NfpSPIzA2+RKCD7xFMnejJcsKgV4p4uVmT52y7VKwNO9vmlmh7juVGfA==",
"license": "ISC"
}, },
"node_modules/@shopify/graphql-client": { "node_modules/@shopify/graphql-client": {
"version": "1.4.0", "version": "1.4.0",

View File

@ -28,7 +28,7 @@
"@remix-run/fs-routes": "^2.16.1", "@remix-run/fs-routes": "^2.16.1",
"@remix-run/node": "^2.16.1", "@remix-run/node": "^2.16.1",
"@remix-run/react": "^2.16.1", "@remix-run/react": "^2.16.1",
"@shopify/app-bridge-react": "^4.1.6", "@shopify/app-bridge-react": "^4.2.3",
"@shopify/polaris": "^12.27.0", "@shopify/polaris": "^12.27.0",
"@shopify/shopify-app-remix": "^3.7.0", "@shopify/shopify-app-remix": "^3.7.0",
"@shopify/shopify-app-session-storage-prisma": "^6.0.0", "@shopify/shopify-app-session-storage-prisma": "^6.0.0",