bundle-offers-app/app/routes/app.plans.tsx
metatroncubeswdev cf500e032c Read the plan from Shopify App Pricing (Partner API) and redirect shops without a plan to plan selection
App Store review 1.2.2: the app didn't register the chosen plan. App Pricing doesn't populate
currentAppInstallation.activeSubscriptions or send billing webhooks, so the plan now comes from the
Partner API activeSubscription query. Shops with no subscription (new install or reinstall) are sent
to Shopify's plan selection page; Partner API failures never redirect or downgrade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:22:55 -04:00

69 lines
3.2 KiB
TypeScript

// Plans use Shopify managed pricing (brief §18): Shopify hosts the plan page, trials and proration.
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
import { boundary } from "@shopify/shopify-app-react-router/server";
import { authenticate } from "../shopify.server";
import { syncShop } from "../lib/shop.server";
import { PLANS, planOf } from "../lib/plans";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin, session } = await authenticate.admin(request);
const shop = await syncShop(admin, session.shop, { fresh: true }); // current plan from Shopify App Pricing
const store = session.shop.replace(".myshopify.com", "");
const handle = process.env.SHOPIFY_APP_HANDLE || "offerlyst";
return {
current: shop.planCode,
pricingUrl: `https://admin.shopify.com/store/${store}/charges/${handle}/pricing_plans`,
plans: Object.entries(PLANS).map(([code, p]) => ({ code, ...p, runningOffers: p.runningOffers === Infinity ? "Unlimited" : String(p.runningOffers) })),
currentName: planOf(shop.planCode).name,
};
};
export default function Plans() {
const d = useLoaderData<typeof loader>();
return (
<s-page heading="Plans">
<s-button slot="primary-action" variant="primary" href={d.pricingUrl} target="_top">Change plan</s-button>
<s-section>
<s-paragraph>You're on the <s-text type="strong">{d.currentName}</s-text> plan. Paid plans include a 14-day free trial; development stores are free.</s-paragraph>
</s-section>
<s-section heading="Compare">
<s-table>
<s-table-header-row>
<s-table-header listSlot="primary">Plan</s-table-header>
<s-table-header>Price</s-table-header>
<s-table-header>Running offers</s-table-header>
<s-table-header>Includes</s-table-header>
</s-table-header-row>
<s-table-body>
{d.plans.map((p) => (
<s-table-row key={p.code}>
<s-table-cell>{p.name}{p.code === d.current ? " (current)" : ""}</s-table-cell>
<s-table-cell>{p.price}{p.yearly ? ` or ${p.yearly}` : ""}</s-table-cell>
<s-table-cell>{p.runningOffers}</s-table-cell>
<s-table-cell>
{[
p.types.length === 3 ? "All bundle types" : "Fixed bundles",
p.scheduling && "scheduling",
p.homepage && "homepage strip",
p.cartHints && "cart hints",
p.templates && "festival templates",
p.analytics && "analytics",
p.markets && "market targeting",
p.translations && "translations",
p.monthlyOrders !== Infinity && `${p.monthlyOrders} bundle orders/month`,
p.branding && "“Powered by” link",
...(p.extras || []),
].filter(Boolean).join(", ")}
</s-table-cell>
</s-table-row>
))}
</s-table-body>
</s-table>
</s-section>
</s-page>
);
}
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);