diff --git a/server/routes/apps.js b/server/routes/apps.js index b7fca0b..76b58a6 100644 --- a/server/routes/apps.js +++ b/server/routes/apps.js @@ -282,10 +282,25 @@ router.get("/:appId/versions/:versionId", async (req, res) => { try { const data = await ascFetch( account, - `/v1/appStoreVersions/${versionId}?fields[appStoreVersions]=versionString,appStoreState,platform,createdDate,releaseType,earliestReleaseDate,downloadable` + `/v1/appStoreVersions/${versionId}?fields[appStoreVersions]=versionString,appStoreState,platform,createdDate,releaseType,earliestReleaseDate,downloadable,reviewType&include=appStoreVersionPhasedRelease&fields[appStoreVersionPhasedReleases]=phasedReleaseState,currentDayNumber,startDate,totalPauseDuration` ); const attrs = data.data.attributes; + + let phasedRelease = null; + if (data.included) { + const pr = data.included.find((inc) => inc.type === "appStoreVersionPhasedReleases"); + if (pr) { + phasedRelease = { + id: pr.id, + phasedReleaseState: pr.attributes.phasedReleaseState, + currentDayNumber: pr.attributes.currentDayNumber, + startDate: pr.attributes.startDate, + totalPauseDuration: pr.attributes.totalPauseDuration, + }; + } + } + const result = { id: data.data.id, versionString: attrs.versionString, @@ -295,6 +310,8 @@ router.get("/:appId/versions/:versionId", async (req, res) => { releaseType: attrs.releaseType, earliestReleaseDate: attrs.earliestReleaseDate, downloadable: attrs.downloadable, + reviewType: attrs.reviewType, + phasedRelease, }; apiCache.set(cacheKey, result); @@ -305,6 +322,139 @@ router.get("/:appId/versions/:versionId", async (req, res) => { } }); +// ── Version Settings (release type, rating reset) ─────────────────────────── + +router.patch("/:appId/versions/:versionId", async (req, res) => { + const { versionId } = req.params; + const { accountId, releaseType, earliestReleaseDate, resetRatingSummary } = req.body; + + if (!accountId) { + return res.status(400).json({ error: "accountId is required" }); + } + + const accounts = getAccounts(); + const account = accounts.find((a) => a.id === accountId); + if (!account) return res.status(400).json({ error: "Account not found" }); + + const attributes = {}; + if (releaseType !== undefined) attributes.releaseType = releaseType; + if (earliestReleaseDate !== undefined) attributes.earliestReleaseDate = earliestReleaseDate; + if (resetRatingSummary !== undefined) attributes.resetRatingSummary = resetRatingSummary; + + try { + await ascFetch(account, `/v1/appStoreVersions/${versionId}`, { + method: "PATCH", + body: { + data: { type: "appStoreVersions", id: versionId, attributes }, + }, + }); + + apiCache.deleteByPrefix(`apps:version-detail:${versionId}:`); + res.json({ success: true }); + } catch (err) { + console.error(`Failed to update version ${versionId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + +// ── Phased Release ────────────────────────────────────────────────────────── + +router.post("/:appId/versions/:versionId/phased-release", async (req, res) => { + const { versionId } = req.params; + const { accountId, phasedReleaseState } = req.body; + + if (!accountId) { + return res.status(400).json({ error: "accountId is required" }); + } + + const accounts = getAccounts(); + const account = accounts.find((a) => a.id === accountId); + if (!account) return res.status(400).json({ error: "Account not found" }); + + try { + const data = await ascFetch(account, "/v1/appStoreVersionPhasedReleases", { + method: "POST", + body: { + data: { + type: "appStoreVersionPhasedReleases", + attributes: { phasedReleaseState: phasedReleaseState || "INACTIVE" }, + relationships: { + appStoreVersion: { data: { type: "appStoreVersions", id: versionId } }, + }, + }, + }, + }); + + apiCache.deleteByPrefix(`apps:version-detail:${versionId}:`); + res.json({ + id: data.data.id, + phasedReleaseState: data.data.attributes.phasedReleaseState, + currentDayNumber: data.data.attributes.currentDayNumber, + startDate: data.data.attributes.startDate, + totalPauseDuration: data.data.attributes.totalPauseDuration, + }); + } catch (err) { + console.error(`Failed to create phased release for version ${versionId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + +router.patch("/:appId/versions/:versionId/phased-release/:phasedReleaseId", async (req, res) => { + const { versionId, phasedReleaseId } = req.params; + const { accountId, phasedReleaseState } = req.body; + + if (!accountId) { + return res.status(400).json({ error: "accountId is required" }); + } + + const accounts = getAccounts(); + const account = accounts.find((a) => a.id === accountId); + if (!account) return res.status(400).json({ error: "Account not found" }); + + try { + const data = await ascFetch(account, `/v1/appStoreVersionPhasedReleases/${phasedReleaseId}`, { + method: "PATCH", + body: { + data: { + type: "appStoreVersionPhasedReleases", + id: phasedReleaseId, + attributes: { phasedReleaseState }, + }, + }, + }); + + apiCache.deleteByPrefix(`apps:version-detail:${versionId}:`); + res.json({ + id: data.data.id, + phasedReleaseState: data.data.attributes.phasedReleaseState, + currentDayNumber: data.data.attributes.currentDayNumber, + startDate: data.data.attributes.startDate, + totalPauseDuration: data.data.attributes.totalPauseDuration, + }); + } catch (err) { + console.error(`Failed to update phased release ${phasedReleaseId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + +router.delete("/:appId/versions/:versionId/phased-release/:phasedReleaseId", async (req, res) => { + const { versionId, phasedReleaseId } = req.params; + const { accountId } = req.query; + + const accounts = getAccounts(); + const account = accounts.find((a) => a.id === accountId) || accounts[0]; + if (!account) return res.status(400).json({ error: "No accounts configured" }); + + try { + await ascFetch(account, `/v1/appStoreVersionPhasedReleases/${phasedReleaseId}`, { method: "DELETE" }); + apiCache.deleteByPrefix(`apps:version-detail:${versionId}:`); + res.json({ success: true }); + } catch (err) { + console.error(`Failed to delete phased release ${phasedReleaseId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + router.get("/:appId/builds", async (req, res) => { const { appId } = req.params; const { accountId } = req.query; diff --git a/src/api/index.js b/src/api/index.js index f668280..0e4d591 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -97,6 +97,57 @@ export async function attachBuild(appId, versionId, buildId, accountId) { return res.json(); } +// ── Version Settings (release type, phased release, rating reset) ──────────── + +export async function updateVersionRelease(appId, versionId, { accountId, releaseType, earliestReleaseDate, resetRatingSummary }) { + const res = await fetch(`/api/apps/${appId}/versions/${versionId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId, releaseType, earliestReleaseDate, resetRatingSummary }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to update version release: ${res.status}`); + } + return res.json(); +} + +export async function createPhasedRelease(appId, versionId, { accountId, phasedReleaseState }) { + const res = await fetch(`/api/apps/${appId}/versions/${versionId}/phased-release`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId, phasedReleaseState }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to create phased release: ${res.status}`); + } + return res.json(); +} + +export async function updatePhasedRelease(appId, versionId, phasedReleaseId, { accountId, phasedReleaseState }) { + const res = await fetch(`/api/apps/${appId}/versions/${versionId}/phased-release/${phasedReleaseId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId, phasedReleaseState }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to update phased release: ${res.status}`); + } + return res.json(); +} + +export async function deletePhasedRelease(appId, versionId, phasedReleaseId, accountId) { + const params = new URLSearchParams({ accountId }); + const res = await fetch(`/api/apps/${appId}/versions/${versionId}/phased-release/${phasedReleaseId}?${params}`, { method: "DELETE" }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to delete phased release: ${res.status}`); + } + return res.json(); +} + export async function fetchAppLookup(bundleId) { const params = new URLSearchParams({ bundleId }); const res = await fetch(`/api/apps/lookup?${params}`); diff --git a/src/components/PhasedReleaseSection.jsx b/src/components/PhasedReleaseSection.jsx new file mode 100644 index 0000000..60216c2 --- /dev/null +++ b/src/components/PhasedReleaseSection.jsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { createPhasedRelease, updatePhasedRelease, deletePhasedRelease } from "../api/index.js"; +import { PHASED_RELEASE_DAY_PERCENTAGES } from "../constants/index.js"; + +export default function PhasedReleaseSection({ appId, versionId, accountId, detail, onDetailUpdate, isMobile }) { + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const pr = detail.phasedRelease; + const hasPhased = !!pr; + const isActive = pr && (pr.phasedReleaseState === "ACTIVE" || pr.phasedReleaseState === "PAUSED"); + + async function handleToggle(wantPhased) { + setSaving(true); + setSaveError(null); + try { + if (wantPhased && !hasPhased) { + await createPhasedRelease(appId, versionId, { accountId, phasedReleaseState: "INACTIVE" }); + } else if (!wantPhased && hasPhased) { + await deletePhasedRelease(appId, versionId, pr.id, accountId); + } + await onDetailUpdate(); + } catch (err) { + setSaveError(err.message); + } finally { + setSaving(false); + } + } + + async function handlePauseResume(newState) { + setSaving(true); + setSaveError(null); + try { + await updatePhasedRelease(appId, versionId, pr.id, { accountId, phasedReleaseState: newState }); + await onDetailUpdate(); + } catch (err) { + setSaveError(err.message); + } finally { + setSaving(false); + } + } + + async function handleCompleteImmediately() { + setSaving(true); + setSaveError(null); + try { + await updatePhasedRelease(appId, versionId, pr.id, { accountId, phasedReleaseState: "COMPLETE" }); + await onDetailUpdate(); + } catch (err) { + setSaveError(err.message); + } finally { + setSaving(false); + } + } + + return ( +
+

Phased Release for Automatic Updates

+

+ Release this update gradually over a 7-day period to users with automatic updates enabled. + You can pause the rollout at any time. +

+ +
+ + +
+ ); +} diff --git a/src/components/RatingResetSection.jsx b/src/components/RatingResetSection.jsx new file mode 100644 index 0000000..0fa47ed --- /dev/null +++ b/src/components/RatingResetSection.jsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { updateVersionRelease } from "../api/index.js"; + +export default function RatingResetSection({ appId, versionId, accountId, detail, onDetailUpdate, isMobile }) { + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + // resetRatingSummary is write-only in the ASC API (not returned in GET), + // so we track the user's selection locally with optimistic state + const [isReset, setIsReset] = useState(false); + + async function handleChange(wantReset) { + const prev = isReset; + setIsReset(wantReset); + setSaving(true); + setSaveError(null); + try { + await updateVersionRelease(appId, versionId, { + accountId, + resetRatingSummary: wantReset, + }); + } catch (err) { + setIsReset(prev); + setSaveError(err.message); + } finally { + setSaving(false); + } + } + + return ( +
+

Reset App Store Summary Rating

+

+ You can choose to reset your app's summary rating when this version is released. + All existing ratings will be removed and a new average will be calculated from new ratings only. +

+ +
+ + + +
+ + {saveError && ( +
{saveError}
+ )} +
+ ); +} diff --git a/src/components/VersionDetailPage.jsx b/src/components/VersionDetailPage.jsx index 2c8e3a3..81b7b0d 100644 --- a/src/components/VersionDetailPage.jsx +++ b/src/components/VersionDetailPage.jsx @@ -1,7 +1,11 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { fetchVersionDetail, fetchVersionBuilds, fetchAttachedBuild, attachBuild } from "../api/index.js"; +import { TERMINAL_STATES } from "../constants/index.js"; import Badge from "./Badge.jsx"; import BuildSelector from "./BuildSelector.jsx"; +import VersionReleaseSection from "./VersionReleaseSection.jsx"; +import PhasedReleaseSection from "./PhasedReleaseSection.jsx"; +import RatingResetSection from "./RatingResetSection.jsx"; import ScreenshotsSection from "./ScreenshotsSection.jsx"; import VersionLocalizationsSection from "./VersionLocalizationsSection.jsx"; @@ -19,6 +23,15 @@ export default function VersionDetailPage({ app, version, accounts, isMobile }) const [attachingBuildId, setAttachingBuildId] = useState(null); const [attachError, setAttachError] = useState(null); + const refreshDetail = useCallback(async () => { + try { + const data = await fetchVersionDetail(app.id, version.id, app.accountId); + setDetail(data); + } catch (err) { + setDetailError(err.message); + } + }, [app.id, version.id, app.accountId]); + useEffect(() => { let cancelled = false; @@ -188,6 +201,36 @@ export default function VersionDetailPage({ app, version, accounts, isMobile }) accountId={app.accountId} isMobile={isMobile} /> + + {/* Version Settings (only for non-terminal versions) */} + {detail && !TERMINAL_STATES.has(detail.appStoreState) && ( +
+ + + +
+ )}
); diff --git a/src/components/VersionReleaseSection.jsx b/src/components/VersionReleaseSection.jsx new file mode 100644 index 0000000..49b0ae9 --- /dev/null +++ b/src/components/VersionReleaseSection.jsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { updateVersionRelease } from "../api/index.js"; +import { RELEASE_TYPES } from "../constants/index.js"; + +export default function VersionReleaseSection({ appId, versionId, accountId, detail, onDetailUpdate, isMobile }) { + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const currentType = detail.releaseType || "AFTER_APPROVAL"; + const currentDate = detail.earliestReleaseDate || ""; + + async function handleTypeChange(newType) { + setSaving(true); + setSaveError(null); + try { + await updateVersionRelease(appId, versionId, { + accountId, + releaseType: newType, + earliestReleaseDate: newType === "SCHEDULED" ? (currentDate || new Date(Date.now() + 86400000).toISOString()) : null, + }); + await onDetailUpdate(); + } catch (err) { + setSaveError(err.message); + } finally { + setSaving(false); + } + } + + async function handleDateChange(dateStr) { + if (!dateStr) return; + setSaving(true); + setSaveError(null); + try { + const isoDate = new Date(dateStr).toISOString(); + await updateVersionRelease(appId, versionId, { + accountId, + releaseType: "SCHEDULED", + earliestReleaseDate: isoDate, + }); + await onDetailUpdate(); + } catch (err) { + setSaveError(err.message); + } finally { + setSaving(false); + } + } + + function toLocalDatetime(isoStr) { + if (!isoStr) return ""; + const d = new Date(isoStr); + const offset = d.getTimezoneOffset(); + const local = new Date(d.getTime() - offset * 60000); + return local.toISOString().slice(0, 16); + } + + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + + return ( +
+

App Store Version Release

+

+ Choose how this version is released to the App Store. You can release manually, automatically after approval, or schedule a specific date. +

+ +
+ {RELEASE_TYPES.map((rt) => ( + + ))} +
+ + {saveError && ( +
{saveError}
+ )} +
+ ); +} diff --git a/src/constants/index.js b/src/constants/index.js index 9721be6..ff6f6a0 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -19,6 +19,16 @@ export const TERMINAL_STATES = new Set([ "REJECTED", ]); +export const RELEASE_TYPES = [ + { value: "MANUAL", label: "Manually release this version" }, + { value: "AFTER_APPROVAL", label: "Automatically release this version" }, + { value: "SCHEDULED", label: "Automatically release this version after App Review, no earlier than" }, +]; + +export const PHASED_RELEASE_DAY_PERCENTAGES = { + 1: "1%", 2: "2%", 3: "5%", 4: "10%", 5: "20%", 6: "50%", 7: "100%", +}; + export const ACCT_COLORS = ["#3b82f6", "#22c55e", "#f59e0b", "#a78bfa", "#ef4444", "#ec4899", "#14b8a6"]; export const IAP_STATUS_MAP = {