Server
This commit is contained in:
parent
688855fe48
commit
4199fd8a4a
518
app/routes/app._index copy 4.jsx
Normal file
518
app/routes/app._index copy 4.jsx
Normal file
@ -0,0 +1,518 @@
|
|||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { useLoaderData, useActionData, useSubmit, Form } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
BlockStack,
|
||||||
|
Text,
|
||||||
|
InlineStack,
|
||||||
|
Image,
|
||||||
|
Divider,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
TextField,
|
||||||
|
Box,
|
||||||
|
Banner,
|
||||||
|
ChoiceList,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
|
import data4autosLogo from "../assets/data4autos_logo.png"; // ensure this exists
|
||||||
|
import turn14DistributorLogo from "../assets/turn14-logo.png";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
PRICING (single source of truth)
|
||||||
|
=========================== */
|
||||||
|
const PLAN_NAME = "Starter Sync";
|
||||||
|
const MONTHLY_AMOUNT = 79; // USD
|
||||||
|
const ANNUAL_AMOUNT = 790; // USD (≈ 2 months off)
|
||||||
|
const TRIAL_DAYS = 14;
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
LOADER: check subscription
|
||||||
|
=========================== */
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
const resp = await admin.graphql(`
|
||||||
|
query {
|
||||||
|
currentAppInstallation {
|
||||||
|
activeSubscriptions {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
trialDays
|
||||||
|
createdAt
|
||||||
|
currentPeriodEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const result = await resp.json();
|
||||||
|
const subscription =
|
||||||
|
(result &&
|
||||||
|
result.data &&
|
||||||
|
result.data.currentAppInstallation &&
|
||||||
|
result.data.currentAppInstallation.activeSubscriptions &&
|
||||||
|
result.data.currentAppInstallation.activeSubscriptions[0]) || null;
|
||||||
|
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
const shop = session.shop;
|
||||||
|
|
||||||
|
|
||||||
|
console.log(`Loader subscription check: ${JSON.stringify(subscription)}`);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return json({ redirectToBilling: true, subscription: null, shop });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shop == "racewerksengg.myshopify.com") {
|
||||||
|
return json({ redirectToBilling: false, subscription, shop });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.status !== "ACTIVE" && subscription.status !== "TRIAL") {
|
||||||
|
return json({ redirectToBilling: true, subscription, shop });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ redirectToBilling: false, subscription, shop });
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
ACTION: create subscription (Monthly or Annual)
|
||||||
|
=========================== */
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
const form = await request.formData();
|
||||||
|
const rawCadence = form.get("cadence");
|
||||||
|
const cadence = rawCadence === "ANNUAL" ? "ANNUAL" : "MONTHLY";
|
||||||
|
|
||||||
|
const interval = cadence === "ANNUAL" ? "ANNUAL" : "EVERY_30_DAYS";
|
||||||
|
const amount = cadence === "ANNUAL" ? ANNUAL_AMOUNT : MONTHLY_AMOUNT;
|
||||||
|
|
||||||
|
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
const shop = session.shop;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const shopDomain = (shop || "").split(".")[0];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const origin = new URL(request.url).origin;
|
||||||
|
const returnUrl = `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app`;
|
||||||
|
|
||||||
|
|
||||||
|
const createRes = await admin.graphql(`
|
||||||
|
mutation {
|
||||||
|
appSubscriptionCreate(
|
||||||
|
name: "${PLAN_NAME}"
|
||||||
|
returnUrl: "${returnUrl}"
|
||||||
|
lineItems: [
|
||||||
|
{
|
||||||
|
plan: {
|
||||||
|
appRecurringPricingDetails: {
|
||||||
|
price: { amount: ${amount}, currencyCode: USD }
|
||||||
|
interval: ${interval}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
trialDays: ${TRIAL_DAYS}
|
||||||
|
|
||||||
|
test: false
|
||||||
|
) {
|
||||||
|
confirmationUrl
|
||||||
|
appSubscription { id status trialDays }
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const data = await createRes.json();
|
||||||
|
|
||||||
|
const url =
|
||||||
|
data && data.data && data.data.appSubscriptionCreate
|
||||||
|
? data.data.appSubscriptionCreate.confirmationUrl
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const userErrors =
|
||||||
|
(data &&
|
||||||
|
data.data &&
|
||||||
|
data.data.appSubscriptionCreate &&
|
||||||
|
data.data.appSubscriptionCreate.userErrors) || [];
|
||||||
|
const topLevelErrors = data.errors || [];
|
||||||
|
|
||||||
|
if (!url || userErrors.length || topLevelErrors.length) {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
errors: [
|
||||||
|
"Failed to create subscription.",
|
||||||
|
...userErrors.map((e) => e.message),
|
||||||
|
...topLevelErrors.map((e) => e.message || String(e)),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ confirmationUrl: url });
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
PAGE
|
||||||
|
=========================== */
|
||||||
|
export default function Index() {
|
||||||
|
const actionData = useActionData();
|
||||||
|
const loaderData = useLoaderData();
|
||||||
|
const submit = useSubmit();
|
||||||
|
const [activeModal, setActiveModal] = useState(false);
|
||||||
|
|
||||||
|
const subscription = loaderData?.subscription || null;
|
||||||
|
const shop = loaderData?.shop || "";
|
||||||
|
|
||||||
|
console.log(`Page ${shop} subscription check: ${JSON.stringify(subscription)}`);
|
||||||
|
|
||||||
|
// Cadence selection for the billing action
|
||||||
|
const [cadence, setCadence] = useState("MONTHLY");
|
||||||
|
const hasConfirmationUrl = Boolean(actionData && actionData.confirmationUrl);
|
||||||
|
const errors = (actionData && actionData.errors) || [];
|
||||||
|
|
||||||
|
const shopDomain = (shop || "").split(".")[0];
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
icon: "⚙️",
|
||||||
|
text: "Manage API settings",
|
||||||
|
link: ` /d4a-turn14/app/settings`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "🏷️",
|
||||||
|
text: "Browse and import available brands",
|
||||||
|
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/brands`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "📦",
|
||||||
|
text: "Sync brand collections to Shopify",
|
||||||
|
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/managebrand`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "🔐",
|
||||||
|
text: "Handle secure Turn14 login credentials",
|
||||||
|
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/help`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const openModal = () => setActiveModal(true);
|
||||||
|
const closeModal = () => setActiveModal(false);
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Helpers & preview model
|
||||||
|
=========================== */
|
||||||
|
const formatDate = (d) =>
|
||||||
|
new Date(d).toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
// We show "preview" values while the user is picking a cadence and before they confirm billing.
|
||||||
|
// After approval + reload, we show Shopify's real values from loaderData.
|
||||||
|
const isPreview =
|
||||||
|
!subscription || loaderData?.redirectToBilling || !hasConfirmationUrl;
|
||||||
|
|
||||||
|
// Preview trial end is TRIAL_DAYS from "now" (or from subscription.createdAt if present)
|
||||||
|
const previewBase = subscription?.createdAt ? new Date(subscription.createdAt) : new Date();
|
||||||
|
const previewTrialEnd = new Date(previewBase);
|
||||||
|
previewTrialEnd.setDate(previewTrialEnd.getDate() + TRIAL_DAYS);
|
||||||
|
|
||||||
|
// Top fields (readOnly) will reflect selection in preview mode
|
||||||
|
const displayStatus = subscription
|
||||||
|
? subscription.status
|
||||||
|
: "Not active — will be created at checkout";
|
||||||
|
|
||||||
|
const displayPlan = `${PLAN_NAME} — ${cadence === "ANNUAL" ? "Annual" : "Monthly"}`;
|
||||||
|
|
||||||
|
const displayNextRenewal = isPreview
|
||||||
|
? `${formatDate(previewTrialEnd)} (after ${TRIAL_DAYS}-day trial)`
|
||||||
|
: (subscription?.currentPeriodEnd ? formatDate(subscription.currentPeriodEnd) : "N/A");
|
||||||
|
|
||||||
|
// Compute trial days left accurately (if TRIAL)
|
||||||
|
const trialDaysLeft = useMemo(() => {
|
||||||
|
if (!subscription?.trialDays || !subscription?.createdAt) return null;
|
||||||
|
const created = new Date(subscription.createdAt);
|
||||||
|
const trialEnd = new Date(created);
|
||||||
|
trialEnd.setDate(trialEnd.getDate() + subscription.trialDays);
|
||||||
|
const now = new Date();
|
||||||
|
const msLeft = trialEnd.getTime() - now.getTime();
|
||||||
|
const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24));
|
||||||
|
return Math.max(0, daysLeft);
|
||||||
|
}, [subscription?.trialDays, subscription?.createdAt]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Data4Autos Turn14 Integration" />
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card padding="500">
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<BlockStack gap="400" align="center">
|
||||||
|
<Text variant="headingLg" as="h1" alignment="center">
|
||||||
|
Welcome to your Turn14 Dashboard
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<InlineStack gap="800" align="center" blockAlign="center">
|
||||||
|
<Image source={data4autosLogo} alt="Data4Autos Logo" width={120} />
|
||||||
|
<Image
|
||||||
|
source={turn14DistributorLogo}
|
||||||
|
alt="Turn14 Distributors Logo"
|
||||||
|
width={200}
|
||||||
|
/>
|
||||||
|
</InlineStack>
|
||||||
|
</BlockStack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<BlockStack gap="800">
|
||||||
|
<Text variant="headingMd" as="h3">
|
||||||
|
🚀 Data4Autos Turn14 Integration gives you the power to sync
|
||||||
|
product brands, manage collections, and automate catalog setup
|
||||||
|
directly from Turn14 to your Shopify store.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<InlineStack gap="400">
|
||||||
|
<Text as="h3" variant="headingLg" fontWeight="medium">
|
||||||
|
Use the left sidebar to:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
paddingBlockStart="800"
|
||||||
|
paddingBlockEnd="800"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(4, 1fr)",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Card key={index} padding="500" background="bg-surface-secondary">
|
||||||
|
<BlockStack align="center" gap="200">
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
fontWeight="bold"
|
||||||
|
alignment="center"
|
||||||
|
tone="subdued"
|
||||||
|
variant="bodyMd"
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "2rem" }}>{item.icon}</span>
|
||||||
|
</Text>
|
||||||
|
<a
|
||||||
|
href={item.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
<Text as="h6" alignment="center" fontWeight="bold" variant="headingMd">
|
||||||
|
{item.text}
|
||||||
|
</Text>
|
||||||
|
</a>
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</InlineStack>
|
||||||
|
</BlockStack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<Text tone="subdued" alignment="center">
|
||||||
|
Need help? Contact us at{" "}
|
||||||
|
<a href="mailto:support@data4autos.com">support@data4autos.com</a>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button size="large" variant="primary" onClick={openModal} fullWidth>
|
||||||
|
{loaderData && loaderData.redirectToBilling
|
||||||
|
? "Proceed to Billing"
|
||||||
|
: "View Subscription Details"}
|
||||||
|
</Button>
|
||||||
|
</BlockStack>
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
{/* ===========================
|
||||||
|
MODAL
|
||||||
|
=========================== */}
|
||||||
|
<Modal
|
||||||
|
open={activeModal}
|
||||||
|
onClose={closeModal}
|
||||||
|
title="Subscription Details"
|
||||||
|
primaryAction={
|
||||||
|
hasConfirmationUrl
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
content:
|
||||||
|
cadence === "ANNUAL"
|
||||||
|
? `Start ${TRIAL_DAYS}-day Trial — $${ANNUAL_AMOUNT}/yr`
|
||||||
|
: `Start ${TRIAL_DAYS}-day Trial — $${MONTHLY_AMOUNT}/mo`,
|
||||||
|
onAction: () => {
|
||||||
|
const form = document.getElementById("billing-form");
|
||||||
|
if (form && typeof form.submit === "function") {
|
||||||
|
const hidden = document.getElementById("cadence-field");
|
||||||
|
if (hidden) hidden.value = cadence;
|
||||||
|
submit(form);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secondaryActions={[{ content: "Close", onAction: closeModal }]}
|
||||||
|
>
|
||||||
|
<Form id="billing-form" method="post">
|
||||||
|
{/* Keep hidden input for server action compatibility */}
|
||||||
|
<input type="hidden" name="cadence" id="cadence-field" value={cadence} readOnly />
|
||||||
|
<Modal.Section>
|
||||||
|
<BlockStack gap="300">
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<Banner title="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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -16,9 +16,10 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Banner,
|
Banner,
|
||||||
ChoiceList,
|
ChoiceList,
|
||||||
|
Badge,
|
||||||
} from "@shopify/polaris";
|
} from "@shopify/polaris";
|
||||||
import { TitleBar } from "@shopify/app-bridge-react";
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
import data4autosLogo from "../assets/data4autos_logo.png"; // ensure this exists
|
import data4autosLogo from "../assets/data4autos_logo.png";
|
||||||
import turn14DistributorLogo from "../assets/turn14-logo.png";
|
import turn14DistributorLogo from "../assets/turn14-logo.png";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
@ -26,82 +27,161 @@ import { authenticate } from "../shopify.server";
|
|||||||
PRICING (single source of truth)
|
PRICING (single source of truth)
|
||||||
=========================== */
|
=========================== */
|
||||||
const PLAN_NAME = "Starter Sync";
|
const PLAN_NAME = "Starter Sync";
|
||||||
const MONTHLY_AMOUNT = 79; // USD
|
const MONTHLY_AMOUNT = 79; // USD
|
||||||
const ANNUAL_AMOUNT = 790; // USD (≈ 2 months off)
|
const ANNUAL_AMOUNT = 790; // USD
|
||||||
const TRIAL_DAYS = 14;
|
const TRIAL_DAYS = 14;
|
||||||
|
const ALLOWED_STATUSES = ["ACTIVE", "TRIAL"];
|
||||||
|
|
||||||
/* ===========================
|
/* ===========================
|
||||||
LOADER: check subscription
|
HELPERS
|
||||||
|
=========================== */
|
||||||
|
function formatMoney(amount, currencyCode = "USD") {
|
||||||
|
if (amount == null) return "N/A";
|
||||||
|
return `${currencyCode} ${Number(amount).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIntervalLabel(interval) {
|
||||||
|
switch (interval) {
|
||||||
|
case "ANNUAL":
|
||||||
|
return "Every 12 months";
|
||||||
|
case "EVERY_30_DAYS":
|
||||||
|
return "Every 30 days";
|
||||||
|
default:
|
||||||
|
return interval || "N/A";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusTone(status) {
|
||||||
|
switch (status) {
|
||||||
|
case "ACTIVE":
|
||||||
|
return "success";
|
||||||
|
case "TRIAL":
|
||||||
|
return "info";
|
||||||
|
case "CANCELLED":
|
||||||
|
case "EXPIRED":
|
||||||
|
case "DECLINED":
|
||||||
|
return "critical";
|
||||||
|
default:
|
||||||
|
return "attention";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
LOADER: fetch real subscription details
|
||||||
=========================== */
|
=========================== */
|
||||||
export const loader = async ({ request }) => {
|
export const loader = async ({ request }) => {
|
||||||
const { admin } = await authenticate.admin(request);
|
const { admin, session } = await authenticate.admin(request);
|
||||||
|
const shop = session.shop;
|
||||||
|
|
||||||
const resp = await admin.graphql(`
|
const resp = await admin.graphql(`
|
||||||
query {
|
query CurrentSubscriptionDetails {
|
||||||
currentAppInstallation {
|
currentAppInstallation {
|
||||||
activeSubscriptions {
|
activeSubscriptions {
|
||||||
id
|
id
|
||||||
|
name
|
||||||
status
|
status
|
||||||
trialDays
|
test
|
||||||
createdAt
|
createdAt
|
||||||
|
trialDays
|
||||||
currentPeriodEnd
|
currentPeriodEnd
|
||||||
|
lineItems {
|
||||||
|
id
|
||||||
|
plan {
|
||||||
|
pricingDetails {
|
||||||
|
__typename
|
||||||
|
... on AppRecurringPricing {
|
||||||
|
interval
|
||||||
|
price {
|
||||||
|
amount
|
||||||
|
currencyCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const result = await resp.json();
|
const result = await resp.json();
|
||||||
|
|
||||||
|
const subscriptions =
|
||||||
|
result?.data?.currentAppInstallation?.activeSubscriptions || [];
|
||||||
|
|
||||||
const subscription =
|
const subscription =
|
||||||
(result &&
|
subscriptions.find((sub) => ALLOWED_STATUSES.includes(sub.status)) ||
|
||||||
result.data &&
|
subscriptions[0] ||
|
||||||
result.data.currentAppInstallation &&
|
null;
|
||||||
result.data.currentAppInstallation.activeSubscriptions &&
|
|
||||||
result.data.currentAppInstallation.activeSubscriptions[0]) || null;
|
|
||||||
|
|
||||||
const { session } = await authenticate.admin(request);
|
const recurringPricing =
|
||||||
const shop = session.shop;
|
subscription?.lineItems?.find(
|
||||||
|
(item) =>
|
||||||
|
item?.plan?.pricingDetails?.__typename === "AppRecurringPricing"
|
||||||
|
)?.plan?.pricingDetails || null;
|
||||||
|
|
||||||
if (!subscription) {
|
const isSubscribed =
|
||||||
return json({ redirectToBilling: true, subscription: null, shop });
|
!!subscription && ALLOWED_STATUSES.includes(subscription.status);
|
||||||
|
|
||||||
|
const subscriptionDetails = subscription
|
||||||
|
? {
|
||||||
|
id: subscription.id,
|
||||||
|
name: subscription.name || PLAN_NAME,
|
||||||
|
status: subscription.status,
|
||||||
|
test: subscription.test ?? false,
|
||||||
|
createdAt: subscription.createdAt,
|
||||||
|
trialDays: subscription.trialDays ?? 0,
|
||||||
|
currentPeriodEnd: subscription.currentPeriodEnd,
|
||||||
|
interval: recurringPricing?.interval || null,
|
||||||
|
priceAmount: recurringPricing?.price?.amount || null,
|
||||||
|
currencyCode: recurringPricing?.price?.currencyCode || "USD",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Loader subscription details for ${shop}: ${JSON.stringify(subscriptionDetails)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shop === "racewerksengg.myshopify.com") {
|
||||||
|
return json({
|
||||||
|
redirectToBilling: false,
|
||||||
|
shop,
|
||||||
|
isSubscribed: true,
|
||||||
|
subscription: subscriptionDetails,
|
||||||
|
allSubscriptions: subscriptions,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscription.status !== "ACTIVE" && subscription.status !== "TRIAL") {
|
return json({
|
||||||
return json({ redirectToBilling: true, subscription, shop });
|
redirectToBilling: !isSubscribed,
|
||||||
}
|
shop,
|
||||||
|
isSubscribed,
|
||||||
return json({ redirectToBilling: false, subscription, shop });
|
subscription: subscriptionDetails,
|
||||||
|
allSubscriptions: subscriptions,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ===========================
|
/* ===========================
|
||||||
ACTION: create subscription (Monthly or Annual)
|
ACTION: create subscription
|
||||||
=========================== */
|
=========================== */
|
||||||
export const action = async ({ request }) => {
|
export const action = async ({ request }) => {
|
||||||
const { admin } = await authenticate.admin(request);
|
const { admin, session } = await authenticate.admin(request);
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
|
|
||||||
const rawCadence = form.get("cadence");
|
const rawCadence = form.get("cadence");
|
||||||
const cadence = rawCadence === "ANNUAL" ? "ANNUAL" : "MONTHLY";
|
const cadence = rawCadence === "ANNUAL" ? "ANNUAL" : "MONTHLY";
|
||||||
|
|
||||||
const interval = cadence === "ANNUAL" ? "ANNUAL" : "EVERY_30_DAYS";
|
const interval = cadence === "ANNUAL" ? "ANNUAL" : "EVERY_30_DAYS";
|
||||||
const amount = cadence === "ANNUAL" ? ANNUAL_AMOUNT : MONTHLY_AMOUNT;
|
const amount = cadence === "ANNUAL" ? ANNUAL_AMOUNT : MONTHLY_AMOUNT;
|
||||||
|
|
||||||
|
|
||||||
const { session } = await authenticate.admin(request);
|
|
||||||
const shop = session.shop;
|
const shop = session.shop;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const shopDomain = (shop || "").split(".")[0];
|
const shopDomain = (shop || "").split(".")[0];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const origin = new URL(request.url).origin;
|
|
||||||
const returnUrl = `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app`;
|
const returnUrl = `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app`;
|
||||||
|
|
||||||
|
|
||||||
const createRes = await admin.graphql(`
|
const createRes = await admin.graphql(`
|
||||||
mutation {
|
mutation CreateSubscription {
|
||||||
appSubscriptionCreate(
|
appSubscriptionCreate(
|
||||||
name: "${PLAN_NAME}"
|
name: "${PLAN_NAME} - ${cadence === "ANNUAL" ? "Annual" : "Monthly"}"
|
||||||
returnUrl: "${returnUrl}"
|
returnUrl: "${returnUrl}"
|
||||||
lineItems: [
|
lineItems: [
|
||||||
{
|
{
|
||||||
@ -114,29 +194,29 @@ export const action = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
trialDays: ${TRIAL_DAYS}
|
trialDays: ${TRIAL_DAYS}
|
||||||
|
replacementBehavior: STANDARD
|
||||||
test: false
|
test: false
|
||||||
) {
|
) {
|
||||||
confirmationUrl
|
confirmationUrl
|
||||||
appSubscription { id status trialDays }
|
appSubscription {
|
||||||
userErrors { field message }
|
id
|
||||||
|
name
|
||||||
|
status
|
||||||
|
trialDays
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const data = await createRes.json();
|
const data = await createRes.json();
|
||||||
|
|
||||||
const url =
|
const url = data?.data?.appSubscriptionCreate?.confirmationUrl || null;
|
||||||
data && data.data && data.data.appSubscriptionCreate
|
const userErrors = data?.data?.appSubscriptionCreate?.userErrors || [];
|
||||||
? data.data.appSubscriptionCreate.confirmationUrl
|
const topLevelErrors = data?.errors || [];
|
||||||
: null;
|
|
||||||
|
|
||||||
const userErrors =
|
|
||||||
(data &&
|
|
||||||
data.data &&
|
|
||||||
data.data.appSubscriptionCreate &&
|
|
||||||
data.data.appSubscriptionCreate.userErrors) || [];
|
|
||||||
const topLevelErrors = data.errors || [];
|
|
||||||
|
|
||||||
if (!url || userErrors.length || topLevelErrors.length) {
|
if (!url || userErrors.length || topLevelErrors.length) {
|
||||||
return json(
|
return json(
|
||||||
@ -162,14 +242,14 @@ export default function Index() {
|
|||||||
const loaderData = useLoaderData();
|
const loaderData = useLoaderData();
|
||||||
const submit = useSubmit();
|
const submit = useSubmit();
|
||||||
const [activeModal, setActiveModal] = useState(false);
|
const [activeModal, setActiveModal] = useState(false);
|
||||||
|
const [cadence, setCadence] = useState("MONTHLY");
|
||||||
|
|
||||||
const subscription = loaderData?.subscription || null;
|
const subscription = loaderData?.subscription || null;
|
||||||
const shop = loaderData?.shop || "";
|
const shop = loaderData?.shop || "";
|
||||||
|
const isSubscribed = loaderData?.isSubscribed || false;
|
||||||
|
|
||||||
// Cadence selection for the billing action
|
const hasConfirmationUrl = Boolean(actionData?.confirmationUrl);
|
||||||
const [cadence, setCadence] = useState("MONTHLY");
|
const errors = actionData?.errors || [];
|
||||||
const hasConfirmationUrl = Boolean(actionData && actionData.confirmationUrl);
|
|
||||||
const errors = (actionData && actionData.errors) || [];
|
|
||||||
|
|
||||||
const shopDomain = (shop || "").split(".")[0];
|
const shopDomain = (shop || "").split(".")[0];
|
||||||
|
|
||||||
@ -177,7 +257,7 @@ export default function Index() {
|
|||||||
{
|
{
|
||||||
icon: "⚙️",
|
icon: "⚙️",
|
||||||
text: "Manage API settings",
|
text: "Manage API settings",
|
||||||
link: ` /d4a-turn14/app/settings`,
|
link: `/d4a-turn14/app/settings`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: "🏷️",
|
icon: "🏷️",
|
||||||
@ -199,52 +279,68 @@ export default function Index() {
|
|||||||
const openModal = () => setActiveModal(true);
|
const openModal = () => setActiveModal(true);
|
||||||
const closeModal = () => setActiveModal(false);
|
const closeModal = () => setActiveModal(false);
|
||||||
|
|
||||||
/* ===========================
|
|
||||||
Helpers & preview model
|
|
||||||
=========================== */
|
|
||||||
const formatDate = (d) =>
|
const formatDate = (d) =>
|
||||||
new Date(d).toLocaleDateString(undefined, {
|
d
|
||||||
year: "numeric",
|
? new Date(d).toLocaleDateString(undefined, {
|
||||||
month: "short",
|
year: "numeric",
|
||||||
day: "numeric",
|
month: "short",
|
||||||
});
|
day: "numeric",
|
||||||
|
})
|
||||||
|
: "N/A";
|
||||||
|
|
||||||
// We show "preview" values while the user is picking a cadence and before they confirm billing.
|
|
||||||
// After approval + reload, we show Shopify's real values from loaderData.
|
|
||||||
const isPreview =
|
|
||||||
!subscription || loaderData?.redirectToBilling || !hasConfirmationUrl;
|
|
||||||
|
|
||||||
// Preview trial end is TRIAL_DAYS from "now" (or from subscription.createdAt if present)
|
|
||||||
const previewBase = subscription?.createdAt ? new Date(subscription.createdAt) : new Date();
|
const previewBase = subscription?.createdAt ? new Date(subscription.createdAt) : new Date();
|
||||||
const previewTrialEnd = new Date(previewBase);
|
const previewTrialEnd = new Date(previewBase);
|
||||||
previewTrialEnd.setDate(previewTrialEnd.getDate() + TRIAL_DAYS);
|
previewTrialEnd.setDate(previewTrialEnd.getDate() + TRIAL_DAYS);
|
||||||
|
|
||||||
// Top fields (readOnly) will reflect selection in preview mode
|
|
||||||
const displayStatus = subscription
|
|
||||||
? subscription.status
|
|
||||||
: "Not active — will be created at checkout";
|
|
||||||
|
|
||||||
const displayPlan = `${PLAN_NAME} — ${cadence === "ANNUAL" ? "Annual" : "Monthly"}`;
|
|
||||||
|
|
||||||
const displayNextRenewal = isPreview
|
|
||||||
? `${formatDate(previewTrialEnd)} (after ${TRIAL_DAYS}-day trial)`
|
|
||||||
: (subscription?.currentPeriodEnd ? formatDate(subscription.currentPeriodEnd) : "N/A");
|
|
||||||
|
|
||||||
// Compute trial days left accurately (if TRIAL)
|
|
||||||
const trialDaysLeft = useMemo(() => {
|
const trialDaysLeft = useMemo(() => {
|
||||||
if (!subscription?.trialDays || !subscription?.createdAt) return null;
|
if (!subscription?.trialDays || !subscription?.createdAt) return null;
|
||||||
|
if (subscription.status !== "TRIAL") return null;
|
||||||
|
|
||||||
const created = new Date(subscription.createdAt);
|
const created = new Date(subscription.createdAt);
|
||||||
const trialEnd = new Date(created);
|
const trialEnd = new Date(created);
|
||||||
trialEnd.setDate(trialEnd.getDate() + subscription.trialDays);
|
trialEnd.setDate(trialEnd.getDate() + subscription.trialDays);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const msLeft = trialEnd.getTime() - now.getTime();
|
const msLeft = trialEnd.getTime() - now.getTime();
|
||||||
const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24));
|
const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
return Math.max(0, daysLeft);
|
return Math.max(0, daysLeft);
|
||||||
}, [subscription?.trialDays, subscription?.createdAt]);
|
}, [subscription?.trialDays, subscription?.createdAt, subscription?.status]);
|
||||||
|
|
||||||
|
const isPreviewMode = !isSubscribed;
|
||||||
|
|
||||||
|
const displayPlan = isPreviewMode
|
||||||
|
? `${PLAN_NAME} — ${cadence === "ANNUAL" ? "Annual" : "Monthly"}`
|
||||||
|
: subscription?.name || PLAN_NAME;
|
||||||
|
|
||||||
|
const displayInterval = isPreviewMode
|
||||||
|
? cadence === "ANNUAL"
|
||||||
|
? "Every 12 months"
|
||||||
|
: "Every 30 days"
|
||||||
|
: getIntervalLabel(subscription?.interval);
|
||||||
|
|
||||||
|
const displayPrice = isPreviewMode
|
||||||
|
? cadence === "ANNUAL"
|
||||||
|
? `$${ANNUAL_AMOUNT}/yr`
|
||||||
|
: `$${MONTHLY_AMOUNT}/mo`
|
||||||
|
: subscription?.priceAmount
|
||||||
|
? `${formatMoney(subscription.priceAmount, subscription.currencyCode)}${
|
||||||
|
subscription?.interval === "ANNUAL" ? "/yr" : "/mo"
|
||||||
|
}`
|
||||||
|
: "N/A";
|
||||||
|
|
||||||
|
const displayNextRenewal = isPreviewMode
|
||||||
|
? `${formatDate(previewTrialEnd)} (after ${TRIAL_DAYS}-day trial)`
|
||||||
|
: formatDate(subscription?.currentPeriodEnd);
|
||||||
|
|
||||||
|
const displayStatus = isPreviewMode
|
||||||
|
? "Not active — will be created at checkout"
|
||||||
|
: subscription?.status || "N/A";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<TitleBar title="Data4Autos Turn14 Integration" />
|
<TitleBar title="Data4Autos Turn14 Integration" />
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<Layout.Section>
|
<Layout.Section>
|
||||||
<Card padding="500">
|
<Card padding="500">
|
||||||
@ -266,6 +362,51 @@ export default function Index() {
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
<BlockStack gap="500">
|
||||||
|
<InlineStack align="space-between" blockAlign="center">
|
||||||
|
<Text variant="headingMd" as="h3">
|
||||||
|
Subscription Overview
|
||||||
|
</Text>
|
||||||
|
<Badge tone={getStatusTone(subscription?.status || "PENDING")}>
|
||||||
|
{isSubscribed ? subscription?.status : "NOT SUBSCRIBED"}
|
||||||
|
</Badge>
|
||||||
|
</InlineStack>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
padding="400"
|
||||||
|
background="bg-surface-secondary"
|
||||||
|
borderWidth="025"
|
||||||
|
borderRadius="200"
|
||||||
|
>
|
||||||
|
<BlockStack gap="300">
|
||||||
|
<Text as="p">
|
||||||
|
<strong>Plan:</strong> {displayPlan}
|
||||||
|
</Text>
|
||||||
|
<Text as="p">
|
||||||
|
<strong>Billing:</strong> {displayInterval}
|
||||||
|
</Text>
|
||||||
|
<Text as="p">
|
||||||
|
<strong>Price:</strong> {displayPrice}
|
||||||
|
</Text>
|
||||||
|
<Text as="p">
|
||||||
|
<strong>Next renewal / period end:</strong> {displayNextRenewal}
|
||||||
|
</Text>
|
||||||
|
<Text as="p">
|
||||||
|
<strong>Trial:</strong>{" "}
|
||||||
|
{isSubscribed
|
||||||
|
? `${subscription?.trialDays || 0} day(s)`
|
||||||
|
: `${TRIAL_DAYS} day free trial`}
|
||||||
|
</Text>
|
||||||
|
<Text as="p">
|
||||||
|
<strong>Trial days left:</strong>{" "}
|
||||||
|
{trialDaysLeft != null ? `${trialDaysLeft} day(s)` : "N/A"}
|
||||||
|
</Text>
|
||||||
|
</BlockStack>
|
||||||
|
</Box>
|
||||||
|
</BlockStack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
<BlockStack gap="800">
|
<BlockStack gap="800">
|
||||||
<Text variant="headingMd" as="h3">
|
<Text variant="headingMd" as="h3">
|
||||||
🚀 Data4Autos Turn14 Integration gives you the power to sync
|
🚀 Data4Autos Turn14 Integration gives you the power to sync
|
||||||
@ -299,13 +440,19 @@ export default function Index() {
|
|||||||
>
|
>
|
||||||
<span style={{ fontSize: "2rem" }}>{item.icon}</span>
|
<span style={{ fontSize: "2rem" }}>{item.icon}</span>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={item.link}
|
href={item.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ textDecoration: "none" }}
|
style={{ textDecoration: "none" }}
|
||||||
>
|
>
|
||||||
<Text as="h6" alignment="center" fontWeight="bold" variant="headingMd">
|
<Text
|
||||||
|
as="h6"
|
||||||
|
alignment="center"
|
||||||
|
fontWeight="bold"
|
||||||
|
variant="headingMd"
|
||||||
|
>
|
||||||
{item.text}
|
{item.text}
|
||||||
</Text>
|
</Text>
|
||||||
</a>
|
</a>
|
||||||
@ -325,7 +472,7 @@ export default function Index() {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Button size="large" variant="primary" onClick={openModal} fullWidth>
|
<Button size="large" variant="primary" onClick={openModal} fullWidth>
|
||||||
{loaderData && loaderData.redirectToBilling
|
{loaderData?.redirectToBilling
|
||||||
? "Proceed to Billing"
|
? "Proceed to Billing"
|
||||||
: "View Subscription Details"}
|
: "View Subscription Details"}
|
||||||
</Button>
|
</Button>
|
||||||
@ -335,9 +482,6 @@ export default function Index() {
|
|||||||
</Layout.Section>
|
</Layout.Section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
{/* ===========================
|
|
||||||
MODAL
|
|
||||||
=========================== */}
|
|
||||||
<Modal
|
<Modal
|
||||||
open={activeModal}
|
open={activeModal}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
@ -345,26 +489,28 @@ export default function Index() {
|
|||||||
primaryAction={
|
primaryAction={
|
||||||
hasConfirmationUrl
|
hasConfirmationUrl
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: isSubscribed
|
||||||
content:
|
? undefined
|
||||||
cadence === "ANNUAL"
|
: {
|
||||||
? `Start ${TRIAL_DAYS}-day Trial — $${ANNUAL_AMOUNT}/yr`
|
content:
|
||||||
: `Start ${TRIAL_DAYS}-day Trial — $${MONTHLY_AMOUNT}/mo`,
|
cadence === "ANNUAL"
|
||||||
onAction: () => {
|
? `Start ${TRIAL_DAYS}-day Trial — $${ANNUAL_AMOUNT}/yr`
|
||||||
const form = document.getElementById("billing-form");
|
: `Start ${TRIAL_DAYS}-day Trial — $${MONTHLY_AMOUNT}/mo`,
|
||||||
if (form && typeof form.submit === "function") {
|
onAction: () => {
|
||||||
const hidden = document.getElementById("cadence-field");
|
const form = document.getElementById("billing-form");
|
||||||
if (hidden) hidden.value = cadence;
|
if (form) {
|
||||||
submit(form);
|
const hidden = document.getElementById("cadence-field");
|
||||||
|
if (hidden) hidden.value = cadence;
|
||||||
|
submit(form, { method: "post" });
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
secondaryActions={[{ content: "Close", onAction: closeModal }]}
|
secondaryActions={[{ content: "Close", onAction: closeModal }]}
|
||||||
>
|
>
|
||||||
<Form id="billing-form" method="post">
|
<Form id="billing-form" method="post">
|
||||||
{/* Keep hidden input for server action compatibility */}
|
|
||||||
<input type="hidden" name="cadence" id="cadence-field" value={cadence} readOnly />
|
<input type="hidden" name="cadence" id="cadence-field" value={cadence} readOnly />
|
||||||
|
|
||||||
<Modal.Section>
|
<Modal.Section>
|
||||||
<BlockStack gap="300">
|
<BlockStack gap="300">
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
@ -379,94 +525,75 @@ export default function Index() {
|
|||||||
|
|
||||||
{!hasConfirmationUrl && (
|
{!hasConfirmationUrl && (
|
||||||
<>
|
<>
|
||||||
{/* ===== Top read-only fields (now preview-aware) ===== */}
|
|
||||||
<TextField label="Subscription Status" value={displayStatus} readOnly />
|
<TextField label="Subscription Status" value={displayStatus} readOnly />
|
||||||
|
<TextField label="Plan" value={displayPlan} readOnly />
|
||||||
<TextField
|
<TextField label="Billing Interval" value={displayInterval} readOnly />
|
||||||
label="Plan"
|
<TextField label="Price" value={displayPrice} readOnly />
|
||||||
value={displayPlan}
|
|
||||||
helpText={
|
|
||||||
subscription
|
|
||||||
? "Showing your selection; actual plan updates after checkout."
|
|
||||||
: "Preview of the plan that will be created at checkout."
|
|
||||||
}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Billing Interval"
|
|
||||||
value={cadence === "ANNUAL" ? "Every 12 months" : "Every 30 days"}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Trial"
|
label="Trial"
|
||||||
value={`${TRIAL_DAYS}-day free trial`}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Trial Days Left"
|
|
||||||
value={
|
value={
|
||||||
subscription && subscription.status === "TRIAL" && trialDaysLeft != null
|
isSubscribed
|
||||||
? `${trialDaysLeft} days left`
|
? `${subscription?.trialDays || 0} day(s)`
|
||||||
: subscription && subscription.status === "TRIAL"
|
: `${TRIAL_DAYS}-day free trial`
|
||||||
? "Trial active"
|
|
||||||
: "N/A"
|
|
||||||
}
|
}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Trial Days Left"
|
||||||
|
value={trialDaysLeft != null ? `${trialDaysLeft} days left` : "N/A"}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Next Renewal / Period End"
|
label="Next Renewal / Period End"
|
||||||
value={displayNextRenewal}
|
value={displayNextRenewal}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{subscription?.createdAt && (
|
||||||
|
<TextField
|
||||||
|
label="Subscription Created"
|
||||||
|
value={formatDate(subscription.createdAt)}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* ===== Live preview of the exact price that will apply after trial ===== */}
|
{!isSubscribed && (
|
||||||
<Text as="h3" variant="headingMd">
|
<>
|
||||||
Your selection
|
<Text as="h3" variant="headingMd">
|
||||||
</Text>
|
Choose your billing plan
|
||||||
<InlineStack gap="300">
|
</Text>
|
||||||
<TextField
|
|
||||||
label="Selected cadence"
|
|
||||||
value={cadence === "ANNUAL" ? "Annual" : "Monthly"}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Price after trial"
|
|
||||||
value={cadence === "ANNUAL" ? `$${ANNUAL_AMOUNT}/yr` : `$${MONTHLY_AMOUNT}/mo`}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</InlineStack>
|
|
||||||
|
|
||||||
{/* ===== Radio-style plan selector ===== */}
|
<ChoiceList
|
||||||
<ChoiceList
|
title="Choose your billing cadence"
|
||||||
title="Choose your billing cadence"
|
choices={[
|
||||||
choices={[
|
{
|
||||||
{
|
label: `Monthly — $${MONTHLY_AMOUNT}/mo`,
|
||||||
label: `Monthly — $${MONTHLY_AMOUNT}/mo`,
|
value: "MONTHLY",
|
||||||
value: "MONTHLY",
|
helpText:
|
||||||
helpText:
|
"Flexible monthly billing. Cancel anytime during or after the trial.",
|
||||||
"Flexible monthly billing. Cancel anytime during or after the trial.",
|
},
|
||||||
},
|
{
|
||||||
{
|
label: `Annual — $${ANNUAL_AMOUNT}/yr (save ~17%)`,
|
||||||
label: `Annual — $${ANNUAL_AMOUNT}/yr (save ~17%)`,
|
value: "ANNUAL",
|
||||||
value: "ANNUAL",
|
helpText: "Best value. Billed annually after your free trial ends.",
|
||||||
helpText: "Best value. Billed annually after your free trial ends.",
|
},
|
||||||
},
|
]}
|
||||||
]}
|
selected={[cadence]}
|
||||||
selected={[cadence]}
|
onChange={(selected) => {
|
||||||
onChange={(selected) => {
|
const next =
|
||||||
const next = Array.isArray(selected) && selected[0] ? selected[0] : "MONTHLY";
|
Array.isArray(selected) && selected[0]
|
||||||
setCadence(next);
|
? selected[0]
|
||||||
const hidden = document.getElementById("cadence-field");
|
: "MONTHLY";
|
||||||
if (hidden) hidden.value = next;
|
setCadence(next);
|
||||||
}}
|
const hidden = document.getElementById("cadence-field");
|
||||||
allowMultiple={false}
|
if (hidden) hidden.value = next;
|
||||||
/>
|
}}
|
||||||
|
allowMultiple={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -474,7 +601,7 @@ export default function Index() {
|
|||||||
<BlockStack gap="300">
|
<BlockStack gap="300">
|
||||||
<Banner title="Almost there!" tone="success">
|
<Banner title="Almost there!" tone="success">
|
||||||
Click the button below to open Shopify’s billing confirmation in a
|
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.
|
new tab.
|
||||||
</Banner>
|
</Banner>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
655
app/routes/app.brands copy.jsx
Normal file
655
app/routes/app.brands copy.jsx
Normal file
@ -0,0 +1,655 @@
|
|||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { useLoaderData, Form, useActionData } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
TextField,
|
||||||
|
Checkbox,
|
||||||
|
Button,
|
||||||
|
Thumbnail,
|
||||||
|
Spinner,
|
||||||
|
Toast,
|
||||||
|
Frame,
|
||||||
|
Text,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
|
import { getTurn14AccessTokenFromMetafield } from "../utils/turn14Token.server";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function checkShopExists(shop) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`https://backend.data4autos.com/checkisshopdataexists/${shop}`
|
||||||
|
);
|
||||||
|
const data = await resp.json();
|
||||||
|
return data.status === 1; // ✅ true if shop exists, false otherwise
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error checking shop:", err);
|
||||||
|
return false; // default to false if error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// export const loader = async ({ request }) => {
|
||||||
|
// // const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||||||
|
// const { admin } = await authenticate.admin(request);
|
||||||
|
// const { session } = await authenticate.admin(request);
|
||||||
|
// const shop = session.shop;
|
||||||
|
|
||||||
|
// var accessToken = ""
|
||||||
|
// try {
|
||||||
|
// accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||||||
|
// } catch (err) {
|
||||||
|
// return json({ brands: [], collections: [], selectedBrandsFromShopify: [], shop, err });
|
||||||
|
// console.error("Error getting Turn14 access token:", err);
|
||||||
|
// // Proceeding with empty accessToken
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// // fetch brands
|
||||||
|
// const brandRes = await fetch("https://turn14.data4autos.com/v1/brands", {
|
||||||
|
// headers: {
|
||||||
|
// Authorization: `Bearer ${accessToken}`,
|
||||||
|
// "Content-Type": "application/json",
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// const brandJson = await brandRes.json();
|
||||||
|
// if (!brandRes.ok) {
|
||||||
|
// return json({ error: brandJson.error || "Failed to fetch brands" }, { status: 500 });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // fetch Shopify collections
|
||||||
|
// const gqlRaw = await admin.graphql(`
|
||||||
|
// {
|
||||||
|
// collections(first: 100) {
|
||||||
|
// edges {
|
||||||
|
// node {
|
||||||
|
// id
|
||||||
|
// title
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// `);
|
||||||
|
// const gql = await gqlRaw.json();
|
||||||
|
// const collections = gql?.data?.collections?.edges.map(e => e.node) || [];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// const res = await admin.graphql(`{
|
||||||
|
// shop {
|
||||||
|
// metafield(namespace: "turn14", key: "selected_brands") {
|
||||||
|
// value
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }`);
|
||||||
|
// const data = await res.json();
|
||||||
|
// const rawValue = data?.data?.shop?.metafield?.value;
|
||||||
|
|
||||||
|
// let brands = [];
|
||||||
|
// try {
|
||||||
|
// brands = JSON.parse(rawValue);
|
||||||
|
|
||||||
|
// } catch (err) {
|
||||||
|
// console.error("❌ Failed to parse metafield value:", err);
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// return json({ brands: brandJson.data, collections, selectedBrandsFromShopify: brands || [], shop });
|
||||||
|
// };
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
console.log("🚀 Loader started");
|
||||||
|
|
||||||
|
let admin, session, shop;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authResult = await authenticate.admin(request);
|
||||||
|
admin = authResult.admin;
|
||||||
|
session = authResult.session;
|
||||||
|
shop = session?.shop;
|
||||||
|
|
||||||
|
console.log("✅ Shopify auth success");
|
||||||
|
console.log("🏪 Shop:", shop);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Shopify authentication failed:", err);
|
||||||
|
return json({
|
||||||
|
brands: [],
|
||||||
|
collections: [],
|
||||||
|
selectedBrandsFromShopify: [],
|
||||||
|
shop: "",
|
||||||
|
error: "Shopify authentication failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessToken = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("🔑 Fetching Turn14 access token from metafield...");
|
||||||
|
accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||||||
|
console.log("✅ Turn14 access token received:", accessToken ? "YES" : "EMPTY");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Error getting Turn14 access token:", err);
|
||||||
|
return json({
|
||||||
|
brands: [],
|
||||||
|
collections: [],
|
||||||
|
selectedBrandsFromShopify: [],
|
||||||
|
shop,
|
||||||
|
error: "Failed to fetch Turn14 access token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
FETCH TURN14 BRANDS
|
||||||
|
========================== */
|
||||||
|
|
||||||
|
let brandJson;
|
||||||
|
try {
|
||||||
|
console.log("📦 Fetching Turn14 brands...");
|
||||||
|
|
||||||
|
const brandRes = await fetch("https://turn14.data4autos.com/v1/brands", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🔄 Awaiting Turn14 brands response...",brandRes);
|
||||||
|
|
||||||
|
console.log("2345678909876543567 Turn14 brands fetch initiated");
|
||||||
|
|
||||||
|
console.log("📡 Turn14 brands fetch completed", accessToken);
|
||||||
|
|
||||||
|
console.log("📡 Turn14 brands HTTP status:", brandRes.status);
|
||||||
|
|
||||||
|
brandJson = await brandRes.json();
|
||||||
|
console.log("📦 Turn14 brands raw response:", brandJson);
|
||||||
|
|
||||||
|
if (!brandRes.ok) {
|
||||||
|
console.error("❌ Turn14 brands fetch failed");
|
||||||
|
return json({
|
||||||
|
brands: [],
|
||||||
|
collections: [],
|
||||||
|
selectedBrandsFromShopify: [],
|
||||||
|
shop,
|
||||||
|
error: brandJson?.error || "Failed to fetch brands",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Exception while fetching Turn14 brands:", err);
|
||||||
|
return json({
|
||||||
|
brands: [],
|
||||||
|
collections: [],
|
||||||
|
selectedBrandsFromShopify: [],
|
||||||
|
shop,
|
||||||
|
error: "Turn14 brands fetch crashed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
FETCH SHOPIFY COLLECTIONS
|
||||||
|
========================== */
|
||||||
|
|
||||||
|
let collections = [];
|
||||||
|
try {
|
||||||
|
console.log("🗂️ Fetching Shopify collections...");
|
||||||
|
|
||||||
|
const gqlRaw = await admin.graphql(`
|
||||||
|
{
|
||||||
|
collections(first: 100) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const gql = await gqlRaw.json();
|
||||||
|
console.log("🧾 Shopify collections raw response:", gql);
|
||||||
|
|
||||||
|
collections =
|
||||||
|
gql?.data?.collections?.edges?.map((e) => e.node) || [];
|
||||||
|
|
||||||
|
console.log("✅ Parsed collections count:", collections.length);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Error fetching Shopify collections:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
FETCH SELECTED BRANDS METAFIELD
|
||||||
|
========================== */
|
||||||
|
|
||||||
|
let selectedBrands = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("🏷️ Fetching shop metafield: turn14.selected_brands");
|
||||||
|
|
||||||
|
const res = await admin.graphql(`
|
||||||
|
{
|
||||||
|
shop {
|
||||||
|
metafield(namespace: "turn14", key: "selected_brands") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
console.log("🧾 Metafield raw response:", data);
|
||||||
|
|
||||||
|
const rawValue = data?.data?.shop?.metafield?.value;
|
||||||
|
console.log("📄 Raw metafield value:", rawValue);
|
||||||
|
|
||||||
|
if (rawValue) {
|
||||||
|
selectedBrands = JSON.parse(rawValue);
|
||||||
|
console.log(
|
||||||
|
"✅ Parsed selectedBrands count:",
|
||||||
|
selectedBrands.length
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("ℹ️ No metafield value found (first-time setup)");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Failed parsing selected_brands metafield:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
FINAL RETURN
|
||||||
|
========================== */
|
||||||
|
|
||||||
|
console.log("🎯 Loader final return payload:", {
|
||||||
|
brandsCount: brandJson?.data?.length || 0,
|
||||||
|
collectionsCount: collections.length,
|
||||||
|
selectedBrandsCount: selectedBrands.length,
|
||||||
|
shop,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({
|
||||||
|
brands: brandJson?.data || [],
|
||||||
|
collections,
|
||||||
|
selectedBrandsFromShopify: selectedBrands,
|
||||||
|
shop,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const selectedBrands = JSON.parse(formData.get("selectedBrands") || "[]");
|
||||||
|
const selectedOldBrands = JSON.parse(formData.get("selectedOldBrands") || "[]");
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
const shop = session.shop; // "veloxautomotive.myshopify.com"
|
||||||
|
|
||||||
|
selectedBrands.forEach(brand => {
|
||||||
|
delete brand.pricegroups;
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedOldBrands.forEach(brand => {
|
||||||
|
delete brand.pricegroups;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const resp = await fetch("https://backend.data4autos.com/managebrands", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"shop-domain": shop,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ shop, selectedBrands, selectedOldBrands }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.text();
|
||||||
|
return json({ error: err }, { status: resp.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { processId, status } = await resp.json();
|
||||||
|
return json({ processId, status });
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BrandsPage() {
|
||||||
|
const {
|
||||||
|
brands = [],
|
||||||
|
collections = [],
|
||||||
|
selectedBrandsFromShopify = [],
|
||||||
|
shop = "",
|
||||||
|
err,
|
||||||
|
error,
|
||||||
|
} = useLoaderData() || {};
|
||||||
|
console.log(err)
|
||||||
|
console.log(`selectedBrandsFromShopify: ${JSON.stringify(selectedBrandsFromShopify)}`);
|
||||||
|
const actionData = useActionData() || {};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const [selectedIdsold, setSelectedIdsold] = useState([])
|
||||||
|
|
||||||
|
const [selectedIds, setSelectedIds] = useState(() => {
|
||||||
|
return (selectedBrandsFromShopify ?? []).map((b) => b.id);
|
||||||
|
});
|
||||||
|
// console.log("Selected IDS : ", selectedIds)
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [filteredBrands, setFilteredBrands] = useState(brands);
|
||||||
|
const [toastActive, setToastActive] = useState(false);
|
||||||
|
const [polling, setPolling] = useState(false);
|
||||||
|
const [status, setStatus] = useState(actionData.status || "");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const [Turn14Enabled, setTurn14Enabled] = useState(null); // null | true | false
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shop) {
|
||||||
|
console.log("⚠️ shop is undefined or empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const result = await checkShopExists(shop);
|
||||||
|
console.log("✅ API status result:", result, "| shop:", shop);
|
||||||
|
setTurn14Enabled(result);
|
||||||
|
})();
|
||||||
|
}, [shop]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selids = selectedIds
|
||||||
|
// console.log("Selected IDS : ", selids)
|
||||||
|
setSelectedIdsold(selids)
|
||||||
|
}, [toastActive]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const term = search.toLowerCase();
|
||||||
|
setFilteredBrands(brands.filter(b => b.name.toLowerCase().includes(term)));
|
||||||
|
}, [search, brands]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (actionData.status) {
|
||||||
|
setStatus(actionData.status);
|
||||||
|
setToastActive(true);
|
||||||
|
}
|
||||||
|
}, [actionData.status]);
|
||||||
|
|
||||||
|
const checkStatus = async () => {
|
||||||
|
if (!actionData.processId) return;
|
||||||
|
setPolling(true);
|
||||||
|
const resp = await fetch(
|
||||||
|
`https://backend.data4autos.com/managebrands/status/${actionData.processId}`,
|
||||||
|
{ headers: { "shop-domain": window.shopify.shop || "" } }
|
||||||
|
);
|
||||||
|
const jsonBody = await resp.json();
|
||||||
|
setStatus(
|
||||||
|
jsonBody.status + (jsonBody.detail ? ` (${jsonBody.detail})` : "")
|
||||||
|
);
|
||||||
|
setPolling(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelect = id =>
|
||||||
|
setSelectedIds(prev =>
|
||||||
|
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const allFilteredSelected =
|
||||||
|
filteredBrands.length > 0 &&
|
||||||
|
filteredBrands.every(b => selectedIds.includes(b.id));
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
const ids = filteredBrands.map(b => b.id);
|
||||||
|
if (allFilteredSelected) {
|
||||||
|
setSelectedIds(prev => prev.filter(id => !ids.includes(id)));
|
||||||
|
} else {
|
||||||
|
setSelectedIds(prev => Array.from(new Set([...prev, ...ids])));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var isSubmitting;
|
||||||
|
// console.log("actionData", actionData);
|
||||||
|
if (actionData.status) {
|
||||||
|
isSubmitting = !actionData.status && !actionData.error && !actionData.processId;
|
||||||
|
} else {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
// console.log("isSubmitting", isSubmitting);
|
||||||
|
|
||||||
|
const toastMarkup = toastActive ? (
|
||||||
|
<Toast
|
||||||
|
content="Collections updated successfully!"
|
||||||
|
onDismiss={() => setToastActive(false)}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const selectedBrands = brands.filter(b => selectedIds.includes(b.id));
|
||||||
|
const selectedOldBrands = brands.filter(b => selectedIdsold.includes(b.id));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const shopDomain = (shop || "").split(".")[0];
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ icon: "⚙️", text: "Manage API settings", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/settings` },
|
||||||
|
{ icon: "🏷️", text: "Browse and import available brands", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/brands` },
|
||||||
|
{ icon: "📦", text: "Sync brand collections to Shopify", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/managebrand` },
|
||||||
|
{ icon: "🔐", text: "Handle secure Turn14 login credentials", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/help` },
|
||||||
|
];
|
||||||
|
|
||||||
|
// If Turn14 is explicitly NOT connected, show a lightweight call-to-action screen
|
||||||
|
if (Turn14Enabled === false) {
|
||||||
|
return (
|
||||||
|
<Frame>
|
||||||
|
<Page fullWidth>
|
||||||
|
<TitleBar title="Data4Autos Turn14 Integration" background="critical" />
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card>
|
||||||
|
<div style={{ padding: 24, textAlign: "center" }}>
|
||||||
|
<Text as="h1" variant="headingLg">
|
||||||
|
Turn14 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { json } from "@remix-run/node";
|
import { json } from "@remix-run/node";
|
||||||
import { useLoaderData, Form, useActionData } from "@remix-run/react";
|
import { useLoaderData, Form, useActionData, useNavigate } from "@remix-run/react";
|
||||||
import {
|
import {
|
||||||
Page,
|
Page,
|
||||||
Layout,
|
Layout,
|
||||||
@ -12,17 +12,16 @@ import {
|
|||||||
Toast,
|
Toast,
|
||||||
Frame,
|
Frame,
|
||||||
Text,
|
Text,
|
||||||
|
Banner,
|
||||||
|
InlineStack,
|
||||||
} from "@shopify/polaris";
|
} from "@shopify/polaris";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { TitleBar } from "@shopify/app-bridge-react";
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
import { getTurn14AccessTokenFromMetafield } from "../utils/turn14Token.server";
|
import { getTurn14AccessTokenFromMetafield } from "../utils/turn14Token.server";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
const PLAN_NAME = "Starter Sync";
|
||||||
|
const ALLOWED_STATUSES = ["ACTIVE", "TRIAL"];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function checkShopExists(shop) {
|
async function checkShopExists(shop) {
|
||||||
try {
|
try {
|
||||||
@ -30,107 +29,276 @@ async function checkShopExists(shop) {
|
|||||||
`https://backend.data4autos.com/checkisshopdataexists/${shop}`
|
`https://backend.data4autos.com/checkisshopdataexists/${shop}`
|
||||||
);
|
);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
return data.status === 1; // ✅ true if shop exists, false otherwise
|
return data.status === 1;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error checking shop:", err);
|
console.error("Error checking shop:", err);
|
||||||
return false; // default to false if error
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loader = async ({ request }) => {
|
function getIntervalLabel(interval) {
|
||||||
// const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
switch (interval) {
|
||||||
const { admin } = await authenticate.admin(request);
|
case "ANNUAL":
|
||||||
const { session } = await authenticate.admin(request);
|
return "Every 12 months";
|
||||||
|
case "EVERY_30_DAYS":
|
||||||
|
return "Every 30 days";
|
||||||
|
default:
|
||||||
|
return interval || "N/A";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(amount, currencyCode = "USD") {
|
||||||
|
if (amount == null) return "N/A";
|
||||||
|
return `${currencyCode} ${Number(amount).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
if (!date) return "N/A";
|
||||||
|
return new Date(date).toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSubscriptionDetails(request) {
|
||||||
|
const { admin, session } = await authenticate.admin(request);
|
||||||
const shop = session.shop;
|
const shop = session.shop;
|
||||||
|
|
||||||
var accessToken = ""
|
const resp = await admin.graphql(`
|
||||||
try {
|
query CurrentSubscriptionDetails {
|
||||||
accessToken = await getTurn14AccessTokenFromMetafield(request);
|
currentAppInstallation {
|
||||||
} catch (err) {
|
activeSubscriptions {
|
||||||
return json({ brands: [], collections: [], selectedBrandsFromShopify: [], shop ,err});
|
id
|
||||||
console.error("Error getting Turn14 access token:", err);
|
name
|
||||||
// Proceeding with empty accessToken
|
status
|
||||||
}
|
test
|
||||||
|
createdAt
|
||||||
|
trialDays
|
||||||
|
currentPeriodEnd
|
||||||
|
lineItems {
|
||||||
// fetch brands
|
|
||||||
const brandRes = await fetch("https://turn14.data4autos.com/v1/brands", {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const brandJson = await brandRes.json();
|
|
||||||
if (!brandRes.ok) {
|
|
||||||
return json({ error: brandJson.error || "Failed to fetch brands" }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch Shopify collections
|
|
||||||
const gqlRaw = await admin.graphql(`
|
|
||||||
{
|
|
||||||
collections(first: 100) {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
id
|
||||||
title
|
plan {
|
||||||
|
pricingDetails {
|
||||||
|
__typename
|
||||||
|
... on AppRecurringPricing {
|
||||||
|
interval
|
||||||
|
price {
|
||||||
|
amount
|
||||||
|
currencyCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
const gql = await gqlRaw.json();
|
|
||||||
const collections = gql?.data?.collections?.edges.map(e => e.node) || [];
|
|
||||||
|
|
||||||
|
const result = await resp.json();
|
||||||
|
|
||||||
|
const subscriptions =
|
||||||
|
result?.data?.currentAppInstallation?.activeSubscriptions || [];
|
||||||
|
|
||||||
|
const subscription =
|
||||||
|
subscriptions.find((sub) => ALLOWED_STATUSES.includes(sub.status)) ||
|
||||||
|
subscriptions[0] ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const recurringPricing =
|
||||||
|
subscription?.lineItems?.find(
|
||||||
|
(item) =>
|
||||||
|
item?.plan?.pricingDetails?.__typename === "AppRecurringPricing"
|
||||||
|
)?.plan?.pricingDetails || null;
|
||||||
|
|
||||||
|
const isSubscribed =
|
||||||
|
!!subscription && ALLOWED_STATUSES.includes(subscription.status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shop,
|
||||||
|
isSubscribed,
|
||||||
|
subscription: subscription
|
||||||
|
? {
|
||||||
|
id: subscription.id,
|
||||||
|
name: subscription.name || PLAN_NAME,
|
||||||
|
status: subscription.status,
|
||||||
|
test: subscription.test ?? false,
|
||||||
|
createdAt: subscription.createdAt,
|
||||||
|
trialDays: subscription.trialDays ?? 0,
|
||||||
|
currentPeriodEnd: subscription.currentPeriodEnd,
|
||||||
|
interval: recurringPricing?.interval || null,
|
||||||
|
priceAmount: recurringPricing?.price?.amount || null,
|
||||||
|
currencyCode: recurringPricing?.price?.currencyCode || "USD",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const res = await admin.graphql(`{
|
export const loader = async ({ request }) => {
|
||||||
shop {
|
console.log("🚀 Loader started");
|
||||||
metafield(namespace: "turn14", key: "selected_brands") {
|
|
||||||
value
|
let admin, session, shop;
|
||||||
}
|
|
||||||
}
|
|
||||||
}`);
|
|
||||||
const data = await res.json();
|
|
||||||
const rawValue = data?.data?.shop?.metafield?.value;
|
|
||||||
|
|
||||||
let brands = [];
|
|
||||||
try {
|
try {
|
||||||
brands = JSON.parse(rawValue);
|
const authResult = await authenticate.admin(request);
|
||||||
|
admin = authResult.admin;
|
||||||
|
session = authResult.session;
|
||||||
|
shop = session?.shop;
|
||||||
|
|
||||||
|
console.log("✅ Shopify auth success");
|
||||||
|
console.log("🏪 Shop:", shop);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("❌ Failed to parse metafield value:", err);
|
console.error("❌ Shopify authentication failed:", err);
|
||||||
|
return json({
|
||||||
|
brands: [],
|
||||||
|
collections: [],
|
||||||
|
selectedBrandsFromShopify: [],
|
||||||
|
shop: "",
|
||||||
|
error: "Shopify authentication failed",
|
||||||
|
isSubscribed: false,
|
||||||
|
subscription: null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { isSubscribed, subscription } = await getSubscriptionDetails(request);
|
||||||
|
|
||||||
|
let accessToken = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("🔑 Fetching Turn14 access token from metafield...");
|
||||||
|
accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||||||
|
console.log("✅ Turn14 access token received:", accessToken ? "YES" : "EMPTY");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Error getting Turn14 access token:", err);
|
||||||
|
return json({
|
||||||
|
brands: [],
|
||||||
|
collections: [],
|
||||||
|
selectedBrandsFromShopify: [],
|
||||||
|
shop,
|
||||||
|
error: "Failed to fetch Turn14 access token",
|
||||||
|
isSubscribed,
|
||||||
|
subscription,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let brandJson;
|
||||||
|
try {
|
||||||
|
const brandRes = await fetch("https://turn14.data4autos.com/v1/brands", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
brandJson = await brandRes.json();
|
||||||
|
|
||||||
return json({ brands: brandJson.data, collections, selectedBrandsFromShopify: brands || [], shop });
|
if (!brandRes.ok) {
|
||||||
|
return json({
|
||||||
|
brands: [],
|
||||||
|
collections: [],
|
||||||
|
selectedBrandsFromShopify: [],
|
||||||
|
shop,
|
||||||
|
error: brandJson?.error || "Failed to fetch brands",
|
||||||
|
isSubscribed,
|
||||||
|
subscription,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Exception while fetching Turn14 brands:", err);
|
||||||
|
return json({
|
||||||
|
brands: [],
|
||||||
|
collections: [],
|
||||||
|
selectedBrandsFromShopify: [],
|
||||||
|
shop,
|
||||||
|
error: "Turn14 brands fetch crashed",
|
||||||
|
isSubscribed,
|
||||||
|
subscription,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let collections = [];
|
||||||
|
try {
|
||||||
|
const gqlRaw = await admin.graphql(`
|
||||||
|
{
|
||||||
|
collections(first: 100) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const gql = await gqlRaw.json();
|
||||||
|
collections = gql?.data?.collections?.edges?.map((e) => e.node) || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Error fetching Shopify collections:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedBrands = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await admin.graphql(`
|
||||||
|
{
|
||||||
|
shop {
|
||||||
|
metafield(namespace: "turn14", key: "selected_brands") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const rawValue = data?.data?.shop?.metafield?.value;
|
||||||
|
|
||||||
|
if (rawValue) {
|
||||||
|
selectedBrands = JSON.parse(rawValue);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Failed parsing selected_brands metafield:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
brands: brandJson?.data || [],
|
||||||
|
collections,
|
||||||
|
selectedBrandsFromShopify: selectedBrands,
|
||||||
|
shop,
|
||||||
|
isSubscribed,
|
||||||
|
subscription,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const action = async ({ request }) => {
|
export const action = async ({ request }) => {
|
||||||
|
const { isSubscribed } = await getSubscriptionDetails(request);
|
||||||
|
|
||||||
|
if (!isSubscribed) {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"An active subscription or free trial is required to save brand collections.",
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const selectedBrands = JSON.parse(formData.get("selectedBrands") || "[]");
|
const selectedBrands = JSON.parse(formData.get("selectedBrands") || "[]");
|
||||||
const selectedOldBrands = JSON.parse(formData.get("selectedOldBrands") || "[]");
|
const selectedOldBrands = JSON.parse(formData.get("selectedOldBrands") || "[]");
|
||||||
|
|
||||||
const { session } = await authenticate.admin(request);
|
const { session } = await authenticate.admin(request);
|
||||||
const shop = session.shop; // "veloxautomotive.myshopify.com"
|
const shop = session.shop;
|
||||||
|
|
||||||
selectedBrands.forEach(brand => {
|
selectedBrands.forEach((brand) => {
|
||||||
delete brand.pricegroups;
|
delete brand.pricegroups;
|
||||||
});
|
});
|
||||||
|
|
||||||
selectedOldBrands.forEach(brand => {
|
selectedOldBrands.forEach((brand) => {
|
||||||
delete brand.pricegroups;
|
delete brand.pricegroups;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const resp = await fetch("https://backend.data4autos.com/managebrands", {
|
const resp = await fetch("https://backend.data4autos.com/managebrands", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@ -150,68 +318,45 @@ export const action = async ({ request }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function BrandsPage() {
|
export default function BrandsPage() {
|
||||||
const { brands, collections, selectedBrandsFromShopify, shop ,err} = useLoaderData();
|
const navigate = useNavigate();
|
||||||
console.log(err)
|
const {
|
||||||
// console.log(`selectedBrandsFromShopify: ${JSON.stringify(selectedBrandsFromShopify)}`);
|
brands = [],
|
||||||
|
selectedBrandsFromShopify = [],
|
||||||
|
shop = "",
|
||||||
|
error,
|
||||||
|
isSubscribed = false,
|
||||||
|
subscription = null,
|
||||||
|
} = useLoaderData() || {};
|
||||||
|
|
||||||
const actionData = useActionData() || {};
|
const actionData = useActionData() || {};
|
||||||
|
|
||||||
|
const [selectedIdsold, setSelectedIdsold] = useState([]);
|
||||||
|
const [selectedIds, setSelectedIds] = useState(() =>
|
||||||
|
(selectedBrandsFromShopify ?? []).map((b) => b.id)
|
||||||
|
);
|
||||||
const [selectedIdsold, setSelectedIdsold] = useState([])
|
|
||||||
// const [selectedIds, setSelectedIds] = useState(() => {
|
|
||||||
// const titles = new Set(collections.map(c => c.title.toLowerCase()));
|
|
||||||
// return brands
|
|
||||||
// .filter(b => titles.has(b.name.toLowerCase()))
|
|
||||||
// .map(b => b.id);
|
|
||||||
// });
|
|
||||||
|
|
||||||
const [selectedIds, setSelectedIds] = useState(() => {
|
|
||||||
return selectedBrandsFromShopify.map(b => b.id);
|
|
||||||
});
|
|
||||||
// console.log("Selected IDS : ", selectedIds)
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [filteredBrands, setFilteredBrands] = useState(brands);
|
const [filteredBrands, setFilteredBrands] = useState(brands);
|
||||||
const [toastActive, setToastActive] = useState(false);
|
const [toastActive, setToastActive] = useState(false);
|
||||||
const [polling, setPolling] = useState(false);
|
const [polling, setPolling] = useState(false);
|
||||||
const [status, setStatus] = useState(actionData.status || "");
|
const [status, setStatus] = useState(actionData.status || "");
|
||||||
|
const [Turn14Enabled, setTurn14Enabled] = useState(null);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [Turn14Enabled, setTurn14Enabled] = useState(null); // null | true | false
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shop) {
|
if (!shop) return;
|
||||||
console.log("⚠️ shop is undefined or empty");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const result = await checkShopExists(shop);
|
const result = await checkShopExists(shop);
|
||||||
console.log("✅ API status result:", result, "| shop:", shop);
|
|
||||||
setTurn14Enabled(result);
|
setTurn14Enabled(result);
|
||||||
})();
|
})();
|
||||||
}, [shop]);
|
}, [shop]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selids = selectedIds
|
setSelectedIdsold(selectedIds);
|
||||||
// console.log("Selected IDS : ", selids)
|
|
||||||
setSelectedIdsold(selids)
|
|
||||||
}, [toastActive]);
|
}, [toastActive]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const term = search.toLowerCase();
|
const term = search.toLowerCase();
|
||||||
setFilteredBrands(brands.filter(b => b.name.toLowerCase().includes(term)));
|
setFilteredBrands(brands.filter((b) => b.name.toLowerCase().includes(term)));
|
||||||
}, [search, brands]);
|
}, [search, brands]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -235,32 +380,33 @@ export default function BrandsPage() {
|
|||||||
setPolling(false);
|
setPolling(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSelect = id =>
|
const toggleSelect = (id) => {
|
||||||
setSelectedIds(prev =>
|
if (!isSubscribed) return;
|
||||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
|
||||||
|
setSelectedIds((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const allFilteredSelected =
|
const allFilteredSelected =
|
||||||
filteredBrands.length > 0 &&
|
filteredBrands.length > 0 &&
|
||||||
filteredBrands.every(b => selectedIds.includes(b.id));
|
filteredBrands.every((b) => selectedIds.includes(b.id));
|
||||||
|
|
||||||
const toggleSelectAll = () => {
|
const toggleSelectAll = () => {
|
||||||
const ids = filteredBrands.map(b => b.id);
|
if (!isSubscribed) return;
|
||||||
|
|
||||||
|
const ids = filteredBrands.map((b) => b.id);
|
||||||
if (allFilteredSelected) {
|
if (allFilteredSelected) {
|
||||||
setSelectedIds(prev => prev.filter(id => !ids.includes(id)));
|
setSelectedIds((prev) => prev.filter((id) => !ids.includes(id)));
|
||||||
} else {
|
} else {
|
||||||
setSelectedIds(prev => Array.from(new Set([...prev, ...ids])));
|
setSelectedIds((prev) => Array.from(new Set([...prev, ...ids])));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var isSubmitting;
|
let isSubmitting = false;
|
||||||
// console.log("actionData", actionData);
|
|
||||||
if (actionData.status) {
|
if (actionData.status) {
|
||||||
isSubmitting = !actionData.status && !actionData.error && !actionData.processId;
|
isSubmitting = !actionData.status && !actionData.error && !actionData.processId;
|
||||||
} else {
|
|
||||||
isSubmitting = false;
|
|
||||||
}
|
}
|
||||||
// console.log("isSubmitting", isSubmitting);
|
|
||||||
|
|
||||||
const toastMarkup = toastActive ? (
|
const toastMarkup = toastActive ? (
|
||||||
<Toast
|
<Toast
|
||||||
@ -269,21 +415,49 @@ export default function BrandsPage() {
|
|||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const selectedBrands = brands.filter(b => selectedIds.includes(b.id));
|
const selectedBrands = brands.filter((b) => selectedIds.includes(b.id));
|
||||||
const selectedOldBrands = brands.filter(b => selectedIdsold.includes(b.id));
|
const selectedOldBrands = brands.filter((b) => selectedIdsold.includes(b.id));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const shopDomain = (shop || "").split(".")[0];
|
const shopDomain = (shop || "").split(".")[0];
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ icon: "⚙️", text: "Manage API settings", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/settings` },
|
{
|
||||||
{ icon: "🏷️", text: "Browse and import available brands", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/brands` },
|
icon: "⚙️",
|
||||||
{ icon: "📦", text: "Sync brand collections to Shopify", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/managebrand` },
|
text: "Manage API settings",
|
||||||
{ icon: "🔐", text: "Handle secure Turn14 login credentials", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/help` },
|
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/settings`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "🏷️",
|
||||||
|
text: "Browse and import available brands",
|
||||||
|
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/brands`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "📦",
|
||||||
|
text: "Sync brand collections to Shopify",
|
||||||
|
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/managebrand`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "🔐",
|
||||||
|
text: "Handle secure Turn14 login credentials",
|
||||||
|
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/help`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// If Turn14 is explicitly NOT connected, show a lightweight call-to-action screen
|
const trialDaysLeft = useMemo(() => {
|
||||||
|
if (!subscription?.trialDays || !subscription?.createdAt) return null;
|
||||||
|
if (subscription.status !== "TRIAL") return null;
|
||||||
|
|
||||||
|
const created = new Date(subscription.createdAt);
|
||||||
|
const trialEnd = new Date(created);
|
||||||
|
trialEnd.setDate(trialEnd.getDate() + subscription.trialDays);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const msLeft = trialEnd.getTime() - now.getTime();
|
||||||
|
const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
return Math.max(0, daysLeft);
|
||||||
|
}, [subscription]);
|
||||||
|
|
||||||
if (Turn14Enabled === false) {
|
if (Turn14Enabled === false) {
|
||||||
return (
|
return (
|
||||||
<Frame>
|
<Frame>
|
||||||
@ -302,7 +476,6 @@ export default function BrandsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary actions */}
|
|
||||||
<div style={{ marginTop: 24, display: "flex", gap: 16, justifyContent: "center", flexWrap: "wrap" }}>
|
<div style={{ marginTop: 24, display: "flex", gap: 16, justifyContent: "center", flexWrap: "wrap" }}>
|
||||||
<a href={items[0].link} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
|
<a href={items[0].link} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
|
||||||
<Text as="h6" variant="headingMd" fontWeight="bold">
|
<Text as="h6" variant="headingMd" fontWeight="bold">
|
||||||
@ -322,7 +495,6 @@ export default function BrandsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Secondary links */}
|
|
||||||
<div style={{ marginTop: 20, display: "flex", gap: 16, justifyContent: "center", flexWrap: "wrap" }}>
|
<div style={{ marginTop: 20, display: "flex", gap: 16, justifyContent: "center", flexWrap: "wrap" }}>
|
||||||
<a href={items[1].link} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
|
<a href={items[1].link} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none" }}>
|
||||||
<Text as="p" variant="bodyMd">
|
<Text as="p" variant="bodyMd">
|
||||||
@ -344,85 +516,161 @@ export default function BrandsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// console.log("Selected Brands:", selectedBrands)
|
|
||||||
return (
|
return (
|
||||||
<Frame>
|
<Frame>
|
||||||
<Page fullWidth>
|
<Page fullWidth>
|
||||||
<TitleBar title="Data4Autos Turn14 Integration" background="primary" />
|
<TitleBar title="Data4Autos Turn14 Integration" background="primary" />
|
||||||
<div style={{ display: "flex", justifyContent: "center", textAlign: "center", padding: "20px 0px", position: "fixed", zIndex: 2, background: "rgb(241 241 241)", width: "100%", top: 0, }}>
|
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Text as="h1" variant="headingLg">
|
<Text as="h1" variant="headingLg">
|
||||||
Data4Autos Turn14 Brands List
|
Data4Autos Turn14 Brands List
|
||||||
</Text>
|
</Text>
|
||||||
<br />
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/* <div>
|
|
||||||
<p>
|
|
||||||
<strong>Turn 14 Status:</strong>{" "}
|
|
||||||
{Turn14Enabled === true
|
|
||||||
? "✅ Turn14 x Shopify Connected!"
|
|
||||||
: Turn14Enabled === false
|
|
||||||
? "❌ Turn14 x Shopify Connection Doesn't Exists"
|
|
||||||
: "Checking..."}
|
|
||||||
</p>
|
|
||||||
</div> */}
|
|
||||||
<Layout >
|
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
{!isSubscribed && (
|
||||||
|
<Layout.Section>
|
||||||
|
<Banner title="Subscription required" tone="warning">
|
||||||
|
<p>
|
||||||
|
This feature is available only for merchants with an active
|
||||||
|
subscription or during the free trial period.
|
||||||
|
</p>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<p>
|
||||||
|
<strong>Current status:</strong>{" "}
|
||||||
|
{subscription?.status || "Not subscribed"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Plan:</strong> {subscription?.name || PLAN_NAME}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Billing:</strong> {getIntervalLabel(subscription?.interval)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Price:</strong>{" "}
|
||||||
|
{formatMoney(
|
||||||
|
subscription?.priceAmount,
|
||||||
|
subscription?.currencyCode
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Next renewal / period end:</strong>{" "}
|
||||||
|
{formatDate(subscription?.currentPeriodEnd)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Trial days left:</strong>{" "}
|
||||||
|
{trialDaysLeft != null ? `${trialDaysLeft} day(s)` : "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<InlineStack gap="300">
|
||||||
|
<Button variant="primary" onClick={() => navigate("/app")}>
|
||||||
|
Go to Home Page
|
||||||
|
</Button>
|
||||||
|
</InlineStack>
|
||||||
|
</div>
|
||||||
|
</Banner>
|
||||||
|
</Layout.Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Layout.Section>
|
||||||
|
<Banner title="Error" tone="critical">
|
||||||
|
<p>{error}</p>
|
||||||
|
</Banner>
|
||||||
|
</Layout.Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<Layout.Section>
|
||||||
|
<Banner title="Action blocked" tone="critical">
|
||||||
|
<p>{actionData.error}</p>
|
||||||
|
</Banner>
|
||||||
|
</Layout.Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Layout.Section>
|
<Layout.Section>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16, flexWrap: "wrap", position: "fixed", zIndex: 2, background: "white", padding: "20px", width: "97%", marginTop: "40px", backgroundColor: "#00d1ff" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
background: "#ffffff",
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: "0 1px 6px rgba(0,0,0,0.08)",
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 16,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", gap: 16, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
{(actionData?.processId || false) && (
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<strong>Process ID:</strong> {actionData.processId}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Status:</strong> {status || "—"}
|
||||||
|
</p>
|
||||||
|
<Button onClick={checkStatus} loading={polling}>
|
||||||
|
Check Status
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Left side - Search + Select All */}
|
<div style={{ minWidth: 260 }}>
|
||||||
<div style={{ display: "flex", gap: 16, alignItems: "center" }}>
|
<TextField
|
||||||
{(actionData?.processId || false) && (
|
labelHidden
|
||||||
<div>
|
label="Search brands"
|
||||||
<p>
|
value={search}
|
||||||
<strong>Process ID:</strong> {actionData.processId}
|
onChange={setSearch}
|
||||||
</p>
|
placeholder="Type brand name…"
|
||||||
<p>
|
autoComplete="off"
|
||||||
<strong>Status:</strong> {status || "—"}
|
disabled={!isSubscribed}
|
||||||
</p>
|
/>
|
||||||
<Button onClick={checkStatus} loading={polling}>
|
|
||||||
Check Status
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<TextField
|
<Checkbox
|
||||||
labelHidden
|
label="Select All"
|
||||||
label="Search brands"
|
checked={allFilteredSelected}
|
||||||
value={search}
|
onChange={toggleSelectAll}
|
||||||
onChange={setSearch}
|
disabled={!isSubscribed}
|
||||||
placeholder="Type brand name…"
|
/>
|
||||||
autoComplete="off"
|
</div>
|
||||||
/>
|
|
||||||
<Checkbox
|
<Form
|
||||||
label="Select All"
|
method="post"
|
||||||
checked={allFilteredSelected}
|
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||||
onChange={toggleSelectAll}
|
>
|
||||||
/>
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="selectedBrands"
|
||||||
|
value={JSON.stringify(selectedBrands)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="selectedOldBrands"
|
||||||
|
value={JSON.stringify(selectedOldBrands)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
submit
|
||||||
|
disabled={isSubmitting || !isSubscribed}
|
||||||
|
size="large"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Spinner size="small" /> : "Save Collections"}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
{/* Right side - Save Button */}
|
|
||||||
<Form method="post" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="selectedBrands"
|
|
||||||
value={JSON.stringify(selectedBrands)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="selectedOldBrands"
|
|
||||||
value={JSON.stringify(selectedOldBrands)}
|
|
||||||
/>
|
|
||||||
{/* <Button primary submit disabled={selectedIds.length === 0 || isSubmitting}> */}
|
|
||||||
<Button primary submit disabled={isSubmitting} size="large" variant="primary">
|
|
||||||
{isSubmitting ? <Spinner size="small" /> : "Save Collections"}
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
</div>
|
||||||
</Layout.Section>
|
</Layout.Section>
|
||||||
|
|
||||||
@ -432,22 +680,20 @@ export default function BrandsPage() {
|
|||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||||
gap: 16,
|
gap: 16,
|
||||||
marginTop: "120px"
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{filteredBrands.map((brand) => (
|
{filteredBrands.map((brand) => (
|
||||||
<Card key={brand.id} sectioned>
|
<Card key={brand.id} sectioned>
|
||||||
<div style={{ position: "relative", textAlign: "center" }}>
|
<div style={{ position: "relative", textAlign: "center" }}>
|
||||||
{/* Checkbox in top-right corner */}
|
|
||||||
<div style={{ position: "absolute", top: 0, right: 0 }}>
|
<div style={{ position: "absolute", top: 0, right: 0 }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label=""
|
label=""
|
||||||
checked={selectedIds.includes(brand.id)}
|
checked={selectedIds.includes(brand.id)}
|
||||||
onChange={() => toggleSelect(brand.id)}
|
onChange={() => toggleSelect(brand.id)}
|
||||||
|
disabled={!isSubscribed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Brand image */}
|
|
||||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
source={
|
source={
|
||||||
@ -458,8 +704,15 @@ export default function BrandsPage() {
|
|||||||
size="large"
|
size="large"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Brand name */}
|
|
||||||
<div style={{ marginTop: "15px", fontWeight: "600", fontSize: "16px", lineHeight: "26px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "15px",
|
||||||
|
fontWeight: "600",
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "26px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{brand.name}
|
{brand.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1000
app/routes/app.managebrand copy 3.jsx
Normal file
1000
app/routes/app.managebrand copy 3.jsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -28,8 +28,9 @@ const SCOPES = [
|
|||||||
"write_products",
|
"write_products",
|
||||||
"read_publications",
|
"read_publications",
|
||||||
"write_publications",
|
"write_publications",
|
||||||
"read_fulfillments",
|
"stagedUploadsCreate",
|
||||||
"write_fulfillments","read_locations","write_locations"
|
"read_fulfillments",
|
||||||
|
"write_files,read_files,write_fulfillments", "read_locations", "write_locations"
|
||||||
].join(",");
|
].join(",");
|
||||||
const REDIRECT_URI = "https://backend.data4autos.com/auth/callback";
|
const REDIRECT_URI = "https://backend.data4autos.com/auth/callback";
|
||||||
const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa";
|
const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa";
|
||||||
@ -54,7 +55,7 @@ export const loader = async ({ request }) => {
|
|||||||
if (data.shop.metafield?.value) {
|
if (data.shop.metafield?.value) {
|
||||||
try { creds = JSON.parse(data.shop.metafield.value); } catch { }
|
try { creds = JSON.parse(data.shop.metafield.value); } catch { }
|
||||||
}
|
}
|
||||||
creds = {};
|
// creds = {};
|
||||||
let savedPricing = { priceType: "map", percentage: 0 };
|
let savedPricing = { priceType: "map", percentage: 0 };
|
||||||
if (data.shop.pricing?.value) {
|
if (data.shop.pricing?.value) {
|
||||||
try {
|
try {
|
||||||
@ -118,11 +119,11 @@ export const action = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// default / legacy: connect Turn14 flow
|
// default / legacy: connect Turn14 flow
|
||||||
// const clientId = formData.get("client_id");
|
const clientId = formData.get("client_id");
|
||||||
// const clientSecret = formData.get("client_secret");
|
const clientSecret = formData.get("client_secret");
|
||||||
|
|
||||||
const clientId = formData.get("demo_client_id");
|
// const clientId = formData.get("demo_client_id");
|
||||||
const clientSecret = formData.get("demo_client_secret");
|
// const clientSecret = formData.get("demo_client_secret");
|
||||||
|
|
||||||
let tokenData;
|
let tokenData;
|
||||||
try {
|
try {
|
||||||
@ -236,18 +237,19 @@ export default function StoreCredentials() {
|
|||||||
{/* —— TURN14 FORM —— */}
|
{/* —— TURN14 FORM —— */}
|
||||||
<Form method="post">
|
<Form method="post">
|
||||||
<input type="hidden" name="intent" value="connect_turn14" />
|
<input type="hidden" name="intent" value="connect_turn14" />
|
||||||
<input type="hidden" name="demo_client_id" value="671f15b6973625885ee392122113d9ed54103ddd" />
|
{/* <input type="hidden" name="demo_client_id" value="671f15b6973625885ee392122113d9ed54103ddd" />
|
||||||
<input type="hidden" name="demo_client_secret" value="98907c3b3d92235c99338bd0ce307144b2f66874" />
|
<input type="hidden" name="demo_client_secret" value="98907c3b3d92235c99338bd0ce307144b2f66874" />
|
||||||
|
*/}
|
||||||
<BlockStack gap="400">
|
<BlockStack gap="400">
|
||||||
<BlockStack gap="200">
|
<BlockStack gap="200">
|
||||||
<TextField
|
<TextField
|
||||||
label="Turn14 Client ID"
|
label="Turn14 Client ID"
|
||||||
name="client_id"
|
name="client_id"
|
||||||
// value={clientId}
|
value={clientId}
|
||||||
value={"********************************************************"}
|
//value={"********************************************************"}
|
||||||
onChange={setClientId}
|
onChange={setClientId}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
// requiredIndicator
|
// requiredIndicator
|
||||||
padding="200"
|
padding="200"
|
||||||
/>
|
/>
|
||||||
</BlockStack>
|
</BlockStack>
|
||||||
@ -255,21 +257,21 @@ export default function StoreCredentials() {
|
|||||||
<TextField
|
<TextField
|
||||||
label="Turn14 Client Secret"
|
label="Turn14 Client Secret"
|
||||||
name="client_secret"
|
name="client_secret"
|
||||||
// value={clientSecret}
|
value={clientSecret}
|
||||||
value={"********************************************************"}
|
//value={"********************************************************"}
|
||||||
onChange={setClientSecret}
|
onChange={setClientSecret}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
// requiredIndicator
|
// requiredIndicator
|
||||||
padding="200"
|
padding="200"
|
||||||
/>
|
/>
|
||||||
</BlockStack>
|
</BlockStack>
|
||||||
<BlockStack gap="200">
|
{/* <BlockStack gap="200">
|
||||||
<Button submit primary size="large" variant="primary">
|
<Button submit primary size="large" variant="primary">
|
||||||
Connect Turn14 With Demo Credentials
|
Connect Turn14 With Demo Credentials
|
||||||
</Button>
|
</Button>
|
||||||
</BlockStack>
|
</BlockStack> */}
|
||||||
|
|
||||||
<BlockStack gap="200">
|
<BlockStack gap="200">
|
||||||
<Button submit primary size="large" variant="primary">
|
<Button submit primary size="large" variant="primary">
|
||||||
Connect Turn14
|
Connect Turn14
|
||||||
</Button>
|
</Button>
|
||||||
@ -321,7 +323,7 @@ export default function StoreCredentials() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{paddingTop:"15px", textAlign:"end"}}>
|
<div style={{ paddingTop: "15px", textAlign: "end" }}>
|
||||||
<Button submit primary variant="primary" size="large" >Save pricing</Button>
|
<Button submit primary variant="primary" size="large" >Save pricing</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@ -29,7 +29,7 @@ const SCOPES = [
|
|||||||
"read_publications",
|
"read_publications",
|
||||||
"write_publications",
|
"write_publications",
|
||||||
"read_fulfillments",
|
"read_fulfillments",
|
||||||
"write_fulfillments","read_locations","write_locations"
|
"write_files,read_files,write_fulfillments","read_locations","write_locations"
|
||||||
].join(",");
|
].join(",");
|
||||||
const REDIRECT_URI = "https://backend.data4autos.com/auth/callback";
|
const REDIRECT_URI = "https://backend.data4autos.com/auth/callback";
|
||||||
const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa";
|
const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa";
|
||||||
|
|||||||
@ -29,7 +29,7 @@ const SCOPES = [
|
|||||||
"read_publications",
|
"read_publications",
|
||||||
"write_publications",
|
"write_publications",
|
||||||
"read_fulfillments",
|
"read_fulfillments",
|
||||||
"write_fulfillments","read_locations","write_locations"
|
"write_files,read_files,write_fulfillments","read_locations","write_locations"
|
||||||
].join(",");
|
].join(",");
|
||||||
const REDIRECT_URI = "https://backend.data4autos.com/auth/callback";
|
const REDIRECT_URI = "https://backend.data4autos.com/auth/callback";
|
||||||
const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa";
|
const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa";
|
||||||
|
|||||||
350
app/routes/app.settings_with test creds.jsx
Normal file
350
app/routes/app.settings_with test creds.jsx
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
// app/routes/store-credentials.jsx
|
||||||
|
|
||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { useLoaderData, useActionData, Form } from "@remix-run/react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
TextContainer,
|
||||||
|
InlineError,
|
||||||
|
Text,
|
||||||
|
BlockStack,
|
||||||
|
Box,
|
||||||
|
Select,
|
||||||
|
Banner,
|
||||||
|
InlineStack,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
const SCOPES = [
|
||||||
|
"read_inventory",
|
||||||
|
"read_products",
|
||||||
|
"write_inventory",
|
||||||
|
"write_products",
|
||||||
|
"read_publications",
|
||||||
|
"write_publications",
|
||||||
|
"read_fulfillments",
|
||||||
|
"write_files,read_files,write_fulfillments","read_locations","write_locations"
|
||||||
|
].join(",");
|
||||||
|
const REDIRECT_URI = "https://backend.data4autos.com/auth/callback";
|
||||||
|
const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa";
|
||||||
|
|
||||||
|
// ===== LOADER =====
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
const resp = await admin.graphql(`
|
||||||
|
{
|
||||||
|
shop {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
myshopifyDomain
|
||||||
|
metafield(namespace: "turn14", key: "credentials") { value }
|
||||||
|
pricing: metafield(namespace: "turn14", key: "pricing_config") { value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const { data } = await resp.json();
|
||||||
|
|
||||||
|
let creds = {};
|
||||||
|
if (data.shop.metafield?.value) {
|
||||||
|
try { creds = JSON.parse(data.shop.metafield.value); } catch { }
|
||||||
|
}
|
||||||
|
creds = {};
|
||||||
|
let savedPricing = { priceType: "map", percentage: 0 };
|
||||||
|
if (data.shop.pricing?.value) {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(data.shop.pricing.value);
|
||||||
|
savedPricing.priceType = (p.priceType || "map").toLowerCase();
|
||||||
|
savedPricing.percentage = Number(p.percentage) || 0;
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
shopName: data.shop.name,
|
||||||
|
shopDomain: data.shop.myshopifyDomain,
|
||||||
|
shopId: data.shop.id,
|
||||||
|
savedCreds: creds,
|
||||||
|
savedPricing,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== ACTION =====
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const intent = formData.get("intent"); // "connect_turn14" | "save_pricing"
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
// we need shop id either way
|
||||||
|
const shopResp = await admin.graphql(`{ shop { id name myshopifyDomain } }`);
|
||||||
|
const shopJson = await shopResp.json();
|
||||||
|
const shopId = shopJson.data.shop.id;
|
||||||
|
const shopName = shopJson.data.shop.name;
|
||||||
|
const shopDomain = shopJson.data.shop.myshopifyDomain;
|
||||||
|
|
||||||
|
if (intent === "save_pricing") {
|
||||||
|
// --- save pricing_config metafield directly ---
|
||||||
|
const priceTypeRaw = (formData.get("price_type") || "map").toString().toLowerCase();
|
||||||
|
const percentageRaw = Number(formData.get("percentage") || 0);
|
||||||
|
|
||||||
|
const priceType = ["map", "percentage"].includes(priceTypeRaw) ? priceTypeRaw : "map";
|
||||||
|
const percentage = Number.isFinite(percentageRaw) ? percentageRaw : 0;
|
||||||
|
|
||||||
|
const cfg = { priceType, percentage };
|
||||||
|
const mutation = `
|
||||||
|
mutation {
|
||||||
|
metafieldsSet(metafields: [{
|
||||||
|
ownerId: "${shopId}",
|
||||||
|
namespace: "turn14",
|
||||||
|
key: "pricing_config",
|
||||||
|
type: "json",
|
||||||
|
value: "${JSON.stringify(cfg).replace(/"/g, '\\"')}"
|
||||||
|
}]) {
|
||||||
|
userErrors { message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const saveRes = await admin.graphql(mutation);
|
||||||
|
const saveJson = await saveRes.json();
|
||||||
|
const errs = saveJson.data.metafieldsSet.userErrors;
|
||||||
|
if (errs.length) {
|
||||||
|
return json({ success: false, pricingSaved: false, error: errs[0].message });
|
||||||
|
}
|
||||||
|
return json({ success: true, pricingSaved: true, savedPricing: cfg });
|
||||||
|
}
|
||||||
|
|
||||||
|
// default / legacy: connect Turn14 flow
|
||||||
|
// const clientId = formData.get("client_id");
|
||||||
|
// const clientSecret = formData.get("client_secret");
|
||||||
|
|
||||||
|
const clientId = formData.get("demo_client_id");
|
||||||
|
const clientSecret = formData.get("demo_client_secret");
|
||||||
|
|
||||||
|
let tokenData;
|
||||||
|
try {
|
||||||
|
const tokenRes = await fetch("https://turn14.data4autos.com/v1/auth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
tokenData = await tokenRes.json();
|
||||||
|
if (!tokenRes.ok) {
|
||||||
|
throw new Error(tokenData.error || "Failed to fetch Turn14 token");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const creds = {
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
accessToken: tokenData.access_token,
|
||||||
|
expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
const mutation = `
|
||||||
|
mutation {
|
||||||
|
metafieldsSet(metafields: [{
|
||||||
|
ownerId: "${shopId}",
|
||||||
|
namespace: "turn14",
|
||||||
|
key: "credentials",
|
||||||
|
type: "json",
|
||||||
|
value: "${JSON.stringify(creds).replace(/"/g, '\\"')}"
|
||||||
|
}]) {
|
||||||
|
userErrors { message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const saveRes = await admin.graphql(mutation);
|
||||||
|
const saveJson = await saveRes.json();
|
||||||
|
const errs = saveJson.data.metafieldsSet.userErrors;
|
||||||
|
if (errs.length) {
|
||||||
|
return json({ success: false, error: errs[0].message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateNonce = Math.random().toString(36).slice(2);
|
||||||
|
const installUrl =
|
||||||
|
`https://${shopDomain}/admin/oauth/authorize` +
|
||||||
|
`?client_id=${CLIENT_ID}` +
|
||||||
|
`&scope=${SCOPES}` +
|
||||||
|
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
|
||||||
|
`&state=${stateNonce}`;
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
confirmationUrl: installUrl,
|
||||||
|
creds,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== COMPONENT =====
|
||||||
|
export default function StoreCredentials() {
|
||||||
|
const { shopName, savedCreds, savedPricing, shopDomain } = useLoaderData();
|
||||||
|
const actionData = useActionData();
|
||||||
|
|
||||||
|
// open Shopify install after Connect Turn14
|
||||||
|
useEffect(() => {
|
||||||
|
if (actionData?.confirmationUrl) {
|
||||||
|
window.open(actionData.confirmationUrl, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
}, [actionData?.confirmationUrl]);
|
||||||
|
|
||||||
|
const [clientId, setClientId] = useState(actionData?.creds?.clientId || savedCreds.clientId || "");
|
||||||
|
const [clientSecret, setClientSecret] = useState(actionData?.creds?.clientSecret || savedCreds.clientSecret || "");
|
||||||
|
const connected = actionData?.success || Boolean(savedCreds.accessToken);
|
||||||
|
|
||||||
|
// Pricing UI state (seed from loader or last action)
|
||||||
|
const initialPriceType = useMemo(
|
||||||
|
() => (actionData?.savedPricing?.priceType || savedPricing?.priceType || "map"),
|
||||||
|
[actionData?.savedPricing?.priceType, savedPricing?.priceType]
|
||||||
|
);
|
||||||
|
const initialPercentage = useMemo(
|
||||||
|
() => Number(actionData?.savedPricing?.percentage ?? savedPricing?.percentage ?? 0),
|
||||||
|
[actionData?.savedPricing?.percentage, savedPricing?.percentage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [priceType, setPriceType] = useState(initialPriceType);
|
||||||
|
const [percentage, setPercentage] = useState(initialPercentage);
|
||||||
|
|
||||||
|
const pricingSavedOk = actionData?.pricingSaved && actionData?.success && !actionData?.error;
|
||||||
|
const pricingError = actionData?.pricingSaved === false ? actionData?.error : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Turn14 & Shopify Connect" />
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", textAlign: "center", paddingBottom: "20px" }}>
|
||||||
|
<Text as="h1" variant="headingLg">Data4Autos Turn14 Integration</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<Box maxWidth="520px" width="100%" marginInline="auto">
|
||||||
|
<Card sectioned padding="600">
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<TextContainer spacing="tight">
|
||||||
|
<Text variant="headingLg" align="center" fontWeight="medium">Shop: {shopName}</Text>
|
||||||
|
</TextContainer>
|
||||||
|
|
||||||
|
{/* —— TURN14 FORM —— */}
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="connect_turn14" />
|
||||||
|
<input type="hidden" name="demo_client_id" value="671f15b6973625885ee392122113d9ed54103ddd" />
|
||||||
|
<input type="hidden" name="demo_client_secret" value="98907c3b3d92235c99338bd0ce307144b2f66874" />
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<TextField
|
||||||
|
label="Turn14 Client ID"
|
||||||
|
name="client_id"
|
||||||
|
// value={clientId}
|
||||||
|
value={"********************************************************"}
|
||||||
|
onChange={setClientId}
|
||||||
|
autoComplete="off"
|
||||||
|
// requiredIndicator
|
||||||
|
padding="200"
|
||||||
|
/>
|
||||||
|
</BlockStack>
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<TextField
|
||||||
|
label="Turn14 Client Secret"
|
||||||
|
name="client_secret"
|
||||||
|
// value={clientSecret}
|
||||||
|
value={"********************************************************"}
|
||||||
|
onChange={setClientSecret}
|
||||||
|
autoComplete="off"
|
||||||
|
// requiredIndicator
|
||||||
|
padding="200"
|
||||||
|
/>
|
||||||
|
</BlockStack>
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Button submit primary size="large" variant="primary">
|
||||||
|
Connect Turn14 With Demo Credentials
|
||||||
|
</Button>
|
||||||
|
</BlockStack>
|
||||||
|
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Button submit primary size="large" variant="primary">
|
||||||
|
Connect Turn14
|
||||||
|
</Button>
|
||||||
|
</BlockStack>
|
||||||
|
</BlockStack>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{actionData?.error && !actionData?.pricingSaved && (
|
||||||
|
<TextContainer spacing="tight" style={{ marginTop: "1rem" }}>
|
||||||
|
<InlineError message={actionData.error} fieldID="client_id" />
|
||||||
|
</TextContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(actionData?.success || Boolean(savedCreds.accessToken)) && (
|
||||||
|
<TextContainer spacing="tight" style={{ marginTop: "1.5rem" }}>
|
||||||
|
<p style={{ color: "green", paddingTop: "5px" }}>✅ Turn14 connected successfully!</p>
|
||||||
|
</TextContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* —— PRICING CONFIG (direct save via this route) —— */}
|
||||||
|
{(actionData?.success || Boolean(savedCreds.accessToken)) && (
|
||||||
|
<Card title="Pricing configuration" sectioned>
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="save_pricing" />
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Price type"
|
||||||
|
options={[
|
||||||
|
{ label: "MAP (no change)", value: "map" },
|
||||||
|
{ label: "MAP + % profit", value: "percentage" },
|
||||||
|
]}
|
||||||
|
value={priceType}
|
||||||
|
onChange={(val) => setPriceType(val)}
|
||||||
|
name="price_type"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{priceType === "percentage" && (
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Percentage"
|
||||||
|
helpText="Add this percentage on top of MAP."
|
||||||
|
value={String(percentage)}
|
||||||
|
onChange={(val) => setPercentage(val)}
|
||||||
|
autoComplete="off"
|
||||||
|
suffix="%"
|
||||||
|
min={0}
|
||||||
|
name="percentage"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{paddingTop:"15px", textAlign:"end"}}>
|
||||||
|
<Button submit primary variant="primary" size="large" >Save pricing</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{pricingSavedOk && (
|
||||||
|
<Banner tone="success">
|
||||||
|
<p>Pricing configuration saved.</p>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
{pricingError && (
|
||||||
|
<Banner tone="critical">
|
||||||
|
<p>{pricingError}</p>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -65,7 +65,7 @@ export const action = async ({ request }) => {
|
|||||||
const shop = request.headers.get("shop-domain") || "";
|
const shop = request.headers.get("shop-domain") || "";
|
||||||
|
|
||||||
// make the POST to your backend
|
// make the POST to your backend
|
||||||
const resp = await fetch("https://backend.dine360.ca/managebrands", {
|
const resp = await fetch("https://backend.data4autos.com/managebrands", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@ -109,7 +109,7 @@ export default function BrandsPage() {
|
|||||||
setPolling(true);
|
setPolling(true);
|
||||||
|
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`https://backend.dine360.ca/managebrands/status/${processId}`,
|
`https://backend.data4autos.com/managebrands/status/${processId}`,
|
||||||
{
|
{
|
||||||
headers: { "shop-domain": window.shopify.shop || "" },
|
headers: { "shop-domain": window.shopify.shop || "" },
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,7 +67,7 @@ export const action = async ({ request }) => {
|
|||||||
const shop = session.shop; // "veloxautomotive.myshopify.com"
|
const shop = session.shop; // "veloxautomotive.myshopify.com"
|
||||||
|
|
||||||
// make the POST to your backend
|
// make the POST to your backend
|
||||||
const resp = await fetch("https://backend.dine360.ca/managebrands", {
|
const resp = await fetch("https://backend.data4autos.com/managebrands", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@ -212,7 +212,7 @@ export default function BrandsPage() {
|
|||||||
setPolling(true);
|
setPolling(true);
|
||||||
|
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`https://backend.dine360.ca/managebrands/status/${processId}`,
|
`https://backend.data4autos.com/managebrands/status/${processId}`,
|
||||||
{
|
{
|
||||||
headers: { "shop-domain": window.shopify.shop || "" },
|
headers: { "shop-domain": window.shopify.shop || "" },
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ const SCOPES = [
|
|||||||
"read_publications",
|
"read_publications",
|
||||||
"write_publications",
|
"write_publications",
|
||||||
].join(",");
|
].join(",");
|
||||||
const REDIRECT_URI = "https://backend.dine360.ca/auth/callback";
|
const REDIRECT_URI = "https://backend.data4autos.com/auth/callback";
|
||||||
const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa";
|
const CLIENT_ID = "b7534c980967bad619cfdb9d3f837cfa";
|
||||||
|
|
||||||
export const loader = async ({ request }) => {
|
export const loader = async ({ request }) => {
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "turn14-test",
|
"name": "Data4Autos",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "turn14-test",
|
"name": "Data4Autos",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"extensions/*"
|
"extensions/*"
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "turn14-test",
|
"name": "Data4Autos",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "remix vite:build",
|
"build": "remix vite:build",
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
client_id = "b7534c980967bad619cfdb9d3f837cfa"
|
client_id = "b7534c980967bad619cfdb9d3f837cfa"
|
||||||
name = "turn14-test"
|
name = "Data4Autos"
|
||||||
handle = "d4a-turn14"
|
handle = "d4a-turn14"
|
||||||
application_url = "https://shop.data4autos.com" # Update this line
|
application_url = "https://shop.data4autos.com" # Update this line
|
||||||
embedded = true
|
embedded = true
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
automatically_update_urls_on_dev = true
|
automatically_update_urls_on_dev = true
|
||||||
include_config_on_deploy = true
|
|
||||||
|
|
||||||
[webhooks]
|
[webhooks]
|
||||||
api_version = "2025-04"
|
api_version = "2025-04"
|
||||||
@ -20,7 +19,7 @@ api_version = "2025-04"
|
|||||||
uri = "/webhooks/app/uninstalled"
|
uri = "/webhooks/app/uninstalled"
|
||||||
|
|
||||||
[access_scopes]
|
[access_scopes]
|
||||||
scopes = "read_inventory,read_products,write_inventory,write_products,read_publications,write_publications,read_fulfillments,write_fulfillments"
|
scopes = "read_inventory,read_products,write_inventory,write_products,read_publications,write_publications,read_fulfillments,write_files,read_files,write_fulfillments"
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
redirect_urls = ["https://backend.dine360.ca/auth/callback"] # Update this line as well
|
redirect_urls = ["https://backend.data4autos.com/auth/callback"] # Update this line as well
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user