Shopify App Pricing keeps a paid yearly plan active until the billing period ends and reports the new plan as pendingUpdate. The app keeps the paid features until then and now says when the change applies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
82 lines
4.1 KiB
TypeScript
82 lines
4.1 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, type PlanCode } from "../lib/plans";
|
|
import { formatShopDate } from "../lib/tz";
|
|
|
|
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,
|
|
scheduled: scheduledChange(shop),
|
|
};
|
|
};
|
|
|
|
/** A plan change Shopify applies at the end of the billing cycle (e.g. downgrading from a yearly plan). */
|
|
export function scheduledChange(shop: { planCode: string; pendingPlanCode: PlanCode | null; planChangesAt: string | null; timezone: string }) {
|
|
if (!shop.pendingPlanCode || shop.pendingPlanCode === shop.planCode) return null;
|
|
return { name: planOf(shop.pendingPlanCode).name, date: formatShopDate(shop.planChangesAt, shop.timezone, false) };
|
|
}
|
|
|
|
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>
|
|
{d.scheduled ? (
|
|
<s-banner tone="info" heading={`Changing to ${d.scheduled.name}${d.scheduled.date ? ` on ${d.scheduled.date}` : " at the end of this billing period"}`}>
|
|
<s-paragraph>You keep the {d.currentName} features until then, because this billing period is already paid.</s-paragraph>
|
|
</s-banner>
|
|
) : null}
|
|
</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);
|