634 lines
20 KiB
JavaScript
634 lines
20 KiB
JavaScript
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,
|
||
Badge,
|
||
} from "@shopify/polaris";
|
||
import { TitleBar } from "@shopify/app-bridge-react";
|
||
import data4autosLogo from "../assets/data4autos_logo.png";
|
||
import turn14DistributorLogo from "../assets/turn14-logo.png";
|
||
import { authenticate } from "../shopify.server";
|
||
|
||
/* ===========================
|
||
PRICING (single source of truth)
|
||
=========================== */
|
||
const PLAN_NAME = "Starter Sync";
|
||
const MONTHLY_AMOUNT = 79; // USD
|
||
const ANNUAL_AMOUNT = 790; // USD
|
||
const TRIAL_DAYS = 14;
|
||
const ALLOWED_STATUSES = ["ACTIVE", "TRIAL"];
|
||
|
||
/* ===========================
|
||
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 }) => {
|
||
const { admin, session } = await authenticate.admin(request);
|
||
const shop = session.shop;
|
||
|
||
const resp = await admin.graphql(`
|
||
query CurrentSubscriptionDetails {
|
||
currentAppInstallation {
|
||
activeSubscriptions {
|
||
id
|
||
name
|
||
status
|
||
test
|
||
createdAt
|
||
trialDays
|
||
currentPeriodEnd
|
||
lineItems {
|
||
id
|
||
plan {
|
||
pricingDetails {
|
||
__typename
|
||
... on AppRecurringPricing {
|
||
interval
|
||
price {
|
||
amount
|
||
currencyCode
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
`);
|
||
|
||
const result = await resp.json();
|
||
|
||
const subscriptions =
|
||
result?.data?.currentAppInstallation?.activeSubscriptions || [];
|
||
|
||
const subscription =
|
||
subscriptions.find((sub) => ALLOWED_STATUSES.includes(sub.status)) ||
|
||
subscriptions[0] ||
|
||
null;
|
||
|
||
const recurringPricing =
|
||
subscription?.lineItems?.find(
|
||
(item) =>
|
||
item?.plan?.pricingDetails?.__typename === "AppRecurringPricing"
|
||
)?.plan?.pricingDetails || null;
|
||
|
||
const isSubscribed =
|
||
!!subscription && ALLOWED_STATUSES.includes(subscription.status);
|
||
|
||
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,
|
||
});
|
||
}
|
||
|
||
return json({
|
||
redirectToBilling: !isSubscribed,
|
||
shop,
|
||
isSubscribed,
|
||
subscription: subscriptionDetails,
|
||
allSubscriptions: subscriptions,
|
||
});
|
||
};
|
||
|
||
/* ===========================
|
||
ACTION: create subscription
|
||
=========================== */
|
||
export const action = async ({ request }) => {
|
||
const { admin, session } = 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 shop = session.shop;
|
||
const shopDomain = (shop || "").split(".")[0];
|
||
const returnUrl = `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app`;
|
||
|
||
const createRes = await admin.graphql(`
|
||
mutation CreateSubscription {
|
||
appSubscriptionCreate(
|
||
name: "${PLAN_NAME} - ${cadence === "ANNUAL" ? "Annual" : "Monthly"}"
|
||
returnUrl: "${returnUrl}"
|
||
lineItems: [
|
||
{
|
||
plan: {
|
||
appRecurringPricingDetails: {
|
||
price: { amount: ${amount}, currencyCode: USD }
|
||
interval: ${interval}
|
||
}
|
||
}
|
||
}
|
||
]
|
||
trialDays: ${TRIAL_DAYS}
|
||
replacementBehavior: STANDARD
|
||
test: false
|
||
) {
|
||
confirmationUrl
|
||
appSubscription {
|
||
id
|
||
name
|
||
status
|
||
trialDays
|
||
}
|
||
userErrors {
|
||
field
|
||
message
|
||
}
|
||
}
|
||
}
|
||
`);
|
||
|
||
const data = await createRes.json();
|
||
|
||
const url = data?.data?.appSubscriptionCreate?.confirmationUrl || null;
|
||
const userErrors = data?.data?.appSubscriptionCreate?.userErrors || [];
|
||
const topLevelErrors = data?.errors || [];
|
||
|
||
if (!url || userErrors.length || topLevelErrors.length) {
|
||
return json(
|
||
{
|
||
errors: [
|
||
"Failed to create subscription.",
|
||
...userErrors.map((e) => e.message),
|
||
...topLevelErrors.map((e) => e.message || String(e)),
|
||
],
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
return json({ confirmationUrl: url });
|
||
};
|
||
|
||
/* ===========================
|
||
PAGE
|
||
=========================== */
|
||
export default function Index() {
|
||
const actionData = useActionData();
|
||
const loaderData = useLoaderData();
|
||
const submit = useSubmit();
|
||
const [activeModal, setActiveModal] = useState(false);
|
||
const [cadence, setCadence] = useState("MONTHLY");
|
||
|
||
const subscription = loaderData?.subscription || null;
|
||
const shop = loaderData?.shop || "";
|
||
const isSubscribed = loaderData?.isSubscribed || false;
|
||
|
||
const hasConfirmationUrl = Boolean(actionData?.confirmationUrl);
|
||
const errors = 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);
|
||
|
||
const formatDate = (d) =>
|
||
d
|
||
? new Date(d).toLocaleDateString(undefined, {
|
||
year: "numeric",
|
||
month: "short",
|
||
day: "numeric",
|
||
})
|
||
: "N/A";
|
||
|
||
const previewBase = subscription?.createdAt ? new Date(subscription.createdAt) : new Date();
|
||
const previewTrialEnd = new Date(previewBase);
|
||
previewTrialEnd.setDate(previewTrialEnd.getDate() + TRIAL_DAYS);
|
||
|
||
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?.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 (
|
||
<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="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">
|
||
<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?.redirectToBilling
|
||
? "Proceed to Billing"
|
||
: "View Subscription Details"}
|
||
</Button>
|
||
</BlockStack>
|
||
</BlockStack>
|
||
</Card>
|
||
</Layout.Section>
|
||
</Layout>
|
||
|
||
<Modal
|
||
open={activeModal}
|
||
onClose={closeModal}
|
||
title="Subscription Details"
|
||
primaryAction={
|
||
hasConfirmationUrl
|
||
? undefined
|
||
: isSubscribed
|
||
? 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) {
|
||
const hidden = document.getElementById("cadence-field");
|
||
if (hidden) hidden.value = cadence;
|
||
submit(form, { method: "post" });
|
||
}
|
||
},
|
||
}
|
||
}
|
||
secondaryActions={[{ content: "Close", onAction: closeModal }]}
|
||
>
|
||
<Form id="billing-form" method="post">
|
||
<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 && (
|
||
<>
|
||
<TextField label="Subscription Status" value={displayStatus} readOnly />
|
||
<TextField label="Plan" value={displayPlan} readOnly />
|
||
<TextField label="Billing Interval" value={displayInterval} readOnly />
|
||
<TextField label="Price" value={displayPrice} readOnly />
|
||
<TextField
|
||
label="Trial"
|
||
value={
|
||
isSubscribed
|
||
? `${subscription?.trialDays || 0} day(s)`
|
||
: `${TRIAL_DAYS}-day free trial`
|
||
}
|
||
readOnly
|
||
/>
|
||
<TextField
|
||
label="Trial Days Left"
|
||
value={trialDaysLeft != null ? `${trialDaysLeft} days left` : "N/A"}
|
||
readOnly
|
||
/>
|
||
<TextField
|
||
label="Next Renewal / Period End"
|
||
value={displayNextRenewal}
|
||
readOnly
|
||
/>
|
||
|
||
{subscription?.createdAt && (
|
||
<TextField
|
||
label="Subscription Created"
|
||
value={formatDate(subscription.createdAt)}
|
||
readOnly
|
||
/>
|
||
)}
|
||
|
||
<Divider />
|
||
|
||
{!isSubscribed && (
|
||
<>
|
||
<Text as="h3" variant="headingMd">
|
||
Choose your billing plan
|
||
</Text>
|
||
|
||
<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.
|
||
</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>
|
||
);
|
||
} |