fix the cost price and few other updates
This commit is contained in:
parent
7ef9040271
commit
688855fe48
412
app/routes/app._index copy 2.jsx
Normal file
412
app/routes/app._index copy 2.jsx
Normal 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="Couldn’t 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 Shopify’s billing confirmation in a
|
||||||
|
new tab. If it doesn’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
492
app/routes/app._index copy 3.jsx
Normal file
492
app/routes/app._index copy 3.jsx
Normal 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="Couldn’t 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 Shopify’s billing confirmation in a
|
||||||
|
new tab. If it doesn’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
337
app/routes/app._index copy.jsx
Normal file
337
app/routes/app._index copy.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,22 +51,20 @@ 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 { 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.
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
return json({ redirectToBilling: true, subscription: null, shop });
|
return json({ redirectToBilling: true, subscription: null, shop });
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no active or trial subscription, return redirect signal
|
|
||||||
if (subscription.status !== "ACTIVE" && subscription.status !== "TRIAL") {
|
if (subscription.status !== "ACTIVE" && subscription.status !== "TRIAL") {
|
||||||
return json({ redirectToBilling: true, subscription, shop });
|
return json({ redirectToBilling: true, subscription, shop });
|
||||||
}
|
}
|
||||||
@ -69,96 +72,175 @@ export const loader = async ({ request }) => {
|
|||||||
return json({ redirectToBilling: false, subscription, shop });
|
return json({ redirectToBilling: false, subscription, shop });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Action to create subscription
|
/* ===========================
|
||||||
|
ACTION: create subscription (Monthly or Annual)
|
||||||
|
=========================== */
|
||||||
export const action = async ({ request }) => {
|
export const action = async ({ request }) => {
|
||||||
console.log("Creating subscription...");
|
|
||||||
const { admin } = await authenticate.admin(request);
|
const { admin } = await authenticate.admin(request);
|
||||||
|
const form = await request.formData();
|
||||||
|
const rawCadence = form.get("cadence");
|
||||||
|
const cadence = rawCadence === "ANNUAL" ? "ANNUAL" : "MONTHLY";
|
||||||
|
|
||||||
|
const interval = cadence === "ANNUAL" ? "ANNUAL" : "EVERY_30_DAYS";
|
||||||
|
const amount = cadence === "ANNUAL" ? ANNUAL_AMOUNT : MONTHLY_AMOUNT;
|
||||||
|
|
||||||
|
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
const shop = session.shop;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const shopDomain = (shop || "").split(".")[0];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const origin = new URL(request.url).origin;
|
||||||
|
const returnUrl = `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app`;
|
||||||
|
|
||||||
|
|
||||||
const createRes = await admin.graphql(`
|
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: 7, # ✅ trialDays is a top-level argument!
|
trialDays: ${TRIAL_DAYS}
|
||||||
test: true
|
|
||||||
|
test: false
|
||||||
) {
|
) {
|
||||||
confirmationUrl
|
confirmationUrl
|
||||||
appSubscription {
|
appSubscription { id status trialDays }
|
||||||
id
|
userErrors { field message }
|
||||||
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="Couldn’t 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 />
|
||||||
|
|
||||||
<BlockStack gap="100">
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Subscription Status"
|
label="Plan"
|
||||||
value={subscription ? subscription.status : "No active subscription"}
|
value={displayPlan}
|
||||||
disabled
|
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
|
<TextField
|
||||||
label="Trial Days Left"
|
label="Trial Days Left"
|
||||||
value={subscription?.trialDays ? `${subscription.trialDays} days left` : "No trial available"}
|
value={
|
||||||
disabled
|
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
|
<TextField
|
||||||
label="Subscription Plan"
|
label="Price after trial"
|
||||||
value={subscription?.id ? "Pro Plan" : "Not subscribed"}
|
value={cadence === "ANNUAL" ? `$${ANNUAL_AMOUNT}/yr` : `$${MONTHLY_AMOUNT}/mo`}
|
||||||
disabled
|
readOnly
|
||||||
/>
|
/>
|
||||||
<TextField
|
</InlineStack>
|
||||||
label="Trial Expiration Date"
|
|
||||||
value={subscription?.currentPeriodEnd ? new Date(subscription.currentPeriodEnd).toLocaleDateString() : "N/A"}
|
{/* ===== Radio-style plan selector ===== */}
|
||||||
disabled
|
<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 Shopify’s billing confirmation in a
|
||||||
|
new tab. If it doesn’t 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>
|
||||||
|
|||||||
568
app/routes/app._index_0110.jsx
Normal file
568
app/routes/app._index_0110.jsx
Normal 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="Couldn’t 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 Shopify’s billing confirmation in a new tab. If it doesn’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
364
app/routes/app._index_2909.jsx
Normal file
364
app/routes/app._index_2909.jsx
Normal 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="Couldn’t 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 Shopify’s billing confirmation in a
|
||||||
|
new tab. If it doesn’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
377
app/routes/app.billingsuccess.jsx
Normal file
377
app/routes/app.billingsuccess.jsx
Normal 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 couldn’t 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. You’re all set to sync brands,
|
||||||
|
build collections, and automate your Turn14 catalog.
|
||||||
|
</p>
|
||||||
|
<p style={{ marginTop: 8 }}>
|
||||||
|
Activated: <strong>{createdAt}</strong> • 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -40,66 +40,79 @@ 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()));
|
||||||
|
|||||||
457
app/routes/app.brands_060925.jsx
Normal file
457
app/routes/app.brands_060925.jsx
Normal 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 isn’t connected yet
|
||||||
|
</Text>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Text as="p" variant="bodyMd">
|
||||||
|
This shop hasn’t been configured with Turn14 / Data4Autos. To get started, open Settings and complete the connection.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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, you’ll 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
457
app/routes/app.brands_2808 copy.jsx
Normal file
457
app/routes/app.brands_2808 copy.jsx
Normal 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 isn’t connected yet
|
||||||
|
</Text>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Text as="p" variant="bodyMd">
|
||||||
|
This shop hasn’t been configured with Turn14 / Data4Autos. To get started, open Settings and complete the connection.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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, you’ll 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -465,8 +530,29 @@ export default function ManageBrandProducts() {
|
|||||||
{ 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` },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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 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>
|
||||||
@ -506,14 +592,14 @@ export default function ManageBrandProducts() {
|
|||||||
|
|
||||||
{/* 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>
|
||||||
@ -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'} > {item?.attributes?.subcategory || 'N/A'}</p>
|
<p><strong>Category:</strong> {item?.attributes?.category || 'N/A'} > {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'} > {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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
805
app/routes/app.managebrand_060925.jsx
Normal file
805
app/routes/app.managebrand_060925.jsx
Normal 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 isn’t connected yet
|
||||||
|
</Text>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Text as="p" variant="bodyMd">
|
||||||
|
This shop hasn’t been configured with Turn14 / Data4Autos. To get started, open Settings and complete the connection.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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, you’ll 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'} > {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
347
app/routes/app.settings_2909.jsx
Normal file
347
app/routes/app.settings_2909.jsx
Normal 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
18
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user