From f57608478501d979945ef2ac7fb09de2e8b2ab2a Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 23 Mar 2026 05:00:17 +0700 Subject: [PATCH 1/2] Fix modal positioning by preventing animation transforms from creating containing blocks animation-fill-mode: both retains transform values after completion, which creates a new CSS containing block on ancestor elements. This causes position: fixed modals to be sized relative to page content instead of the viewport. Changed fill-mode to backwards (styles only applied before animation starts, removed after) and keyframe end values to transform: none. --- src/components/AppDetailPage.jsx | 2 +- src/components/BuildDetailPage.jsx | 2 +- src/components/ProductsPage.jsx | 2 +- src/components/VersionDetailPage.jsx | 2 +- src/components/XcodeCloudPage.jsx | 2 +- src/main.css | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/AppDetailPage.jsx b/src/components/AppDetailPage.jsx index 4ed129d..2719cbd 100644 --- a/src/components/AppDetailPage.jsx +++ b/src/components/AppDetailPage.jsx @@ -45,7 +45,7 @@ export default function AppDetailPage({ app, accounts, isMobile, onSelectVersion ]; return ( -
+
{/* Back navigation bar */}
diff --git a/src/components/BuildDetailPage.jsx b/src/components/BuildDetailPage.jsx index 86e62f7..e775a50 100644 --- a/src/components/BuildDetailPage.jsx +++ b/src/components/BuildDetailPage.jsx @@ -139,7 +139,7 @@ export default function BuildDetailPage({ app, buildRun, accounts, isMobile }) { const commitSha = buildRun.sourceCommit?.commitSha; return ( -
+
{/* Breadcrumb */}
diff --git a/src/components/ProductsPage.jsx b/src/components/ProductsPage.jsx index a8fe340..45ebf02 100644 --- a/src/components/ProductsPage.jsx +++ b/src/components/ProductsPage.jsx @@ -88,7 +88,7 @@ export default function ProductsPage({ app, accounts, isMobile }) { const nonRenewing = iaps.filter((i) => i.type === "NON_RENEWING_SUBSCRIPTION"); return ( -
+
{/* Back navigation bar */}
diff --git a/src/components/VersionDetailPage.jsx b/src/components/VersionDetailPage.jsx index 2e78f51..03673e3 100644 --- a/src/components/VersionDetailPage.jsx +++ b/src/components/VersionDetailPage.jsx @@ -105,7 +105,7 @@ export default function VersionDetailPage({ app, version, accounts, isMobile }) ] : []; return ( -
+
{/* Breadcrumb bar */}
diff --git a/src/components/XcodeCloudPage.jsx b/src/components/XcodeCloudPage.jsx index 2c94df4..67599fe 100644 --- a/src/components/XcodeCloudPage.jsx +++ b/src/components/XcodeCloudPage.jsx @@ -98,7 +98,7 @@ export default function XcodeCloudPage({ app, accounts, isMobile, onSelectBuild useEffect(() => { loadData(); }, [loadData]); return ( -
+
{/* Back navigation bar */}
diff --git a/src/main.css b/src/main.css index ac856b3..c2b35c7 100644 --- a/src/main.css +++ b/src/main.css @@ -43,12 +43,12 @@ @keyframes asc-slidein { from { opacity: 0; transform: translateX(20px); } - to { opacity: 1; transform: translateX(0); } + to { opacity: 1; transform: none; } } @keyframes asc-fadein { from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: translateY(0); } + to { opacity: 1; transform: none; } } @layer base { From a7ed1fe22f3e18f4b613f35f7e81de0ae502fb1f Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 23 Mar 2026 05:03:41 +0700 Subject: [PATCH 2/2] Add IAP and subscription pricing management Adds per-territory price viewing and editing for in-app purchases and subscriptions. Includes backend pricing routes with ASC API integration, a PricingPanel component embedded in the product edit modal, and a territory reference dataset. --- server/index.js | 2 + server/routes/pricing.js | 372 ++++++++++++++++++++++++++++++++ src/api/index.js | 56 +++++ src/components/PricingPanel.jsx | 345 +++++++++++++++++++++++++++++ src/components/ProductModal.jsx | 19 +- src/lib/territories.js | 190 ++++++++++++++++ 6 files changed, 981 insertions(+), 3 deletions(-) create mode 100644 server/routes/pricing.js create mode 100644 src/components/PricingPanel.jsx create mode 100644 src/lib/territories.js diff --git a/server/index.js b/server/index.js index 6fd33da..692e421 100644 --- a/server/index.js +++ b/server/index.js @@ -3,6 +3,7 @@ import accountsRouter from "./routes/accounts.js"; import appsRouter from "./routes/apps.js"; import productsRouter from "./routes/products.js"; import xcodeCloudRouter from "./routes/xcode-cloud.js"; +import pricingRouter from "./routes/pricing.js"; const app = express(); @@ -11,6 +12,7 @@ app.use(express.json()); app.use("/api/accounts", accountsRouter); app.use("/api/apps", appsRouter); app.use("/api/apps", productsRouter); +app.use("/api/apps", pricingRouter); app.use("/api/apps", xcodeCloudRouter); const PORT = process.env.SERVER_PORT || 3001; diff --git a/server/routes/pricing.js b/server/routes/pricing.js new file mode 100644 index 0000000..d7832a3 --- /dev/null +++ b/server/routes/pricing.js @@ -0,0 +1,372 @@ +import { Router } from "express"; +import { getAccounts } from "../lib/account-store.js"; +import { ascFetch } from "../lib/asc-client.js"; +import { apiCache } from "../lib/cache.js"; + +const router = Router(); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function resolveAccount(req, res) { + const accountId = req.query.accountId || req.body?.accountId; + const accounts = getAccounts(); + const account = accounts.find((a) => a.id === accountId) || accounts[0]; + if (!account) { + res.status(400).json({ error: "No accounts configured" }); + return null; + } + return account; +} + +async function fetchAllPages(account, path, maxPages = 20) { + const allData = []; + const allIncluded = []; + let url = path; + let page = 0; + + while (url) { + if (page >= maxPages) break; + const result = await ascFetch(account, url); + if (result.data) allData.push(...result.data); + if (result.included) allIncluded.push(...result.included); + page++; + url = result.links?.next || null; + if (url) { + // ASC returns full URLs for pagination; strip the base + url = url.replace("https://api.appstoreconnect.apple.com", ""); + } + } + + return { data: allData, included: allIncluded }; +} + +// ── IAP Prices ────────────────────────────────────────────────────────────── + +// Get current prices for an IAP (fast — only fetches schedule, not all price points) +router.get("/:appId/iap/:iapId/prices", async (req, res) => { + const { iapId } = req.params; + const account = resolveAccount(req, res); + if (!account) return; + + const cacheKey = `iap:prices:${iapId}:${account.id}`; + const cached = apiCache.get(cacheKey); + if (cached) return res.json(cached); + + try { + const scheduleRes = await ascFetch( + account, + `/v1/inAppPurchases/${iapId}/inAppPurchasePriceSchedule?include=manualPrices,baseTerritory` + ).catch(() => ({ data: null, included: [] })); + + // Extract base territory + const baseTerritoryData = (scheduleRes.included || []).find( + (i) => i.type === "territories" + ); + const baseTerritory = baseTerritoryData?.id || null; + + // Build map of current prices from manual prices in schedule + const manualPrices = (scheduleRes.included || []).filter( + (i) => i.type === "inAppPurchasePrices" + ); + + // Collect price point IDs we need to resolve customerPrice for + const pricePointIds = new Set(); + const currentPrices = {}; + + for (const mp of manualPrices) { + const territoryId = mp.relationships?.territory?.data?.id; + const pricePointId = mp.relationships?.inAppPurchasePricePoint?.data?.id; + if (territoryId && pricePointId) { + currentPrices[territoryId] = { pricePointId, customerPrice: null }; + pricePointIds.add(pricePointId); + } + } + + // Resolve customerPrice from included inAppPurchasePricePoints + for (const inc of scheduleRes.included || []) { + if (inc.type === "inAppPurchasePricePoints" && pricePointIds.has(inc.id)) { + for (const entry of Object.values(currentPrices)) { + if (entry.pricePointId === inc.id) { + entry.customerPrice = inc.attributes?.customerPrice || null; + } + } + } + } + + const result = { baseTerritory, currentPrices }; + + apiCache.set(cacheKey, result); + res.json(result); + } catch (err) { + console.error(`Failed to fetch IAP prices for ${iapId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + +// Get available price points for a specific territory (lazy-loaded by frontend) +router.get("/:appId/iap/:iapId/price-points", async (req, res) => { + const { iapId } = req.params; + const territory = req.query.territory; + const account = resolveAccount(req, res); + if (!account) return; + + if (!territory) { + return res.status(400).json({ error: "territory query parameter is required" }); + } + + const cacheKey = `iap:price-points:${iapId}:${territory}:${account.id}`; + const cached = apiCache.get(cacheKey); + if (cached) return res.json(cached); + + try { + const ppRes = await fetchAllPages( + account, + `/v1/inAppPurchases/${iapId}/pricePoints?filter[territory]=${territory}&include=territory&limit=200` + ); + + const pricePoints = (ppRes.data || []).map((pp) => ({ + id: pp.id, + customerPrice: pp.attributes?.customerPrice || "0", + proceeds: pp.attributes?.proceeds || "0", + })).sort((a, b) => parseFloat(a.customerPrice) - parseFloat(b.customerPrice)); + + const result = { pricePoints }; + apiCache.set(cacheKey, result); + res.json(result); + } catch (err) { + console.error(`Failed to fetch IAP price points for ${iapId}/${territory}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + +router.post("/:appId/iap/:iapId/prices", async (req, res) => { + const { iapId } = req.params; + const { accountId, baseTerritory, manualPrices } = req.body; + + if (!accountId || !baseTerritory || !manualPrices) { + return res + .status(400) + .json({ error: "accountId, baseTerritory, and manualPrices are required" }); + } + + const accounts = getAccounts(); + const account = accounts.find((a) => a.id === accountId); + if (!account) return res.status(400).json({ error: "Account not found" }); + + try { + // Build the included array with client-generated IDs + const included = manualPrices.map((mp, index) => ({ + type: "inAppPurchasePrices", + id: `price-${mp.territory}-${index}`, + attributes: { + startDate: null, + }, + relationships: { + inAppPurchaseV2: { + data: { type: "inAppPurchases", id: iapId }, + }, + inAppPurchasePricePoint: { + data: { + type: "inAppPurchasePricePoints", + id: mp.pricePointId, + }, + }, + }, + })); + + const body = { + data: { + type: "inAppPurchasePriceSchedules", + relationships: { + inAppPurchase: { + data: { type: "inAppPurchases", id: iapId }, + }, + baseTerritory: { + data: { type: "territories", id: baseTerritory }, + }, + manualPrices: { + data: included.map((inc) => ({ + type: inc.type, + id: inc.id, + })), + }, + }, + }, + included, + }; + + await ascFetch(account, "/v1/inAppPurchasePriceSchedules", { + method: "POST", + body, + }); + + apiCache.deleteByPrefix(`iap:prices:${iapId}:`); + res.json({ success: true }); + } catch (err) { + console.error(`Failed to set IAP prices for ${iapId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + +// ── Subscription Prices ───────────────────────────────────────────────────── + +// Get current prices for a subscription (fast — only fetches current prices, not all price points) +router.get( + "/:appId/subscription-groups/:groupId/subscriptions/:subId/prices", + async (req, res) => { + const { subId } = req.params; + const account = resolveAccount(req, res); + if (!account) return; + + const cacheKey = `sub:prices:${subId}:${account.id}`; + const cached = apiCache.get(cacheKey); + if (cached) return res.json(cached); + + try { + const currentPricesRes = await fetchAllPages( + account, + `/v1/subscriptions/${subId}/prices?include=subscriptionPricePoint,territory&limit=200` + ); + + // Map current price point IDs to their prices from included + const pricePointDetailMap = new Map(); + for (const inc of currentPricesRes.included || []) { + if (inc.type === "subscriptionPricePoints") { + pricePointDetailMap.set(inc.id, { + customerPrice: inc.attributes?.customerPrice || "0", + proceeds: inc.attributes?.proceeds || "0", + }); + } + } + + // Build current prices map: territory -> { pricePointId, customerPrice } + const currentPrices = {}; + for (const price of currentPricesRes.data || []) { + const territoryId = price.relationships?.territory?.data?.id; + const pricePointId = + price.relationships?.subscriptionPricePoint?.data?.id; + if (territoryId && pricePointId) { + const detail = pricePointDetailMap.get(pricePointId); + currentPrices[territoryId] = { + pricePointId, + customerPrice: detail?.customerPrice || null, + }; + } + } + + const result = { baseTerritory: null, currentPrices }; + + apiCache.set(cacheKey, result); + res.json(result); + } catch (err) { + console.error( + `Failed to fetch subscription prices for ${subId}:`, + err.message + ); + res.status(502).json({ error: err.message }); + } + } +); + +// Get available price points for a specific territory (lazy-loaded by frontend) +router.get( + "/:appId/subscription-groups/:groupId/subscriptions/:subId/price-points", + async (req, res) => { + const { subId } = req.params; + const territory = req.query.territory; + const account = resolveAccount(req, res); + if (!account) return; + + if (!territory) { + return res.status(400).json({ error: "territory query parameter is required" }); + } + + const cacheKey = `sub:price-points:${subId}:${territory}:${account.id}`; + const cached = apiCache.get(cacheKey); + if (cached) return res.json(cached); + + try { + const ppRes = await fetchAllPages( + account, + `/v1/subscriptions/${subId}/pricePoints?filter[territory]=${territory}&include=territory&limit=200` + ); + + const pricePoints = (ppRes.data || []).map((pp) => ({ + id: pp.id, + customerPrice: pp.attributes?.customerPrice || "0", + proceeds: pp.attributes?.proceeds || "0", + })).sort((a, b) => parseFloat(a.customerPrice) - parseFloat(b.customerPrice)); + + const result = { pricePoints }; + apiCache.set(cacheKey, result); + res.json(result); + } catch (err) { + console.error(`Failed to fetch subscription price points for ${subId}/${territory}:`, err.message); + res.status(502).json({ error: err.message }); + } + } +); + +router.post( + "/:appId/subscription-groups/:groupId/subscriptions/:subId/prices", + async (req, res) => { + const { subId } = req.params; + const { accountId, prices } = req.body; + + if (!accountId || !prices || !Array.isArray(prices)) { + return res + .status(400) + .json({ error: "accountId and prices array are required" }); + } + + const accounts = getAccounts(); + const account = accounts.find((a) => a.id === accountId); + if (!account) return res.status(400).json({ error: "Account not found" }); + + const results = await Promise.allSettled( + prices.map((p) => + ascFetch(account, "/v1/subscriptionPrices", { + method: "POST", + body: { + data: { + type: "subscriptionPrices", + attributes: { + startDate: null, + preserveCurrentPrice: false, + }, + relationships: { + subscription: { + data: { type: "subscriptions", id: subId }, + }, + subscriptionPricePoint: { + data: { + type: "subscriptionPricePoints", + id: p.pricePointId, + }, + }, + }, + }, + }, + }) + ) + ); + + const errors = []; + let saved = 0; + results.forEach((r, i) => { + if (r.status === "fulfilled") { + saved++; + } else { + errors.push({ + territory: prices[i].territory, + message: r.reason?.message || "Unknown error", + }); + } + }); + + apiCache.deleteByPrefix(`sub:prices:${subId}:`); + res.json({ saved, errors }); + } +); + +export default router; diff --git a/src/api/index.js b/src/api/index.js index 2ab72a9..fad5a18 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -329,6 +329,62 @@ export async function deleteSubscriptionLocalization(appId, groupId, subId, locI return res.json(); } +// ── Pricing ───────────────────────────────────────────────────────────────── + +export async function fetchIAPPrices(appId, iapId, accountId) { + const params = new URLSearchParams({ accountId }); + const res = await fetch(`/api/apps/${appId}/iap/${iapId}/prices?${params}`); + if (!res.ok) throw new Error(`Failed to fetch IAP prices: ${res.status}`); + return res.json(); +} + +export async function setIAPPrices(appId, iapId, { accountId, baseTerritory, manualPrices }) { + const res = await fetch(`/api/apps/${appId}/iap/${iapId}/prices`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId, baseTerritory, manualPrices }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to set IAP prices: ${res.status}`); + } + return res.json(); +} + +export async function fetchSubscriptionPrices(appId, groupId, subId, accountId) { + const params = new URLSearchParams({ accountId }); + const res = await fetch(`/api/apps/${appId}/subscription-groups/${groupId}/subscriptions/${subId}/prices?${params}`); + if (!res.ok) throw new Error(`Failed to fetch subscription prices: ${res.status}`); + return res.json(); +} + +export async function fetchIAPPricePoints(appId, iapId, territory, accountId) { + const params = new URLSearchParams({ territory, accountId }); + const res = await fetch(`/api/apps/${appId}/iap/${iapId}/price-points?${params}`); + if (!res.ok) throw new Error(`Failed to fetch IAP price points: ${res.status}`); + return res.json(); +} + +export async function fetchSubscriptionPricePoints(appId, groupId, subId, territory, accountId) { + const params = new URLSearchParams({ territory, accountId }); + const res = await fetch(`/api/apps/${appId}/subscription-groups/${groupId}/subscriptions/${subId}/price-points?${params}`); + if (!res.ok) throw new Error(`Failed to fetch subscription price points: ${res.status}`); + return res.json(); +} + +export async function setSubscriptionPrices(appId, groupId, subId, { accountId, prices }) { + const res = await fetch(`/api/apps/${appId}/subscription-groups/${groupId}/subscriptions/${subId}/prices`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId, prices }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to set subscription prices: ${res.status}`); + } + return res.json(); +} + // ── Xcode Cloud ───────────────────────────────────────────────────────────── export async function fetchBuildActions(appId, buildId, accountId) { diff --git a/src/components/PricingPanel.jsx b/src/components/PricingPanel.jsx new file mode 100644 index 0000000..650be13 --- /dev/null +++ b/src/components/PricingPanel.jsx @@ -0,0 +1,345 @@ +import { useState, useEffect, useCallback } from "react"; +import { + fetchIAPPrices, setIAPPrices, fetchIAPPricePoints, + fetchSubscriptionPrices, setSubscriptionPrices, fetchSubscriptionPricePoints, +} from "../api/index.js"; +import { TERRITORIES, TERRITORY_MAP } from "../lib/territories.js"; + +const inputCls = "w-full px-3.5 py-2.5 bg-dark-surface border border-dark-border-light rounded-lg text-dark-text outline-none font-sans text-[13px] transition-colors"; + +export default function PricingPanel({ productType, productId, groupId, appId, accountId }) { + const [expanded, setExpanded] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [pricingData, setPricingData] = useState(null); + const [pendingChanges, setPendingChanges] = useState(new Map()); + const [saving, setSaving] = useState(false); + const [saveResult, setSaveResult] = useState(null); + const [search, setSearch] = useState(""); + + // Cache of loaded price points per territory + const [pricePointsCache, setPricePointsCache] = useState(new Map()); + const [loadingTerritories, setLoadingTerritories] = useState(new Set()); + + const isIAP = productType === "iap"; + + // Lazy-load current prices on first expand + useEffect(() => { + if (!expanded || pricingData) return; + let cancelled = false; + setLoading(true); + setError(null); + + const fetchFn = isIAP + ? fetchIAPPrices(appId, productId, accountId) + : fetchSubscriptionPrices(appId, groupId, productId, accountId); + + fetchFn + .then((data) => { if (!cancelled) setPricingData(data); }) + .catch((err) => { if (!cancelled) setError(err.message); }) + .finally(() => { if (!cancelled) setLoading(false); }); + + return () => { cancelled = true; }; + }, [expanded, pricingData, isIAP, appId, productId, groupId, accountId]); + + // Load price points for a specific territory on demand + const loadPricePoints = useCallback(async (territory) => { + if (pricePointsCache.has(territory)) return; + if (loadingTerritories.has(territory)) return; + + setLoadingTerritories((prev) => new Set(prev).add(territory)); + + try { + const result = isIAP + ? await fetchIAPPricePoints(appId, productId, territory, accountId) + : await fetchSubscriptionPricePoints(appId, groupId, productId, territory, accountId); + + setPricePointsCache((prev) => { + const next = new Map(prev); + next.set(territory, result.pricePoints || []); + return next; + }); + } catch { + // Silently fail — dropdown will show current price only + } finally { + setLoadingTerritories((prev) => { + const next = new Set(prev); + next.delete(territory); + return next; + }); + } + }, [isIAP, appId, productId, groupId, accountId, pricePointsCache, loadingTerritories]); + + function handlePriceChange(territory, pricePointId) { + setPendingChanges((prev) => { + const next = new Map(prev); + const currentEntry = pricingData?.currentPrices?.[territory]; + // If selecting back to original, remove from pending + if (currentEntry?.pricePointId === pricePointId) { + next.delete(territory); + } else { + next.set(territory, pricePointId); + } + return next; + }); + setSaveResult(null); + } + + async function handleApply() { + if (pendingChanges.size === 0) return; + setSaving(true); + setSaveResult(null); + + try { + if (isIAP) { + // IAP: build complete manualPrices array (current + changes merged) + const allPrices = TERRITORIES.map((t) => { + const current = pricingData?.currentPrices?.[t.code]; + return { + territory: t.code, + pricePointId: pendingChanges.has(t.code) + ? pendingChanges.get(t.code) + : current?.pricePointId, + }; + }).filter((p) => p.pricePointId); + + await setIAPPrices(appId, productId, { + accountId, + baseTerritory: pricingData?.baseTerritory || "USA", + manualPrices: allPrices, + }); + + setSaveResult({ saved: pendingChanges.size, errors: [] }); + } else { + // Subscription: only send changed territories + const prices = Array.from(pendingChanges.entries()).map( + ([territory, pricePointId]) => ({ territory, pricePointId }) + ); + + const result = await setSubscriptionPrices(appId, groupId, productId, { + accountId, + prices, + }); + + setSaveResult(result); + + // Keep failed territories in pendingChanges for retry + if (result.errors?.length > 0) { + const failedTerritories = new Set( + result.errors.map((e) => e.territory) + ); + setPendingChanges((prev) => { + const next = new Map(); + for (const [k, v] of prev) { + if (failedTerritories.has(k)) next.set(k, v); + } + return next; + }); + // Re-fetch to update saved prices + setPricingData(null); + setPricePointsCache(new Map()); + return; + } + } + + // On full success: re-fetch and clear pending + setPendingChanges(new Map()); + setPricingData(null); + setPricePointsCache(new Map()); + } catch (err) { + setSaveResult({ saved: 0, errors: [{ territory: "all", message: err.message }] }); + } finally { + setSaving(false); + } + } + + // Build territory list from TERRITORY_MAP + currentPrices + const territories = TERRITORIES.map((t) => { + const current = pricingData?.currentPrices?.[t.code]; + return { + territory: t.code, + currency: t.currency, + currentPricePointId: current?.pricePointId || null, + currentCustomerPrice: current?.customerPrice || null, + }; + }); + + // Filter territories by search + const filteredTerritories = territories.filter((t) => { + if (!search.trim()) return true; + const q = search.toLowerCase(); + const meta = TERRITORY_MAP.get(t.territory); + return ( + t.territory.toLowerCase().includes(q) || + t.currency.toLowerCase().includes(q) || + (meta?.name || "").toLowerCase().includes(q) + ); + }); + + return ( +
+ + + {expanded && ( +
+ {loading && ( +
+
{"\u21bb"}
+
+ )} + + {error && ( +
{error}
+ )} + + {!loading && !error && pricingData && ( + <> + {/* Search filter */} + setSearch(e.target.value)} + placeholder="Filter territories..." + /> + + {/* Territory table */} +
+ + + + + + + + + + + {filteredTerritories.map((t) => { + const isPending = pendingChanges.has(t.territory); + const selectedValue = isPending + ? pendingChanges.get(t.territory) + : t.currentPricePointId || ""; + const cachedPoints = pricePointsCache.get(t.territory); + const isLoadingPoints = loadingTerritories.has(t.territory); + + return ( + + + + + + + ); + })} + {filteredTerritories.length === 0 && ( + + + + )} + +
TerritoryCurrencyCurrentPrice Tier
+ {t.territory} + + {TERRITORY_MAP.get(t.territory)?.name || ""} + + {t.currency} + {t.currentCustomerPrice + ? `${t.currency} ${t.currentCustomerPrice}` + : "Not set"} + + +
+ No territories match your filter +
+
+ + {/* Apply button */} + {pendingChanges.size > 0 && ( + + )} + + {/* Save result */} + {saveResult && ( +
0 ? "text-danger" : "text-green-400"}`}> + {saveResult.errors?.length > 0 ? ( + <> +
Saved {saveResult.saved} of {saveResult.saved + saveResult.errors.length} territories
+ {saveResult.errors.map((e, i) => ( +
{e.territory}: {e.message}
+ ))} + + ) : ( +
Saved {saveResult.saved} price{saveResult.saved !== 1 ? "s" : ""} successfully
+ )} +
+ )} + + )} +
+ )} +
+ ); +} diff --git a/src/components/ProductModal.jsx b/src/components/ProductModal.jsx index 4f9b610..6ed330a 100644 --- a/src/components/ProductModal.jsx +++ b/src/components/ProductModal.jsx @@ -7,6 +7,7 @@ import { fetchSubscriptionLocalizations, createSubscriptionLocalization, updateSubscriptionLocalization, deleteSubscriptionLocalization, } from "../api/index.js"; import { IAP_TYPES, SUBSCRIPTION_PERIODS } from "../constants/index.js"; +import PricingPanel from "./PricingPanel.jsx"; const labelCls = "text-[11px] uppercase tracking-wide font-semibold text-dark-dim mb-1.5 block"; const inputCls = "w-full px-3.5 py-2.5 bg-dark-surface border border-dark-border-light rounded-lg text-dark-text outline-none font-sans text-[13px] transition-colors"; @@ -72,6 +73,7 @@ export default function ProductModal({ mode, target, appId, accountId, isMobile, const isSub = mode.includes("sub"); const isGroup = mode.includes("group"); const showLocs = isEditMode && (isIAP || isSub); + const showPricing = isEditMode && (isIAP || isSub); // Lazy-load localizations useEffect(() => { @@ -179,10 +181,10 @@ export default function ProductModal({ mode, target, appId, accountId, isMobile,
e.stopPropagation()} style={{ animation: "asc-fadein 0.3s ease" }} - className={`bg-dark-card border border-dark-border-light w-full overflow-y-auto shadow-[0_32px_64px_rgba(0,0,0,0.15)] ${ + className={`bg-dark-card border border-dark-border-light w-full shadow-[0_32px_64px_rgba(0,0,0,0.15)] ${ isMobile - ? "rounded-t-2xl max-w-full max-h-[90vh]" - : "rounded-2xl max-w-[540px] max-h-[85vh]" + ? "rounded-t-2xl max-w-full max-h-[90vh] overflow-y-auto" + : "rounded-2xl max-w-[540px] max-h-[85vh] overflow-y-auto" }`} > {/* Header */} @@ -431,6 +433,17 @@ export default function ProductModal({ mode, target, appId, accountId, isMobile, )}
)} + + {/* Pricing panel (edit modes only) */} + {showPricing && ( + + )}
{saveError && ( diff --git a/src/lib/territories.js b/src/lib/territories.js new file mode 100644 index 0000000..3ddb7d5 --- /dev/null +++ b/src/lib/territories.js @@ -0,0 +1,190 @@ +// App Store Connect territory codes, names, and currencies. +// Source: Apple App Store Connect API territory list. + +export const TERRITORIES = [ + { code: "ABW", name: "Aruba", currency: "USD" }, + { code: "AFG", name: "Afghanistan", currency: "USD" }, + { code: "AGO", name: "Angola", currency: "USD" }, + { code: "AIA", name: "Anguilla", currency: "USD" }, + { code: "ALB", name: "Albania", currency: "USD" }, + { code: "AND", name: "Andorra", currency: "EUR" }, + { code: "ANT", name: "Netherlands Antilles", currency: "USD" }, + { code: "ARE", name: "United Arab Emirates", currency: "AED" }, + { code: "ARG", name: "Argentina", currency: "USD" }, + { code: "ARM", name: "Armenia", currency: "USD" }, + { code: "ATG", name: "Antigua and Barbuda", currency: "USD" }, + { code: "AUS", name: "Australia", currency: "AUD" }, + { code: "AUT", name: "Austria", currency: "EUR" }, + { code: "AZE", name: "Azerbaijan", currency: "USD" }, + { code: "BEL", name: "Belgium", currency: "EUR" }, + { code: "BEN", name: "Benin", currency: "USD" }, + { code: "BFA", name: "Burkina Faso", currency: "USD" }, + { code: "BGD", name: "Bangladesh", currency: "USD" }, + { code: "BGR", name: "Bulgaria", currency: "BGN" }, + { code: "BHR", name: "Bahrain", currency: "USD" }, + { code: "BHS", name: "Bahamas", currency: "USD" }, + { code: "BIH", name: "Bosnia and Herzegovina", currency: "USD" }, + { code: "BLR", name: "Belarus", currency: "USD" }, + { code: "BLZ", name: "Belize", currency: "USD" }, + { code: "BMU", name: "Bermuda", currency: "USD" }, + { code: "BOL", name: "Bolivia", currency: "USD" }, + { code: "BRA", name: "Brazil", currency: "BRL" }, + { code: "BRB", name: "Barbados", currency: "USD" }, + { code: "BRN", name: "Brunei", currency: "USD" }, + { code: "BTN", name: "Bhutan", currency: "USD" }, + { code: "BWA", name: "Botswana", currency: "USD" }, + { code: "CAF", name: "Central African Republic", currency: "USD" }, + { code: "CAN", name: "Canada", currency: "CAD" }, + { code: "CHE", name: "Switzerland", currency: "CHF" }, + { code: "CHL", name: "Chile", currency: "CLP" }, + { code: "CHN", name: "China mainland", currency: "CNY" }, + { code: "CIV", name: "Cote d'Ivoire", currency: "USD" }, + { code: "CMR", name: "Cameroon", currency: "USD" }, + { code: "COD", name: "Congo, Democratic Republic", currency: "USD" }, + { code: "COG", name: "Congo, Republic", currency: "USD" }, + { code: "COL", name: "Colombia", currency: "COP" }, + { code: "CPV", name: "Cape Verde", currency: "USD" }, + { code: "CRI", name: "Costa Rica", currency: "USD" }, + { code: "CYM", name: "Cayman Islands", currency: "USD" }, + { code: "CYP", name: "Cyprus", currency: "EUR" }, + { code: "CZE", name: "Czech Republic", currency: "CZK" }, + { code: "DEU", name: "Germany", currency: "EUR" }, + { code: "DMA", name: "Dominica", currency: "USD" }, + { code: "DNK", name: "Denmark", currency: "DKK" }, + { code: "DOM", name: "Dominican Republic", currency: "USD" }, + { code: "DZA", name: "Algeria", currency: "USD" }, + { code: "ECU", name: "Ecuador", currency: "USD" }, + { code: "EGY", name: "Egypt", currency: "EGP" }, + { code: "ESP", name: "Spain", currency: "EUR" }, + { code: "EST", name: "Estonia", currency: "EUR" }, + { code: "ETH", name: "Ethiopia", currency: "USD" }, + { code: "FIN", name: "Finland", currency: "EUR" }, + { code: "FJI", name: "Fiji", currency: "USD" }, + { code: "FRA", name: "France", currency: "EUR" }, + { code: "FSM", name: "Micronesia", currency: "USD" }, + { code: "GAB", name: "Gabon", currency: "USD" }, + { code: "GBR", name: "United Kingdom", currency: "GBP" }, + { code: "GEO", name: "Georgia", currency: "USD" }, + { code: "GHA", name: "Ghana", currency: "USD" }, + { code: "GIN", name: "Guinea", currency: "USD" }, + { code: "GMB", name: "Gambia", currency: "USD" }, + { code: "GNB", name: "Guinea-Bissau", currency: "USD" }, + { code: "GRC", name: "Greece", currency: "EUR" }, + { code: "GRD", name: "Grenada", currency: "USD" }, + { code: "GTM", name: "Guatemala", currency: "USD" }, + { code: "GUY", name: "Guyana", currency: "USD" }, + { code: "HKG", name: "Hong Kong", currency: "HKD" }, + { code: "HND", name: "Honduras", currency: "USD" }, + { code: "HRV", name: "Croatia", currency: "EUR" }, + { code: "HUN", name: "Hungary", currency: "HUF" }, + { code: "IDN", name: "Indonesia", currency: "IDR" }, + { code: "IND", name: "India", currency: "INR" }, + { code: "IRL", name: "Ireland", currency: "EUR" }, + { code: "IRQ", name: "Iraq", currency: "USD" }, + { code: "ISL", name: "Iceland", currency: "USD" }, + { code: "ISR", name: "Israel", currency: "ILS" }, + { code: "ITA", name: "Italy", currency: "EUR" }, + { code: "JAM", name: "Jamaica", currency: "USD" }, + { code: "JOR", name: "Jordan", currency: "USD" }, + { code: "JPN", name: "Japan", currency: "JPY" }, + { code: "KAZ", name: "Kazakhstan", currency: "KZT" }, + { code: "KEN", name: "Kenya", currency: "KES" }, + { code: "KGZ", name: "Kyrgyzstan", currency: "USD" }, + { code: "KHM", name: "Cambodia", currency: "USD" }, + { code: "KNA", name: "St. Kitts and Nevis", currency: "USD" }, + { code: "KOR", name: "South Korea", currency: "KRW" }, + { code: "KWT", name: "Kuwait", currency: "USD" }, + { code: "LAO", name: "Laos", currency: "USD" }, + { code: "LBN", name: "Lebanon", currency: "USD" }, + { code: "LBR", name: "Liberia", currency: "USD" }, + { code: "LBY", name: "Libya", currency: "USD" }, + { code: "LCA", name: "St. Lucia", currency: "USD" }, + { code: "LKA", name: "Sri Lanka", currency: "USD" }, + { code: "LTU", name: "Lithuania", currency: "EUR" }, + { code: "LUX", name: "Luxembourg", currency: "EUR" }, + { code: "LVA", name: "Latvia", currency: "EUR" }, + { code: "MAC", name: "Macao", currency: "USD" }, + { code: "MAR", name: "Morocco", currency: "USD" }, + { code: "MDA", name: "Moldova", currency: "USD" }, + { code: "MDG", name: "Madagascar", currency: "USD" }, + { code: "MDV", name: "Maldives", currency: "USD" }, + { code: "MEX", name: "Mexico", currency: "MXN" }, + { code: "MKD", name: "North Macedonia", currency: "USD" }, + { code: "MLI", name: "Mali", currency: "USD" }, + { code: "MLT", name: "Malta", currency: "EUR" }, + { code: "MMR", name: "Myanmar", currency: "USD" }, + { code: "MNE", name: "Montenegro", currency: "EUR" }, + { code: "MNG", name: "Mongolia", currency: "USD" }, + { code: "MOZ", name: "Mozambique", currency: "USD" }, + { code: "MRT", name: "Mauritania", currency: "USD" }, + { code: "MSR", name: "Montserrat", currency: "USD" }, + { code: "MUS", name: "Mauritius", currency: "USD" }, + { code: "MWI", name: "Malawi", currency: "USD" }, + { code: "MYS", name: "Malaysia", currency: "MYR" }, + { code: "NAM", name: "Namibia", currency: "USD" }, + { code: "NER", name: "Niger", currency: "USD" }, + { code: "NGA", name: "Nigeria", currency: "NGN" }, + { code: "NIC", name: "Nicaragua", currency: "USD" }, + { code: "NLD", name: "Netherlands", currency: "EUR" }, + { code: "NOR", name: "Norway", currency: "NOK" }, + { code: "NPL", name: "Nepal", currency: "USD" }, + { code: "NRU", name: "Nauru", currency: "USD" }, + { code: "NZL", name: "New Zealand", currency: "NZD" }, + { code: "OMN", name: "Oman", currency: "USD" }, + { code: "PAK", name: "Pakistan", currency: "PKR" }, + { code: "PAN", name: "Panama", currency: "USD" }, + { code: "PER", name: "Peru", currency: "PEN" }, + { code: "PHL", name: "Philippines", currency: "PHP" }, + { code: "PLW", name: "Palau", currency: "USD" }, + { code: "PNG", name: "Papua New Guinea", currency: "USD" }, + { code: "POL", name: "Poland", currency: "PLN" }, + { code: "PRT", name: "Portugal", currency: "EUR" }, + { code: "PRY", name: "Paraguay", currency: "USD" }, + { code: "PSE", name: "Palestinian Authority", currency: "USD" }, + { code: "QAT", name: "Qatar", currency: "QAR" }, + { code: "ROU", name: "Romania", currency: "RON" }, + { code: "RUS", name: "Russia", currency: "RUB" }, + { code: "RWA", name: "Rwanda", currency: "USD" }, + { code: "SAU", name: "Saudi Arabia", currency: "SAR" }, + { code: "SEN", name: "Senegal", currency: "USD" }, + { code: "SGP", name: "Singapore", currency: "SGD" }, + { code: "SLB", name: "Solomon Islands", currency: "USD" }, + { code: "SLE", name: "Sierra Leone", currency: "USD" }, + { code: "SLV", name: "El Salvador", currency: "USD" }, + { code: "SRB", name: "Serbia", currency: "USD" }, + { code: "STP", name: "Sao Tome and Principe", currency: "USD" }, + { code: "SUR", name: "Suriname", currency: "USD" }, + { code: "SVK", name: "Slovakia", currency: "EUR" }, + { code: "SVN", name: "Slovenia", currency: "EUR" }, + { code: "SWE", name: "Sweden", currency: "SEK" }, + { code: "SWZ", name: "Eswatini", currency: "USD" }, + { code: "SYC", name: "Seychelles", currency: "USD" }, + { code: "TCA", name: "Turks and Caicos", currency: "USD" }, + { code: "TCD", name: "Chad", currency: "USD" }, + { code: "TGO", name: "Togo", currency: "USD" }, + { code: "THA", name: "Thailand", currency: "THB" }, + { code: "TJK", name: "Tajikistan", currency: "USD" }, + { code: "TKM", name: "Turkmenistan", currency: "USD" }, + { code: "TON", name: "Tonga", currency: "USD" }, + { code: "TTO", name: "Trinidad and Tobago", currency: "USD" }, + { code: "TUN", name: "Tunisia", currency: "USD" }, + { code: "TUR", name: "Turkey", currency: "TRY" }, + { code: "TWN", name: "Taiwan", currency: "TWD" }, + { code: "TZA", name: "Tanzania", currency: "TZS" }, + { code: "UGA", name: "Uganda", currency: "USD" }, + { code: "UKR", name: "Ukraine", currency: "USD" }, + { code: "URY", name: "Uruguay", currency: "USD" }, + { code: "USA", name: "United States", currency: "USD" }, + { code: "UZB", name: "Uzbekistan", currency: "USD" }, + { code: "VCT", name: "St. Vincent and the Grenadines", currency: "USD" }, + { code: "VEN", name: "Venezuela", currency: "USD" }, + { code: "VGB", name: "British Virgin Islands", currency: "USD" }, + { code: "VNM", name: "Vietnam", currency: "VND" }, + { code: "VUT", name: "Vanuatu", currency: "USD" }, + { code: "YEM", name: "Yemen", currency: "USD" }, + { code: "ZAF", name: "South Africa", currency: "ZAR" }, + { code: "ZMB", name: "Zambia", currency: "USD" }, + { code: "ZWE", name: "Zimbabwe", currency: "USD" }, +]; + +export const TERRITORY_MAP = new Map(TERRITORIES.map((t) => [t.code, t]));