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

508 lines
17 KiB
JavaScript
Raw 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, Form } from "@remix-run/react";
import {
Page,
Layout,
Card,
BlockStack,
Text,
InlineStack,
Image,
Divider,
Button,
Modal,
TextField,
Box,
Banner,
ChoiceList,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import data4autosLogo from "../assets/data4autos_logo.png"; // ensure this exists
import turn14DistributorLogo from "../assets/turn14-logo.png";
import { authenticate } from "../shopify.server";
/* ===========================
PRICING (single source of truth)
=========================== */
const PLAN_NAME = "Starter Sync";
const MONTHLY_AMOUNT = 79; // USD
const ANNUAL_AMOUNT = 790; // USD (≈ 2 months off)
const TRIAL_DAYS = 14;
/* ===========================
LOADER: check subscription
=========================== */
export const loader = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const resp = await admin.graphql(`
query {
currentAppInstallation {
activeSubscriptions {
id
status
trialDays
createdAt
currentPeriodEnd
}
}
}
`);
const result = await resp.json();
const subscription =
(result &&
result.data &&
result.data.currentAppInstallation &&
result.data.currentAppInstallation.activeSubscriptions &&
result.data.currentAppInstallation.activeSubscriptions[0]) || null;
const { session } = await authenticate.admin(request);
const shop = session.shop;
if (!subscription) {
return json({ redirectToBilling: true, subscription: null, shop });
}
if (subscription.status !== "ACTIVE" && subscription.status !== "TRIAL") {
return json({ redirectToBilling: true, subscription, shop });
}
return json({ redirectToBilling: false, subscription, shop });
};
/* ===========================
ACTION: create subscription (Monthly or Annual)
=========================== */
export const action = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const form = await request.formData();
const rawCadence = form.get("cadence");
const cadence = rawCadence === "ANNUAL" ? "ANNUAL" : "MONTHLY";
const interval = cadence === "ANNUAL" ? "ANNUAL" : "EVERY_30_DAYS";
const amount = cadence === "ANNUAL" ? ANNUAL_AMOUNT : MONTHLY_AMOUNT;
const { 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 || "";
// 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="Couldnt 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 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>
);
}