569 lines
19 KiB
JavaScript
569 lines
19 KiB
JavaScript
import React, { useMemo, useState } from "react";
|
||
import { json } from "@remix-run/node";
|
||
import { useLoaderData, useActionData, useSubmit } from "@remix-run/react";
|
||
import {
|
||
Page,
|
||
Layout,
|
||
Card,
|
||
BlockStack,
|
||
Text,
|
||
InlineStack,
|
||
Image,
|
||
Divider,
|
||
Button,
|
||
Modal,
|
||
Box,
|
||
Banner,
|
||
Badge,
|
||
Tooltip,
|
||
} from "@shopify/polaris";
|
||
import { TitleBar } from "@shopify/app-bridge-react";
|
||
import data4autosLogo from "../assets/data4autos_logo.png";
|
||
import turn14DistributorLogo from "../assets/turn14-logo.png";
|
||
import { authenticate } from "../shopify.server";
|
||
import { Form } from "@remix-run/react";
|
||
|
||
/* ===========================
|
||
PLAN CATALOG (edit freely)
|
||
=========================== */
|
||
const PLANS = [
|
||
{
|
||
id: "starter",
|
||
name: "Starter Sync",
|
||
badge: "New",
|
||
highlight: false,
|
||
features: [
|
||
"Guided Turn14 → Shopify import",
|
||
"Auto inventory & price updates",
|
||
"Editable title/description helpers",
|
||
"Email support",
|
||
],
|
||
periods: [
|
||
{ id: "monthly", label: "Monthly", interval: "EVERY_30_DAYS", amount: 79, currencyCode: "USD" },
|
||
{ id: "annual", label: "Annual", interval: "ANNUAL", amount: 790, currencyCode: "USD", sublabel: "2 months free" },
|
||
],
|
||
},
|
||
{
|
||
id: "growth",
|
||
name: "Growth",
|
||
badge: "Popular",
|
||
highlight: true,
|
||
features: [
|
||
"Everything in Starter",
|
||
"Bulk brand imports",
|
||
"Smart collection sync",
|
||
"Basic error diagnostics",
|
||
],
|
||
periods: [
|
||
{ id: "monthly", label: "Monthly", interval: "EVERY_30_DAYS", amount: 129, currencyCode: "USD" },
|
||
{ id: "annual", label: "Annual", interval: "ANNUAL", amount: 1290, currencyCode: "USD", sublabel: "2 months free" },
|
||
],
|
||
},
|
||
{
|
||
id: "pro",
|
||
name: "Pro",
|
||
badge: "For Teams",
|
||
highlight: false,
|
||
features: [
|
||
"Everything in Growth",
|
||
"Automated brand metadata",
|
||
"Advanced mapping rules",
|
||
"Priority email support",
|
||
],
|
||
periods: [
|
||
{ id: "monthly", label: "Monthly", interval: "EVERY_30_DAYS", amount: 199, currencyCode: "USD" },
|
||
{ id: "annual", label: "Annual", interval: "ANNUAL", amount: 1990, currencyCode: "USD", sublabel: "2 months free" },
|
||
],
|
||
},
|
||
{
|
||
id: "scale",
|
||
name: "Scale",
|
||
badge: "Best Value",
|
||
highlight: false,
|
||
features: [
|
||
"Everything in Pro",
|
||
"Unlimited brand sync",
|
||
"Enhanced audit logs",
|
||
"Slack alerts",
|
||
],
|
||
periods: [
|
||
{ id: "monthly", label: "Monthly", interval: "EVERY_30_DAYS", amount: 399, currencyCode: "USD" },
|
||
{ id: "annual", label: "Annual", interval: "ANNUAL", amount: 3990, currencyCode: "USD", sublabel: "2 months free" },
|
||
],
|
||
},
|
||
{
|
||
id: "enterprise",
|
||
name: "Enterprise",
|
||
badge: "Custom",
|
||
highlight: false,
|
||
features: [
|
||
"Everything in Scale",
|
||
"SLA & dedicated onboarding",
|
||
"Solution architect sessions",
|
||
"Custom integrations",
|
||
],
|
||
periods: [
|
||
{ id: "monthly", label: "Monthly", interval: "EVERY_30_DAYS", amount: 799, currencyCode: "USD" },
|
||
{ id: "annual", label: "Annual", interval: "ANNUAL", amount: 7990, currencyCode: "USD", sublabel: "2 months free" },
|
||
],
|
||
},
|
||
];
|
||
|
||
/* ===========================
|
||
LOADER: check subscription
|
||
=========================== */
|
||
export const loader = async ({ request }) => {
|
||
const { admin } = await authenticate.admin(request);
|
||
|
||
const resp = await admin.graphql(`
|
||
query {
|
||
currentAppInstallation {
|
||
activeSubscriptions {
|
||
id
|
||
status
|
||
trialDays
|
||
createdAt
|
||
currentPeriodEnd
|
||
}
|
||
}
|
||
}
|
||
`);
|
||
|
||
const result = await resp.json();
|
||
const subscription =
|
||
result?.data?.currentAppInstallation?.activeSubscriptions?.[0] || null;
|
||
|
||
const { session } = await authenticate.admin(request);
|
||
const shop = session.shop;
|
||
|
||
if (!subscription) {
|
||
return json({ redirectToBilling: true, subscription: null, shop, plans: PLANS });
|
||
}
|
||
|
||
if (subscription.status !== "ACTIVE" && subscription.status !== "TRIAL") {
|
||
return json({ redirectToBilling: true, subscription, shop, plans: PLANS });
|
||
}
|
||
|
||
return json({ redirectToBilling: false, subscription, shop, plans: PLANS });
|
||
};
|
||
|
||
/* ===========================
|
||
ACTION: create subscription
|
||
=========================== */
|
||
export const action = async ({ request }) => {
|
||
const { admin } = await authenticate.admin(request);
|
||
const formData = await request.formData();
|
||
|
||
const planId = String(formData.get("planId") || "");
|
||
const periodId = String(formData.get("periodId") || "");
|
||
const trialDays = Number(formData.get("trialDays") || 14); // change default if needed
|
||
|
||
const plan = PLANS.find((p) => p.id === planId);
|
||
const period = plan?.periods.find((pr) => pr.id === periodId);
|
||
|
||
if (!plan || !period) {
|
||
return json(
|
||
{ errors: ["Invalid plan or billing period selection. Please try again."] },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// Build a safe returnUrl from the incoming request origin
|
||
const urlObj = new URL(request.url);
|
||
const origin = `${urlObj.protocol}//${urlObj.host}`;
|
||
const returnUrl = `${origin}/app/after-billing`; // adjust if your route differs
|
||
|
||
const gql = `
|
||
mutation appSubscriptionCreate(
|
||
$name: String!,
|
||
$returnUrl: URL!,
|
||
$price: MoneyInput!,
|
||
$interval: AppBillingInterval!,
|
||
$trialDays: Int,
|
||
$test: Boolean
|
||
) {
|
||
appSubscriptionCreate(
|
||
name: $name
|
||
returnUrl: $returnUrl
|
||
lineItems: [
|
||
{
|
||
plan: {
|
||
appRecurringPricingDetails: {
|
||
price: $price
|
||
interval: $interval
|
||
}
|
||
}
|
||
}
|
||
]
|
||
trialDays: $trialDays
|
||
test: $test
|
||
) {
|
||
confirmationUrl
|
||
appSubscription { id status trialDays }
|
||
userErrors { field message }
|
||
}
|
||
}
|
||
`;
|
||
|
||
const variables = {
|
||
name: `${plan.name} (${period.label})`,
|
||
returnUrl,
|
||
price: { amount: period.amount, currencyCode: period.currencyCode },
|
||
interval: period.interval, // "EVERY_30_DAYS" | "ANNUAL"
|
||
trialDays,
|
||
test: true, // flip to false in production
|
||
};
|
||
|
||
const createRes = await admin.graphql(gql, { variables });
|
||
const data = await createRes.json();
|
||
|
||
const confirmationUrl = data?.data?.appSubscriptionCreate?.confirmationUrl;
|
||
const userErrors = data?.data?.appSubscriptionCreate?.userErrors || [];
|
||
const topLevelErrors = data?.errors || [];
|
||
|
||
if (!confirmationUrl || userErrors.length || topLevelErrors.length) {
|
||
return json(
|
||
{
|
||
errors: [
|
||
"Failed to create subscription.",
|
||
...userErrors.map((e) => e.message),
|
||
...topLevelErrors.map((e) => e.message || String(e)),
|
||
],
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
return json({ confirmationUrl });
|
||
};
|
||
|
||
/* ===========================
|
||
PAGE
|
||
=========================== */
|
||
export default function Index() {
|
||
const actionData = useActionData();
|
||
const loaderData = useLoaderData();
|
||
const submit = useSubmit();
|
||
const [activeModal, setActiveModal] = useState(false);
|
||
|
||
const subscription = loaderData?.subscription;
|
||
const shop = loaderData?.shop;
|
||
const plans = loaderData?.plans || PLANS;
|
||
|
||
const shopDomain = (shop || "").split(".")[0];
|
||
|
||
const items = [
|
||
{
|
||
icon: "⚙️",
|
||
text: "Manage API settings",
|
||
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/settings`,
|
||
},
|
||
{
|
||
icon: "🏷️",
|
||
text: "Browse and import available brands",
|
||
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/brands`,
|
||
},
|
||
{
|
||
icon: "📦",
|
||
text: "Sync brand collections to Shopify",
|
||
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/managebrand`,
|
||
},
|
||
{
|
||
icon: "🔐",
|
||
text: "Handle secure Turn14 login credentials",
|
||
link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/help`,
|
||
},
|
||
];
|
||
|
||
const openModal = () => setActiveModal(true);
|
||
const closeModal = () => setActiveModal(false);
|
||
|
||
const hasConfirmationUrl = Boolean(actionData?.confirmationUrl);
|
||
const errors = actionData?.errors || [];
|
||
|
||
// Selection state (for the beautiful plan picker)
|
||
const [selectedPlanId, setSelectedPlanId] = useState(plans[0]?.id ?? "starter");
|
||
const selectedPlan = useMemo(
|
||
() => plans.find((p) => p.id === selectedPlanId),
|
||
[plans, selectedPlanId]
|
||
);
|
||
|
||
// default to monthly
|
||
const [selectedPeriodId, setSelectedPeriodId] = useState(
|
||
selectedPlan?.periods?.[0]?.id ?? "monthly"
|
||
);
|
||
|
||
// keep period valid when switching plans
|
||
React.useEffect(() => {
|
||
if (!selectedPlan) return;
|
||
const exists = selectedPlan.periods.some((p) => p.id === selectedPeriodId);
|
||
if (!exists) setSelectedPeriodId(selectedPlan.periods[0].id);
|
||
}, [selectedPlanId]);
|
||
|
||
return (
|
||
<Page>
|
||
<TitleBar title="Data4Autos Turn14 Integration" />
|
||
<Layout>
|
||
<Layout.Section>
|
||
<Card padding="500">
|
||
<BlockStack gap="400">
|
||
<BlockStack gap="400" align="center">
|
||
<Text variant="headingLg" as="h1" alignment="center">
|
||
Welcome to your Turn14 Dashboard
|
||
</Text>
|
||
|
||
<InlineStack gap="800" align="center" blockAlign="center">
|
||
<Image source={data4autosLogo} alt="Data4Autos Logo" width={120} />
|
||
<Image
|
||
source={turn14DistributorLogo}
|
||
alt="Turn14 Distributors Logo"
|
||
width={200}
|
||
/>
|
||
</InlineStack>
|
||
</BlockStack>
|
||
|
||
<Divider />
|
||
|
||
<BlockStack gap="800">
|
||
<Text variant="headingMd" as="h3">
|
||
🚀 Data4Autos Turn14 Integration gives you the power to sync brands,
|
||
manage collections, and automate catalog setup directly from Turn14 to your Shopify store.
|
||
</Text>
|
||
|
||
<InlineStack gap="400">
|
||
<Text as="h3" variant="headingLg" fontWeight="medium">
|
||
Quick links
|
||
</Text>
|
||
|
||
<Box
|
||
paddingBlockStart="800"
|
||
paddingBlockEnd="800"
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(4, 1fr)",
|
||
gap: "1rem",
|
||
}}
|
||
>
|
||
{items.map((item, index) => (
|
||
<Card key={index} padding="500" background="bg-surface-secondary">
|
||
<BlockStack align="center" gap="200">
|
||
<Text as="p" alignment="center" tone="subdued" variant="bodyMd">
|
||
<span style={{ fontSize: "2rem" }}>{item.icon}</span>
|
||
</Text>
|
||
<a
|
||
href={item.link}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ textDecoration: "none" }}
|
||
>
|
||
<Text as="h6" alignment="center" fontWeight="bold" variant="headingMd">
|
||
{item.text}
|
||
</Text>
|
||
</a>
|
||
</BlockStack>
|
||
</Card>
|
||
))}
|
||
</Box>
|
||
</InlineStack>
|
||
</BlockStack>
|
||
|
||
<Divider />
|
||
|
||
<BlockStack gap="400">
|
||
<Text tone="subdued" alignment="center">
|
||
Need help? Contact us at{" "}
|
||
<a href="mailto:support@data4autos.com">support@data4autos.com</a>
|
||
</Text>
|
||
|
||
<InlineStack align="center" blockAlign="center">
|
||
{subscription ? (
|
||
<Badge tone="success">
|
||
{subscription.status} • Trial: {subscription.trialDays ?? 0}d
|
||
</Badge>
|
||
) : (
|
||
<Badge tone="attention">No active subscription</Badge>
|
||
)}
|
||
</InlineStack>
|
||
|
||
<Button size="large" variant="primary" onClick={openModal} fullWidth>
|
||
{loaderData?.redirectToBilling ? "Choose a Plan" : "View/Change Plan"}
|
||
</Button>
|
||
</BlockStack>
|
||
</BlockStack>
|
||
</Card>
|
||
</Layout.Section>
|
||
</Layout>
|
||
|
||
{/* ===========================
|
||
BEAUTIFUL PLAN PICKER MODAL
|
||
=========================== */}
|
||
<Modal
|
||
open={activeModal}
|
||
onClose={closeModal}
|
||
title={hasConfirmationUrl ? "Confirm Billing" : "Choose your plan"}
|
||
primaryAction={
|
||
hasConfirmationUrl
|
||
? undefined
|
||
: {
|
||
content: "Proceed to Billing",
|
||
onAction: () => {
|
||
const form = document.getElementById("billing-form") || null;
|
||
if (form) submit(form);
|
||
},
|
||
disabled: !selectedPlan || !selectedPeriodId,
|
||
}
|
||
}
|
||
secondaryActions={[{ content: "Close", onAction: closeModal }]}
|
||
large
|
||
>
|
||
<Form id="billing-form" method="post">
|
||
<Modal.Section>
|
||
<BlockStack gap="500">
|
||
{errors.length > 0 && (
|
||
<Banner title="Couldn’t create subscription" tone="critical">
|
||
<ul style={{ margin: 0, paddingLeft: "1rem" }}>
|
||
{errors.map((e, i) => (
|
||
<li key={i}>{e}</li>
|
||
))}
|
||
</ul>
|
||
</Banner>
|
||
)}
|
||
|
||
{!hasConfirmationUrl && (
|
||
<>
|
||
{/* Hidden fields sent to the action */}
|
||
<input type="hidden" name="planId" value={selectedPlanId} />
|
||
<input type="hidden" name="periodId" value={selectedPeriodId} />
|
||
<input type="hidden" name="trialDays" value={14} />
|
||
|
||
{/* Period toggle (Monthly / Annual) */}
|
||
<InlineStack gap="300" align="center" blockAlign="center">
|
||
{selectedPlan?.periods.map((period) => {
|
||
const isActive = selectedPeriodId === period.id;
|
||
return (
|
||
<Button
|
||
key={period.id}
|
||
variant={isActive ? "primary" : "secondary"}
|
||
onClick={() => setSelectedPeriodId(period.id)}
|
||
>
|
||
<InlineStack gap="150" align="center">
|
||
<span>{period.label}</span>
|
||
{period.sublabel ? (
|
||
<Tooltip content={period.sublabel}>
|
||
<Badge tone="success">Save</Badge>
|
||
</Tooltip>
|
||
) : null}
|
||
</InlineStack>
|
||
</Button>
|
||
);
|
||
})}
|
||
</InlineStack>
|
||
|
||
{/* Plan tiles */}
|
||
<Box
|
||
paddingBlockStart="400"
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(12, 1fr)",
|
||
gap: "1rem",
|
||
}}
|
||
>
|
||
{plans.map((plan) => {
|
||
const active = selectedPlanId === plan.id;
|
||
const p = plan.periods.find((pp) => pp.id === selectedPeriodId) ?? plan.periods[0];
|
||
|
||
return (
|
||
<Card
|
||
key={plan.id}
|
||
onClick={() => setSelectedPlanId(plan.id)}
|
||
padding="500"
|
||
background={active ? "bg-surface" : "bg-surface-secondary"}
|
||
sectioned
|
||
roundedAbove="sm"
|
||
style={{
|
||
gridColumn: "span 6",
|
||
cursor: "pointer",
|
||
border: active ? "2px solid var(--p-color-border-interactive)" : "1px solid var(--p-color-border-subdued)",
|
||
boxShadow: active ? "var(--p-shadow-lg)" : "var(--p-shadow-sm)",
|
||
transition: "all .2s ease",
|
||
}}
|
||
>
|
||
<BlockStack gap="300">
|
||
<InlineStack align="space-between" blockAlign="center">
|
||
<Text as="h3" variant="headingLg">
|
||
{plan.name}
|
||
</Text>
|
||
{plan.badge && (
|
||
<Badge tone={plan.highlight ? "success" : "attention"}>{plan.badge}</Badge>
|
||
)}
|
||
</InlineStack>
|
||
|
||
<InlineStack gap="100" blockAlign="baseline">
|
||
<Text as="p" variant="heading2xl" fontWeight="bold">
|
||
${p.amount}
|
||
</Text>
|
||
<Text as="p" tone="subdued">
|
||
{p.interval === "ANNUAL" ? "/yr" : "/mo"} • {p.currencyCode}
|
||
</Text>
|
||
</InlineStack>
|
||
|
||
<Divider />
|
||
|
||
<BlockStack gap="200">
|
||
{plan.features.map((f) => (
|
||
<InlineStack key={f} gap="200" blockAlign="center">
|
||
<span aria-hidden>✅</span>
|
||
<Text as="p">{f}</Text>
|
||
</InlineStack>
|
||
))}
|
||
</BlockStack>
|
||
|
||
<Divider />
|
||
|
||
<InlineStack align="space-between" blockAlign="center">
|
||
<Text tone="subdued">14-day free trial</Text>
|
||
<Button variant={active ? "primary" : "secondary"}>Select</Button>
|
||
</InlineStack>
|
||
</BlockStack>
|
||
</Card>
|
||
);
|
||
})}
|
||
</Box>
|
||
</>
|
||
)}
|
||
|
||
{hasConfirmationUrl && (
|
||
<BlockStack gap="300">
|
||
<Banner title="Almost there!" tone="success">
|
||
Click the button below to open Shopify’s billing confirmation in a new tab. If it doesn’t open, copy the link and open it manually.
|
||
</Banner>
|
||
|
||
<Button
|
||
url={actionData.confirmationUrl}
|
||
target="_blank"
|
||
external
|
||
onClick={() => setActiveModal(false)}
|
||
variant="primary"
|
||
size="large"
|
||
>
|
||
Open Billing Confirmation
|
||
</Button>
|
||
|
||
<a
|
||
href={actionData.confirmationUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ wordBreak: "break-all" }}
|
||
>
|
||
{actionData.confirmationUrl}
|
||
</a>
|
||
</BlockStack>
|
||
)}
|
||
</BlockStack>
|
||
</Modal.Section>
|
||
</Form>
|
||
</Modal>
|
||
</Page>
|
||
);
|
||
}
|