2025-10-06 03:49:49 +00:00

569 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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