diff --git a/.gitignore b/.gitignore index c50a086..452aff1 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,10 @@ yarn-error.log /extensions/**/types/*.types.d.ts +# App-specific config (generated by shopify app config link) +shopify.app.*.toml +!shopify.app.toml + # OS files **/.DS_Store .shopify 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.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 new file mode 100644 index 0000000..617cdab --- /dev/null +++ b/app/routes/app.settings._index/components/PostPurchaseUpsellSettings.tsx @@ -0,0 +1,283 @@ +import {useState, useCallback, useMemo, useEffect} from 'react'; +import { + BlockStack, + Box, + Button, + Card, + Checkbox, + Divider, + FormLayout, + InlineGrid, + InlineStack, + Select, + Spinner, + Text, + TextField, + Thumbnail, +} from '@shopify/polaris'; +import {ImageIcon} from '@shopify/polaris-icons'; +import {useAppBridge} from '@shopify/app-bridge-react'; +import {Form, useNavigation, useFetcher} 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 sellingPlansFetcher = useFetcher<{sellingPlans: SellingPlanInfo[]}>(); + 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(null); + + // 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; + + // 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({ + 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 numericVariantId = variant.id?.replace( + 'gid://shopify/ProductVariant/', + '', + ); + setVariantId(numericVariantId || ''); + setPickedProduct({ + title: product.title, + variantTitle: variant.title || 'Default', + imageUrl: product.images?.[0]?.originalSrc || '', + price: String(variant.price || '0.00'), + }); + setSellingPlanId(''); + + // Fetch selling plans via server route + const productNumericId = product.id.replace( + 'gid://shopify/Product/', + '', + ); + setPickedProductId(productNumericId); + sellingPlansFetcher.load( + `/app/selling-plans?productId=${productNumericId}`, + ); + } + } + }, [shopify, sellingPlansFetcher]); + + const sellingPlanOptions = [ + {label: 'None (one-time only)', value: ''}, + ...availableSellingPlans.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} + + + + + + ) : ( + + + + + No product selected + + + + + + )} + + + + + + + {productInfo && ( + loadingSellingPlans ? ( + + + Subscription selling plan + + + + + Loading selling plans… + + + + ) : ( + { + setSelectedPlan(value); + setBuyerConsent(false); + }} + options={purchaseOption.sellingPlanOptions.map((opt) => ({ + label: opt.label, + value: opt.value, + }))} + /> + )} + + {/* Recurring charge info */} + {isSubscription && ( + + + {formatCurrency(discountedPrice)} billed every {intervalLabel} + + + )} + + + + + + + + + + + {/* Buyer consent for subscriptions — required */} + {isSubscription && ( + + )} + + + + + + + + + ); +} + +function PriceHeader({ discountedPrice, originalPrice, loading }) { + return ( + + + {!loading && formatCurrency(originalPrice)} + + + {" "} + {!loading && formatCurrency(discountedPrice)} + + + ); +} + +function ProductDescription({ textLines }) { + return ( + + {textLines.map((text, index) => ( + + {text} + + ))} + + ); +} + +function MoneyLine({ label, amount, loading = false }) { + return ( + + {label} + + + {loading ? "-" : formatCurrency(amount)} + + + + ); +} + +function MoneySummary({ label, amount }) { + return ( + + + {label} + + + + {formatCurrency(amount)} + + + + ); +} + +function formatCurrency(amount) { + if (!amount || amount === "0.00") { + return "Free"; + } + return `$${amount}`; +} diff --git a/prisma/migrations/20260212215325_add_post_purchase_offer/migration.sql b/prisma/migrations/20260212215325_add_post_purchase_offer/migration.sql new file mode 100644 index 0000000..080eaed --- /dev/null +++ b/prisma/migrations/20260212215325_add_post_purchase_offer/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "PostPurchaseOffer" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "shop" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "variantId" TEXT NOT NULL DEFAULT '', + "discountPercent" INTEGER NOT NULL DEFAULT 10, + "sellingPlanId" TEXT NOT NULL DEFAULT '', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "PostPurchaseOffer_shop_key" ON "PostPurchaseOffer"("shop"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 2a5a444..e5e5c47 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (e.g., Git) -provider = "sqlite" +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc753a9..1ced91f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,3 +51,14 @@ model DunningTracker { @@unique([shop, contractId, billingCycleIndex, failureReason], name: "uniqueBillingCycleFailure") @@index([completedAt]) } + +model PostPurchaseOffer { + id Int @id @default(autoincrement()) + shop String @unique + enabled Boolean @default(false) + variantId String @default("") + discountPercent Int @default(10) + sellingPlanId String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +}