From 73ed98c644c26053049e343bb0df235d5ed28bda Mon Sep 17 00:00:00 2001 From: Diego Gilon Date: Thu, 12 Feb 2026 15:41:06 -0800 Subject: [PATCH 1/5] Add post-purchase subscription upsell extension with admin settings Adds a post-purchase checkout extension that offers customers a subscription upsell after completing their purchase. The offer product, discount, and selling plan are configurable from the app's admin settings page. Key changes: - Post-purchase UI extension with one-time and subscription options - Admin settings section for configuring the upsell offer (product picker, discount %, selling plan selector, enable/disable toggle) - API routes (/api/offer, /api/sign-changeset) that read config from Prisma and fetch product data from the Admin API at runtime - PostPurchaseOffer Prisma model for lean config storage - Shop domain extracted from JWT token for checkout-scoped API routes Co-Authored-By: Claude Opus 4.6 --- app/models/PostPurchaseOffer.server.ts | 25 ++ app/offer.server.ts | 259 ++++++++++++ app/routes/api.offer.ts | 73 ++++ app/routes/api.sign-changeset.ts | 88 +++++ .../components/PostPurchaseUpsellSettings.tsx | 213 ++++++++++ app/routes/app.settings._index/route.tsx | 155 +++++++- extensions/post-purchase-ui/package.json | 14 + .../post-purchase-ui/shopify.extension.toml | 11 + extensions/post-purchase-ui/src/index.jsx | 372 ++++++++++++++++++ .../migration.sql | 14 + prisma/migrations/migration_lock.toml | 4 +- prisma/schema.prisma | 11 + 12 files changed, 1221 insertions(+), 18 deletions(-) create mode 100644 app/models/PostPurchaseOffer.server.ts create mode 100644 app/offer.server.ts create mode 100644 app/routes/api.offer.ts create mode 100644 app/routes/api.sign-changeset.ts create mode 100644 app/routes/app.settings._index/components/PostPurchaseUpsellSettings.tsx create mode 100644 extensions/post-purchase-ui/package.json create mode 100644 extensions/post-purchase-ui/shopify.extension.toml create mode 100644 extensions/post-purchase-ui/src/index.jsx create mode 100644 prisma/migrations/20260212215325_add_post_purchase_offer/migration.sql diff --git a/app/models/PostPurchaseOffer.server.ts b/app/models/PostPurchaseOffer.server.ts new file mode 100644 index 0000000..488d4ae --- /dev/null +++ b/app/models/PostPurchaseOffer.server.ts @@ -0,0 +1,25 @@ +import prisma from '~/db.server'; + +export async function loadPostPurchaseOffer(shop: string) { + return prisma.postPurchaseOffer.upsert({ + where: {shop}, + create: {shop}, + update: {}, + }); +} + +export async function updatePostPurchaseOffer( + shop: string, + data: { + enabled: boolean; + variantId: string; + discountPercent: number; + sellingPlanId: string; + }, +) { + return prisma.postPurchaseOffer.upsert({ + where: {shop}, + create: {shop, ...data}, + update: data, + }); +} diff --git a/app/offer.server.ts b/app/offer.server.ts new file mode 100644 index 0000000..a15fc1f --- /dev/null +++ b/app/offer.server.ts @@ -0,0 +1,259 @@ +import type {GraphQLClient} from '~/types'; +import type {PostPurchaseOffer} from '@prisma/client'; + +interface AddVariantChange { + type: "add_variant"; + variantID: number; + quantity: number; + discount: { + value: number; + valueType: "percentage" | "fixed_amount"; + title: string; + }; +} + +interface AddSubscriptionChange { + type: "add_subscription"; + variantID: number; + quantity: number; + discount: { + value: number; + valueType: "percentage" | "fixed_amount"; + title: string; + }; + sellingPlanID: number; + initialShippingPrice: string; + recurringShippingPrice: string; +} + +interface AddShippingLineChange { + type: "add_shipping_line"; + price: string; + title: string; +} + +export type OfferChange = AddVariantChange | AddSubscriptionChange | AddShippingLineChange; + +export interface SellingPlanOption { + label: string; + value: string; + sellingPlanName: string; + interval: string; + intervalCount: number; + discount: number; +} + +export interface Offer { + id: number; + productTitle: string; + productImageURL: string; + productDescription: string[]; + originalPrice: string; + discountedPrice: string; + changes: OfferChange[]; + hasSubscriptionOption: boolean; + sellingPlanOptions?: SellingPlanOption[]; +} + +const VARIANT_QUERY = `#graphql + query VariantDetails($id: ID!) { + node(id: $id) { + ... on ProductVariant { + id + title + price + product { + title + description + featuredMedia { + preview { + image { + url + } + } + } + sellingPlanGroups(first: 5) { + nodes { + sellingPlans(first: 10) { + nodes { + id + name + billingPolicy { + ... on SellingPlanRecurringBillingPolicy { + interval + intervalCount + } + } + pricingPolicies { + ... on SellingPlanFixedPricingPolicy { + adjustmentType + adjustmentValue { + ... on SellingPlanPricingPolicyPercentageValue { + percentage + } + } + } + ... on SellingPlanRecurringPricingPolicy { + adjustmentType + adjustmentValue { + ... on SellingPlanPricingPolicyPercentageValue { + percentage + } + } + } + } + } + } + } + } + } + } + } + } +`; + +export async function fetchOfferDetails( + graphql: GraphQLClient, + config: PostPurchaseOffer, +): Promise { + if (!config.variantId) return null; + + const variantGid = config.variantId.startsWith('gid://') + ? config.variantId + : `gid://shopify/ProductVariant/${config.variantId}`; + + const response = await graphql(VARIANT_QUERY, { + variables: {id: variantGid}, + }); + + const {data} = await response.json(); + const variant = data?.node; + + if (!variant) return null; + + const product = variant.product; + const price = variant.price; + const imageUrl = product?.featuredMedia?.preview?.image?.url || ''; + const discountedPrice = ( + parseFloat(price) * + (1 - config.discountPercent / 100) + ).toFixed(2); + + // Extract numeric variant ID for the changeset + const numericVariantId = parseInt( + variantGid.replace('gid://shopify/ProductVariant/', ''), + 10, + ); + + // Build selling plan options from the product's selling plans + const sellingPlanOptions: SellingPlanOption[] = []; + const hasSubscriptionOption = !!config.sellingPlanId; + + if (hasSubscriptionOption) { + // Add one-time purchase option first + sellingPlanOptions.push({ + label: `One-time purchase — $${price}`, + value: 'one-time', + sellingPlanName: '', + interval: '', + intervalCount: 0, + discount: config.discountPercent, + }); + + // Find matching selling plans from the product + for (const group of product?.sellingPlanGroups?.nodes || []) { + for (const plan of group.sellingPlans?.nodes || []) { + const planNumericId = plan.id.replace('gid://shopify/SellingPlan/', ''); + if (planNumericId !== config.sellingPlanId) continue; + + const interval = plan.billingPolicy?.interval || 'MONTH'; + const intervalCount = plan.billingPolicy?.intervalCount || 1; + + // Get discount from the selling plan's pricing policies + let planDiscount = 0; + for (const policy of plan.pricingPolicies || []) { + if (policy.adjustmentType === 'PERCENTAGE') { + planDiscount = policy.adjustmentValue?.percentage || 0; + } + } + + const planPrice = ( + parseFloat(price) * + (1 - planDiscount / 100) + ).toFixed(2); + + sellingPlanOptions.push({ + label: `${plan.name} — $${planPrice} (${planDiscount}% off)`, + value: planNumericId, + sellingPlanName: plan.name, + interval, + intervalCount, + discount: planDiscount, + }); + } + } + } + + return { + id: 1, + productTitle: product?.title || '', + productImageURL: imageUrl, + productDescription: product?.description + ? [product.description] + : [''], + originalPrice: price, + discountedPrice, + hasSubscriptionOption, + sellingPlanOptions: hasSubscriptionOption ? sellingPlanOptions : undefined, + changes: [ + { + type: 'add_variant', + variantID: numericVariantId, + quantity: 1, + discount: { + value: config.discountPercent, + valueType: 'percentage', + title: `Post-purchase ${config.discountPercent}% off`, + }, + }, + ], + }; +} + +export function buildChangesForPlan( + offer: Offer, + sellingPlanId: string, +): OfferChange[] { + if (sellingPlanId === "one-time") { + return offer.changes; + } + + const plan = offer.sellingPlanOptions?.find( + (opt) => opt.value === sellingPlanId, + ); + + if (!plan) return offer.changes; + + const variantID = (offer.changes[0] as AddVariantChange).variantID; + + return [ + { + type: "add_subscription", + variantID, + quantity: 1, + discount: { + value: plan.discount, + valueType: "percentage", + title: `Subscribe & save ${plan.discount}%`, + }, + sellingPlanID: parseInt(sellingPlanId, 10), + initialShippingPrice: "0.00", + recurringShippingPrice: "0.00", + }, + { + type: "add_shipping_line", + price: "0.00", + title: "Free shipping on subscriptions", + }, + ]; +} diff --git a/app/routes/api.offer.ts b/app/routes/api.offer.ts new file mode 100644 index 0000000..404fb9a --- /dev/null +++ b/app/routes/api.offer.ts @@ -0,0 +1,73 @@ +import type {ActionFunctionArgs, LoaderFunctionArgs} from '@remix-run/node'; +import jwt from 'jsonwebtoken'; +import {authenticate, unauthenticated} from '~/shopify.server'; +import {loadPostPurchaseOffer} from '~/models/PostPurchaseOffer.server'; +import {fetchOfferDetails} from '~/offer.server'; + +function getShopFromRequest(request: Request): string | null { + const authHeader = request.headers.get('Authorization') || ''; + const token = authHeader.replace('Bearer ', ''); + if (!token) return null; + + try { + const decoded = jwt.decode(token) as { + input_data?: {shop?: {domain?: string}}; + } | null; + return decoded?.input_data?.shop?.domain || null; + } catch { + return null; + } +} + +export async function loader({request}: LoaderFunctionArgs) { + const {cors} = await authenticate.public.checkout(request); + return cors(new Response(null, {status: 204})); +} + +export async function action({request}: ActionFunctionArgs) { + const {cors} = await authenticate.public.checkout(request); + + const shop = getShopFromRequest(request); + + if (!shop) { + return cors( + new Response(JSON.stringify({offers: []}), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }), + ); + } + + const config = await loadPostPurchaseOffer(shop); + + if (!config.enabled || !config.variantId) { + return cors( + new Response(JSON.stringify({offers: []}), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }), + ); + } + + try { + const {admin} = await unauthenticated.admin(shop); + const offer = await fetchOfferDetails(admin.graphql, config); + + const offers = offer ? [offer] : []; + + return cors( + new Response(JSON.stringify({offers}), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }), + ); + } catch (error) { + console.error('[api.offer] Error:', error); + return cors( + new Response(JSON.stringify({offers: [], error: String(error)}), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }), + ); + } +} diff --git a/app/routes/api.sign-changeset.ts b/app/routes/api.sign-changeset.ts new file mode 100644 index 0000000..88f3f4b --- /dev/null +++ b/app/routes/api.sign-changeset.ts @@ -0,0 +1,88 @@ +import type {ActionFunctionArgs, LoaderFunctionArgs} from '@remix-run/node'; +import jwt from 'jsonwebtoken'; +import {v4 as uuidv4} from 'uuid'; +import {authenticate, unauthenticated} from '~/shopify.server'; +import {loadPostPurchaseOffer} from '~/models/PostPurchaseOffer.server'; +import {fetchOfferDetails, buildChangesForPlan} from '~/offer.server'; + +function getShopFromRequest(request: Request): string | null { + const authHeader = request.headers.get('Authorization') || ''; + const token = authHeader.replace('Bearer ', ''); + if (!token) return null; + + try { + const decoded = jwt.decode(token) as { + input_data?: {shop?: {domain?: string}}; + } | null; + return decoded?.input_data?.shop?.domain || null; + } catch { + return null; + } +} + +export async function loader({request}: LoaderFunctionArgs) { + const {cors} = await authenticate.public.checkout(request); + return cors(new Response(null, {status: 204})); +} + +export async function action({request}: ActionFunctionArgs) { + const {cors} = await authenticate.public.checkout(request); + + const body = await request.json(); + const {referenceId, sellingPlanId} = body; + + const shop = getShopFromRequest(request); + + if (!shop) { + return cors( + new Response(JSON.stringify({error: 'Shop required'}), { + status: 400, + headers: {'Content-Type': 'application/json'}, + }), + ); + } + + const config = await loadPostPurchaseOffer(shop); + + if (!config.enabled || !config.variantId) { + return cors( + new Response(JSON.stringify({error: 'Offer not configured'}), { + status: 404, + headers: {'Content-Type': 'application/json'}, + }), + ); + } + + const {admin} = await unauthenticated.admin(shop); + const offer = await fetchOfferDetails(admin.graphql, config); + + if (!offer) { + return cors( + new Response(JSON.stringify({error: 'Offer not found'}), { + status: 404, + headers: {'Content-Type': 'application/json'}, + }), + ); + } + + const changes = buildChangesForPlan(offer, sellingPlanId || 'one-time'); + + const payload = { + iss: process.env.SHOPIFY_API_KEY, + jti: uuidv4(), + iat: Math.floor(Date.now() / 1000), + sub: referenceId, + changes, + }; + + const token = jwt.sign(payload, process.env.SHOPIFY_API_SECRET!, { + algorithm: 'HS256', + }); + + return cors( + new Response(JSON.stringify({token}), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }), + ); +} diff --git a/app/routes/app.settings._index/components/PostPurchaseUpsellSettings.tsx b/app/routes/app.settings._index/components/PostPurchaseUpsellSettings.tsx new file mode 100644 index 0000000..81d9326 --- /dev/null +++ b/app/routes/app.settings._index/components/PostPurchaseUpsellSettings.tsx @@ -0,0 +1,213 @@ +import {useState, useCallback} from 'react'; +import { + BlockStack, + Box, + Button, + Card, + Checkbox, + FormLayout, + InlineGrid, + InlineStack, + Select, + Text, + TextField, + Thumbnail, +} from '@shopify/polaris'; +import {ImageIcon} from '@shopify/polaris-icons'; +import {useAppBridge} from '@shopify/app-bridge-react'; +import {Form, useNavigation} from '@remix-run/react'; + +interface SellingPlanInfo { + id: string; + name: string; +} + +interface ProductInfo { + title: string; + variantTitle: string; + imageUrl: string; + price: string; +} + +export interface PostPurchaseOfferData { + enabled: boolean; + variantId: string; + discountPercent: number; + sellingPlanId: string; + productInfo: ProductInfo | null; + sellingPlans: SellingPlanInfo[]; +} + +interface PostPurchaseUpsellSettingsProps { + offer: PostPurchaseOfferData; +} + +export function PostPurchaseUpsellSettings({ + offer, +}: PostPurchaseUpsellSettingsProps) { + const shopify = useAppBridge(); + const navigation = useNavigation(); + const isSubmitting = + navigation.state === 'submitting' && + navigation.formData?.get('_action') === 'postPurchaseOffer'; + + const [enabled, setEnabled] = useState(offer.enabled); + const [variantId, setVariantId] = useState(offer.variantId); + const [discountPercent, setDiscountPercent] = useState( + String(offer.discountPercent), + ); + const [sellingPlanId, setSellingPlanId] = useState(offer.sellingPlanId); + + // Local product info from resource picker (overrides loader data when user picks a new product) + const [pickedProduct, setPickedProduct] = useState<{ + title: string; + variantTitle: string; + imageUrl: string; + price: string; + } | null>(null); + + const productInfo = pickedProduct || offer.productInfo; + + const selectProduct = useCallback(async () => { + const selected = await shopify.resourcePicker({ + type: 'product', + action: 'select', + multiple: false, + filter: { + variants: true, + }, + }); + + if (selected && selected.length > 0) { + const product = selected[0]; + const variant = + product.variants && product.variants.length > 0 + ? product.variants[0] + : null; + + if (variant) { + const numericId = variant.id?.replace( + 'gid://shopify/ProductVariant/', + '', + ); + setVariantId(numericId || ''); + setPickedProduct({ + title: product.title, + variantTitle: variant.title || 'Default', + imageUrl: product.images?.[0]?.originalSrc || '', + price: String(variant.price || '0.00'), + }); + // Clear selling plan since product changed — save first to load new selling plans + setSellingPlanId(''); + } + } + }, [shopify]); + + const sellingPlanOptions = [ + {label: 'None (one-time only)', value: ''}, + ...offer.sellingPlans.map((sp) => ({ + label: sp.name, + value: sp.id, + })), + ]; + + return ( + + + + + Post-purchase upsell + + + Configure the product offered to customers after checkout. + + + + +
+ + + + + + + + + + + Product + + + {productInfo ? ( + + + + +
+ + + {productInfo.title} + + + {productInfo.variantTitle} — ${productInfo.price} + + +
+ +
+ ) : ( + + )} + + + + + {offer.sellingPlans.length > 0 && ( + - + @@ -90,6 +205,14 @@ export default function SettingsIndex() { + + + + + + + + ); } diff --git a/extensions/post-purchase-ui/package.json b/extensions/post-purchase-ui/package.json new file mode 100644 index 0000000..11e1a6b --- /dev/null +++ b/extensions/post-purchase-ui/package.json @@ -0,0 +1,14 @@ +{ + "name": "post-purchase-ui", + "private": true, + "version": "1.0.0", + "main": "dist/main.js", + "license": "UNLICENSED", + "dependencies": { + "react": "^17.0.0", + "@shopify/post-purchase-ui-extensions-react": "^0.13.2" + }, + "devDependencies": { + "@types/react": "^17.0.0" + } +} \ No newline at end of file diff --git a/extensions/post-purchase-ui/shopify.extension.toml b/extensions/post-purchase-ui/shopify.extension.toml new file mode 100644 index 0000000..2977925 --- /dev/null +++ b/extensions/post-purchase-ui/shopify.extension.toml @@ -0,0 +1,11 @@ +name = "post-purchase-ui" +type = "checkout_post_purchase" +uid = "cd7fff10-47a6-a6f7-60f2-c8e090f069c8fd0d2443" + +# [[metafields]] +# namespace = "my-namespace" +# key = "my-key" + +# [[metafields]] +# namespace = "my-namespace" +# key = "my-key-2" diff --git a/extensions/post-purchase-ui/src/index.jsx b/extensions/post-purchase-ui/src/index.jsx new file mode 100644 index 0000000..842abac --- /dev/null +++ b/extensions/post-purchase-ui/src/index.jsx @@ -0,0 +1,372 @@ +import { useEffect, useState, useCallback } from "react"; +import { + extend, + render, + useExtensionInput, + BlockStack, + Button, + CalloutBanner, + Heading, + Image, + Text, + TextContainer, + Separator, + Tiles, + TextBlock, + Layout, + Select, + BuyerConsent, +} from "@shopify/post-purchase-ui-extensions-react"; + +// For local development, replace APP_URL with your local tunnel URL. +const APP_URL = "https://clips-latinas-walls-odds.trycloudflare.com"; + +const INTERVAL_LABELS = { + DAY: { singular: "day", plural: "days" }, + WEEK: { singular: "week", plural: "weeks" }, + MONTH: { singular: "month", plural: "months" }, + YEAR: { singular: "year", plural: "years" }, +}; + +function formatInterval(interval, intervalCount) { + const labels = INTERVAL_LABELS[interval] || { singular: interval.toLowerCase(), plural: interval.toLowerCase() + "s" }; + if (intervalCount === 1) return labels.singular; + return `${intervalCount} ${labels.plural}`; +} + +// Preload data from your app server to ensure that the extension loads quickly. +extend( + "Checkout::PostPurchase::ShouldRender", + async ({ inputData, storage }) => { + const postPurchaseOffer = await fetch(`${APP_URL}/api/offer`, { + method: "POST", + headers: { + Authorization: `Bearer ${inputData.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + referenceId: inputData.initialPurchase.referenceId, + }), + }).then((response) => response.json()); + + await storage.update(postPurchaseOffer); + + return { render: postPurchaseOffer.offers && postPurchaseOffer.offers.length > 0 }; + } +); + +render("Checkout::PostPurchase::Render", () => ); + +export function App() { + const { storage, inputData, calculateChangeset, applyChangeset, done } = + useExtensionInput(); + const [loading, setLoading] = useState(true); + const [calculatedPurchase, setCalculatedPurchase] = useState(); + // Default to the first subscription plan if available, otherwise one-time + const defaultPlan = storage.initialData.offers[0]?.sellingPlanOptions?.find( + (opt) => opt.value !== "one-time" + )?.value || "one-time"; + const [selectedPlan, setSelectedPlan] = useState(defaultPlan); + const [buyerConsent, setBuyerConsent] = useState(false); + + const { offers } = storage.initialData; + const purchaseOption = offers[0]; + + const isSubscription = selectedPlan !== "one-time"; + + // Get the currently selected plan details + const currentPlan = purchaseOption.sellingPlanOptions?.find( + (opt) => opt.value === selectedPlan + ); + + // Format the interval string for the current plan + const intervalLabel = currentPlan?.interval + ? formatInterval(currentPlan.interval, currentPlan.intervalCount) + : ""; + + // Build the changes array based on the selected plan + const getChangesForPlan = useCallback(() => { + if (!isSubscription) { + return purchaseOption.changes; + } + + const plan = purchaseOption.sellingPlanOptions?.find( + (opt) => opt.value === selectedPlan + ); + + if (!plan) return purchaseOption.changes; + + return [ + { + type: "add_subscription", + variantID: purchaseOption.changes[0].variantID, + quantity: 1, + discount: { + value: plan.discount, + valueType: "percentage", + title: `Subscribe & save ${plan.discount}%`, + }, + sellingPlanID: parseInt(plan.value, 10), + initialShippingPrice: "0.00", + recurringShippingPrice: "0.00", + }, + { + type: "add_shipping_line", + price: "0.00", + title: "Free shipping on subscriptions", + }, + ]; + }, [selectedPlan, purchaseOption, isSubscription]); + + // Recalculate changeset when plan selection changes + useEffect(() => { + async function calculatePurchase() { + setLoading(true); + const changes = getChangesForPlan(); + const result = await calculateChangeset({ changes }); + setCalculatedPurchase(result.calculatedPurchase); + setLoading(false); + } + + calculatePurchase(); + }, [calculateChangeset, selectedPlan, getChangesForPlan]); + + // Compute fallback price from the offer data based on selected plan + const fallbackPrice = currentPlan + ? (parseFloat(purchaseOption.originalPrice) * (1 - currentPlan.discount / 100)).toFixed(2) + : purchaseOption.discountedPrice; + + // Extract values from the calculated purchase, falling back to offer data. + const shipping = + calculatedPurchase?.addedShippingLines?.[0]?.priceSet?.presentmentMoney + ?.amount ?? "0.00"; + const taxes = + calculatedPurchase?.addedTaxLines?.[0]?.priceSet?.presentmentMoney?.amount ?? "0.00"; + const discountedPrice = + calculatedPurchase?.updatedLineItems?.[0]?.totalPriceSet?.presentmentMoney + ?.amount || fallbackPrice; + const originalPrice = + calculatedPurchase?.updatedLineItems?.[0]?.priceSet?.presentmentMoney?.amount || purchaseOption.originalPrice; + const total = calculatedPurchase?.totalOutstandingSet?.presentmentMoney?.amount || fallbackPrice; + + // Discount percentage for display + const savingsPercent = currentPlan?.discount || 0; + + async function acceptOffer() { + if (isSubscription && !buyerConsent) { + return; + } + + setLoading(true); + + const token = await fetch(`${APP_URL}/api/sign-changeset`, { + method: "POST", + headers: { + Authorization: `Bearer ${inputData.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + referenceId: inputData.initialPurchase.referenceId, + changes: purchaseOption.id, + sellingPlanId: selectedPlan, + }), + }) + .then((response) => response.json()) + .then((response) => response.token) + .catch((e) => console.log(e)); + + let result; + if (isSubscription) { + result = await applyChangeset(token, { + buyerConsentToSubscriptions: buyerConsent, + }); + } else { + result = await applyChangeset(token); + } + + done(); + } + + function declineOffer() { + setLoading(true); + done(); + } + + return ( + + + + + + {isSubscription + ? `Subscribe & save ${savingsPercent}%` + : "Exclusive post-purchase offer"} + + + + + {isSubscription + ? `Get ${purchaseOption.productTitle} delivered every ${intervalLabel} and never run out.` + : `Add ${purchaseOption.productTitle} to your order at a special discount.`} + + + + + + + + + {purchaseOption.productTitle} + + + + {/* Selling plan selector */} + {purchaseOption.hasSubscriptionOption && + purchaseOption.sellingPlanOptions && ( + - + diff --git a/app/utils/validateFormData.ts b/app/utils/validateFormData.ts index e91f1bd..6662941 100644 --- a/app/utils/validateFormData.ts +++ b/app/utils/validateFormData.ts @@ -1,4 +1,4 @@ -import {withStandardSchema, type ValidationResult} from '@rvf/core'; +import {createValidator, type ValidationResult} from '@rvf/core'; import type {ZodType} from 'zod'; type ZodSchemaType = Type extends ZodType ? X : never; @@ -7,7 +7,22 @@ export async function validateFormData( schema: S, formData: FormData, ): Promise>> { - const validator = withStandardSchema(schema); + const validator = createValidator>({ + validate: async (data) => { + const result = await schema.safeParseAsync(data); + if (result.success) { + return {data: result.data, error: undefined}; + } + const fieldErrors: Record = {}; + for (const issue of result.error.issues) { + const path = issue.path.join('.'); + if (!fieldErrors[path]) { + fieldErrors[path] = issue.message; + } + } + return {error: fieldErrors, data: undefined}; + }, + }); return validator.validate(formData); } From df33330d46f0bb7c1de91f9714a7ffaff234b979 Mon Sep 17 00:00:00 2001 From: Diego Gilon Date: Fri, 13 Feb 2026 13:15:06 -0800 Subject: [PATCH 4/5] Improve post-purchase upsell settings UX - Disable save button when no changes have been made (dirty state tracking) - Disable form fields when upsell is toggled off - Fetch selling plans immediately on product pick via Admin API - Show spinner while loading selling plans - Lock selling plan dropdown for products without selling plans - Add dividers between sections and better empty state Co-Authored-By: Claude Opus 4.6 --- .../components/PostPurchaseUpsellSettings.tsx | 186 ++++++++++++++---- 1 file changed, 150 insertions(+), 36 deletions(-) diff --git a/app/routes/app.settings._index/components/PostPurchaseUpsellSettings.tsx b/app/routes/app.settings._index/components/PostPurchaseUpsellSettings.tsx index 81d9326..2240f10 100644 --- a/app/routes/app.settings._index/components/PostPurchaseUpsellSettings.tsx +++ b/app/routes/app.settings._index/components/PostPurchaseUpsellSettings.tsx @@ -1,14 +1,16 @@ -import {useState, useCallback} from 'react'; +import {useState, useCallback, useMemo} from 'react'; import { BlockStack, Box, Button, Card, Checkbox, + Divider, FormLayout, InlineGrid, InlineStack, Select, + Spinner, Text, TextField, Thumbnail, @@ -42,6 +44,51 @@ interface PostPurchaseUpsellSettingsProps { offer: PostPurchaseOfferData; } +const SELLING_PLANS_QUERY = ` + query ProductSellingPlans($id: ID!) { + product(id: $id) { + sellingPlanGroups(first: 5) { + nodes { + sellingPlans(first: 10) { + nodes { + id + name + } + } + } + } + } + } +`; + +async function fetchSellingPlans( + productGid: string, +): Promise { + const response = await fetch('shopify:admin/api/2025-04/graphql.json', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + query: SELLING_PLANS_QUERY, + variables: {id: productGid}, + }), + }); + + const json = await response.json(); + const data = json.data; + const plans: SellingPlanInfo[] = []; + + for (const group of data?.product?.sellingPlanGroups?.nodes || []) { + for (const plan of group.sellingPlans?.nodes || []) { + plans.push({ + id: plan.id.replace('gid://shopify/SellingPlan/', ''), + name: plan.name, + }); + } + } + + return plans; +} + export function PostPurchaseUpsellSettings({ offer, }: PostPurchaseUpsellSettingsProps) { @@ -59,14 +106,26 @@ export function PostPurchaseUpsellSettings({ const [sellingPlanId, setSellingPlanId] = useState(offer.sellingPlanId); // Local product info from resource picker (overrides loader data when user picks a new product) - const [pickedProduct, setPickedProduct] = useState<{ - title: string; - variantTitle: string; - imageUrl: string; - price: string; - } | null>(null); + const [pickedProduct, setPickedProduct] = useState(null); + + // Selling plans fetched for the picked product (null = using loader data) + const [pickedSellingPlans, setPickedSellingPlans] = useState< + SellingPlanInfo[] | null + >(null); + const [loadingSellingPlans, setLoadingSellingPlans] = useState(false); const productInfo = pickedProduct || offer.productInfo; + const availableSellingPlans = pickedSellingPlans ?? offer.sellingPlans; + + // Track dirty state + const isDirty = useMemo(() => { + return ( + enabled !== offer.enabled || + variantId !== offer.variantId || + discountPercent !== String(offer.discountPercent) || + sellingPlanId !== offer.sellingPlanId + ); + }, [enabled, variantId, discountPercent, sellingPlanId, offer]); const selectProduct = useCallback(async () => { const selected = await shopify.resourcePicker({ @@ -97,15 +156,26 @@ export function PostPurchaseUpsellSettings({ imageUrl: product.images?.[0]?.originalSrc || '', price: String(variant.price || '0.00'), }); - // Clear selling plan since product changed — save first to load new selling plans setSellingPlanId(''); + + // Fetch selling plans for the newly picked product + setLoadingSellingPlans(true); + try { + const plans = await fetchSellingPlans(product.id); + setPickedSellingPlans(plans); + } catch (e) { + console.error('Failed to fetch selling plans:', e); + setPickedSellingPlans([]); + } finally { + setLoadingSellingPlans(false); + } } } }, [shopify]); const sellingPlanOptions = [ {label: 'None (one-time only)', value: ''}, - ...offer.sellingPlans.map((sp) => ({ + ...availableSellingPlans.map((sp) => ({ label: sp.name, value: sp.id, })), @@ -142,35 +212,55 @@ export function PostPurchaseUpsellSettings({ onChange={setEnabled} /> + + Product {productInfo ? ( - - - -
+ + - + {productInfo.title} {productInfo.variantTitle} — ${productInfo.price} -
- + +
) : ( - + + + + + No product selected + + + + + )} + + - {offer.sellingPlans.length > 0 && ( - 0 + ? sellingPlanOptions + : [{label: 'None (one-time only)', value: ''}] + } + value={ + availableSellingPlans.length > 0 ? sellingPlanId : '' + } + onChange={setSellingPlanId} + disabled={!enabled || availableSellingPlans.length === 0} + helpText={ + availableSellingPlans.length > 0 + ? 'Select a selling plan to offer a subscription option alongside one-time purchase.' + : 'This product has no selling plans. Add a selling plan to the product to enable subscription upsells.' + } + /> + ) )} - From 6f9eaf7cf0f84647cd501289adb74f7f9db34aad Mon Sep 17 00:00:00 2001 From: Diego Gilon Date: Fri, 13 Feb 2026 14:11:04 -0800 Subject: [PATCH 5/5] Fetch selling plans on product pick via server route Replace unreliable shopify: direct API with a Remix fetcher calling a new /app/selling-plans loader. Selling plans now load immediately when a product is selected without requiring a save+reload. Co-Authored-By: Claude Opus 4.6 --- app/routes/app.selling-plans.ts | 57 +++++++++++ .../components/PostPurchaseUpsellSettings.tsx | 94 +++++-------------- 2 files changed, 82 insertions(+), 69 deletions(-) create mode 100644 app/routes/app.selling-plans.ts diff --git a/app/routes/app.selling-plans.ts b/app/routes/app.selling-plans.ts new file mode 100644 index 0000000..97ec97c --- /dev/null +++ b/app/routes/app.selling-plans.ts @@ -0,0 +1,57 @@ +import type {LoaderFunctionArgs} from '@remix-run/node'; +import {json} from '@remix-run/node'; +import {authenticate} from '~/shopify.server'; + +const SELLING_PLANS_QUERY = `#graphql + query ProductSellingPlans($id: ID!) { + product(id: $id) { + sellingPlanGroups(first: 5) { + nodes { + sellingPlans(first: 10) { + nodes { + id + name + } + } + } + } + } + } +`; + +export async function loader({request}: LoaderFunctionArgs) { + const {admin} = await authenticate.admin(request); + + const url = new URL(request.url); + const productId = url.searchParams.get('productId'); + + if (!productId) { + return json({sellingPlans: []}); + } + + const productGid = productId.startsWith('gid://') + ? productId + : `gid://shopify/Product/${productId}`; + + try { + const response = await admin.graphql(SELLING_PLANS_QUERY, { + variables: {id: productGid}, + }); + const {data} = await response.json(); + + const sellingPlans: {id: string; name: string}[] = []; + for (const group of data?.product?.sellingPlanGroups?.nodes || []) { + for (const plan of group.sellingPlans?.nodes || []) { + sellingPlans.push({ + id: plan.id.replace('gid://shopify/SellingPlan/', ''), + name: plan.name, + }); + } + } + + return json({sellingPlans}); + } catch (e) { + console.error('Failed to fetch selling plans:', e); + return json({sellingPlans: []}); + } +} diff --git a/app/routes/app.settings._index/components/PostPurchaseUpsellSettings.tsx b/app/routes/app.settings._index/components/PostPurchaseUpsellSettings.tsx index 2240f10..617cdab 100644 --- a/app/routes/app.settings._index/components/PostPurchaseUpsellSettings.tsx +++ b/app/routes/app.settings._index/components/PostPurchaseUpsellSettings.tsx @@ -1,4 +1,4 @@ -import {useState, useCallback, useMemo} from 'react'; +import {useState, useCallback, useMemo, useEffect} from 'react'; import { BlockStack, Box, @@ -17,7 +17,7 @@ import { } from '@shopify/polaris'; import {ImageIcon} from '@shopify/polaris-icons'; import {useAppBridge} from '@shopify/app-bridge-react'; -import {Form, useNavigation} from '@remix-run/react'; +import {Form, useNavigation, useFetcher} from '@remix-run/react'; interface SellingPlanInfo { id: string; @@ -44,56 +44,12 @@ interface PostPurchaseUpsellSettingsProps { offer: PostPurchaseOfferData; } -const SELLING_PLANS_QUERY = ` - query ProductSellingPlans($id: ID!) { - product(id: $id) { - sellingPlanGroups(first: 5) { - nodes { - sellingPlans(first: 10) { - nodes { - id - name - } - } - } - } - } - } -`; - -async function fetchSellingPlans( - productGid: string, -): Promise { - const response = await fetch('shopify:admin/api/2025-04/graphql.json', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - query: SELLING_PLANS_QUERY, - variables: {id: productGid}, - }), - }); - - const json = await response.json(); - const data = json.data; - const plans: SellingPlanInfo[] = []; - - for (const group of data?.product?.sellingPlanGroups?.nodes || []) { - for (const plan of group.sellingPlans?.nodes || []) { - plans.push({ - id: plan.id.replace('gid://shopify/SellingPlan/', ''), - name: plan.name, - }); - } - } - - return plans; -} - export function PostPurchaseUpsellSettings({ offer, }: PostPurchaseUpsellSettingsProps) { const shopify = useAppBridge(); const navigation = useNavigation(); + const sellingPlansFetcher = useFetcher<{sellingPlans: SellingPlanInfo[]}>(); const isSubmitting = navigation.state === 'submitting' && navigation.formData?.get('_action') === 'postPurchaseOffer'; @@ -108,14 +64,14 @@ export function PostPurchaseUpsellSettings({ // Local product info from resource picker (overrides loader data when user picks a new product) const [pickedProduct, setPickedProduct] = useState(null); - // Selling plans fetched for the picked product (null = using loader data) - const [pickedSellingPlans, setPickedSellingPlans] = useState< - SellingPlanInfo[] | null - >(null); - const [loadingSellingPlans, setLoadingSellingPlans] = useState(false); + // Selling plans: use fetcher data when a new product is picked, otherwise loader data + const [pickedProductId, setPickedProductId] = useState(null); + + const loadingSellingPlans = pickedProductId !== null && sellingPlansFetcher.state === 'loading'; + const fetchedSellingPlans = pickedProductId !== null ? sellingPlansFetcher.data?.sellingPlans : null; + const availableSellingPlans = fetchedSellingPlans ?? (pickedProductId !== null ? [] : offer.sellingPlans); const productInfo = pickedProduct || offer.productInfo; - const availableSellingPlans = pickedSellingPlans ?? offer.sellingPlans; // Track dirty state const isDirty = useMemo(() => { @@ -145,11 +101,11 @@ export function PostPurchaseUpsellSettings({ : null; if (variant) { - const numericId = variant.id?.replace( + const numericVariantId = variant.id?.replace( 'gid://shopify/ProductVariant/', '', ); - setVariantId(numericId || ''); + setVariantId(numericVariantId || ''); setPickedProduct({ title: product.title, variantTitle: variant.title || 'Default', @@ -158,20 +114,18 @@ export function PostPurchaseUpsellSettings({ }); setSellingPlanId(''); - // Fetch selling plans for the newly picked product - setLoadingSellingPlans(true); - try { - const plans = await fetchSellingPlans(product.id); - setPickedSellingPlans(plans); - } catch (e) { - console.error('Failed to fetch selling plans:', e); - setPickedSellingPlans([]); - } finally { - setLoadingSellingPlans(false); - } + // Fetch selling plans via server route + const productNumericId = product.id.replace( + 'gid://shopify/Product/', + '', + ); + setPickedProductId(productNumericId); + sellingPlansFetcher.load( + `/app/selling-plans?productId=${productNumericId}`, + ); } } - }, [shopify]); + }, [shopify, sellingPlansFetcher]); const sellingPlanOptions = [ {label: 'None (one-time only)', value: ''}, @@ -272,13 +226,15 @@ export function PostPurchaseUpsellSettings({ suffix="%" autoComplete="off" disabled={!enabled} - helpText="Percentage off the original price for one-time purchases." + helpText="Percentage off the original price applied to the post-purchase offer." /> {productInfo && ( loadingSellingPlans ? ( - Subscription selling plan + + Subscription selling plan +