diff --git a/server/routes/apps.js b/server/routes/apps.js index 755d781..4ea1fd0 100644 --- a/server/routes/apps.js +++ b/server/routes/apps.js @@ -1112,47 +1112,12 @@ router.get("/:appId/review-submissions/:submissionId", async (req, res) => { try { const submissionUrl = `/v1/reviewSubmissions/${submissionId}` + "?include=items,appStoreVersionForReview,submittedByActor,lastUpdatedByActor" - + "&fields[reviewSubmissions]=submittedDate,state,platform" + + "&fields[reviewSubmissions]=submittedDate,state,platform,items,appStoreVersionForReview,submittedByActor,lastUpdatedByActor" + "&fields[reviewSubmissionItems]=state,appStoreVersion" + "&fields[appStoreVersions]=versionString,platform,appStoreState" + "&fields[actors]=userFirstName,userLastName"; - const itemsUrl = `/v1/reviewSubmissions/${submissionId}/items` - + "?include=appStoreVersion" - + "&fields[reviewSubmissionItems]=state,appStoreVersion" - + "&fields[appStoreVersions]=versionString,platform,appStoreState"; - - const [submissionData, itemsData] = await Promise.all([ - ascFetch(account, submissionUrl), - ascFetch(account, itemsUrl), - ]); - - // Replace item objects with richer ones from items endpoint (they carry appStoreVersion relationship) - if (!submissionData.included) submissionData.included = []; - if (itemsData.data) { - for (const item of itemsData.data) { - const idx = submissionData.included.findIndex((e) => e.type === item.type && e.id === item.id); - if (idx >= 0) submissionData.included[idx] = item; - else submissionData.included.push(item); - } - } - // Merge included version objects from items endpoint - if (itemsData.included) { - for (const inc of itemsData.included) { - const exists = submissionData.included.some((e) => e.type === inc.type && e.id === inc.id); - if (!exists) submissionData.included.push(inc); - } - } - - // DEBUG: log raw API responses to understand structure - console.log("=== SUBMISSION DATA ==="); - console.log("data.relationships:", JSON.stringify(submissionData.data?.relationships, null, 2)); - console.log("included types:", submissionData.included?.map(i => `${i.type}:${i.id}`)); - console.log("=== ITEMS DATA ==="); - console.log("itemsData.data:", JSON.stringify(itemsData.data, null, 2)); - console.log("itemsData.included:", JSON.stringify(itemsData.included, null, 2)); - console.log("=== MERGED INCLUDED ==="); - console.log("all included:", submissionData.included?.map(i => `${i.type}:${i.id} rels=${Object.keys(i.relationships || {}).join(",")}`)); + const submissionData = await ascFetch(account, submissionUrl); const result = parseReviewSubmissionDetail(submissionData); diff --git a/src/api/index.js b/src/api/index.js index 01b8865..da68329 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -234,6 +234,13 @@ export async function fetchReviewSubmissions(appId, accountId) { return res.json(); } +export async function fetchReviewSubmissionDetail(appId, submissionId, accountId) { + const params = new URLSearchParams({ accountId }); + const res = await fetch(`/api/apps/${appId}/review-submissions/${submissionId}?${params}`); + if (!res.ok) throw new Error(`Failed to fetch review submission detail: ${res.status}`); + return res.json(); +} + // ── In-App Purchases ───────────────────────────────────────────────────────── export async function fetchIAPs(appId, accountId) { diff --git a/src/components/AppDetailPage.jsx b/src/components/AppDetailPage.jsx index d578d1f..86e7387 100644 --- a/src/components/AppDetailPage.jsx +++ b/src/components/AppDetailPage.jsx @@ -16,7 +16,7 @@ function StarRating({ rating }) { return {stars.join("")}; } -export default function AppDetailPage({ app, accounts, isMobile, onSelectVersion, onViewProducts, onViewXcodeCloud }) { +export default function AppDetailPage({ app, accounts, isMobile, onSelectVersion, onViewProducts, onViewXcodeCloud, onViewReviewDetail }) { const [lookupData, setLookupData] = useState(null); const [lookupLoading, setLookupLoading] = useState(true); const [descExpanded, setDescExpanded] = useState(false); @@ -168,7 +168,7 @@ export default function AppDetailPage({ app, accounts, isMobile, onSelectVersion )} {/* App Review */} - + {/* Version History */}
diff --git a/src/components/AppReviewSection.jsx b/src/components/AppReviewSection.jsx index b354283..69988f9 100644 --- a/src/components/AppReviewSection.jsx +++ b/src/components/AppReviewSection.jsx @@ -14,13 +14,13 @@ const STATUS_ICONS = { "Removed": "\u2013", }; -function formatDate(dateString) { +export function formatDate(dateString) { if (!dateString) return "\u2014"; const d = new Date(dateString); return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } -function ReviewStatus({ status }) { +export function ReviewStatus({ status }) { const color = STATUS_COLORS[status] || "#8e8e93"; const icon = STATUS_ICONS[status]; @@ -41,7 +41,7 @@ function ReviewStatus({ status }) { ); } -function MessagesTable({ messages }) { +function MessagesTable({ messages, onViewDetail }) { if (messages.length === 0) return null; return ( @@ -63,7 +63,11 @@ function MessagesTable({ messages }) { {messages.map((msg) => ( - + onViewDetail(msg.id)} + > {formatDate(msg.createdDate)} @@ -82,7 +86,7 @@ function MessagesTable({ messages }) { ); } -function SubmissionsTable({ submissions }) { +function SubmissionsTable({ submissions, onViewDetail }) { if (submissions.length === 0) return null; return ( @@ -105,7 +109,11 @@ function SubmissionsTable({ submissions }) { {submissions.map((sub) => ( - + onViewDetail(sub.id)} + > {formatDate(sub.submittedDate)} @@ -123,7 +131,7 @@ function SubmissionsTable({ submissions }) { ); } -export default function AppReviewSection({ appId, accountId }) { +export default function AppReviewSection({ appId, accountId, onViewDetail }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -180,8 +188,8 @@ export default function AppReviewSection({ appId, accountId }) { return (

App Review

- {hasMessages && } - {hasSubmissions && } + {hasMessages && } + {hasSubmissions && }
); } diff --git a/src/components/AppStoreManager.jsx b/src/components/AppStoreManager.jsx index f4b8ac9..a9fb589 100644 --- a/src/components/AppStoreManager.jsx +++ b/src/components/AppStoreManager.jsx @@ -12,6 +12,7 @@ import ProductsPage from "./ProductsPage.jsx"; import XcodeCloudPage from "./XcodeCloudPage.jsx"; import BuildDetailPage from "./BuildDetailPage.jsx"; import WorkflowEditPage from "./WorkflowEditPage.jsx"; +import ReviewSubmissionDetail from "./ReviewSubmissionDetail.jsx"; function buildGroups(apps, groupBy, accounts) { if (groupBy === "none") return [{ key: "all", label: null, apps }]; @@ -49,6 +50,8 @@ function getRouteFromPath() { if (xcodeCloudBuildMatch) return { appId: xcodeCloudBuildMatch[1], versionId: null, subPage: "xcode-cloud-build", buildId: xcodeCloudBuildMatch[2] }; const xcodeCloudMatch = path.match(/^\/app\/([^/]+)\/xcode-cloud$/); if (xcodeCloudMatch) return { appId: xcodeCloudMatch[1], versionId: null, subPage: "xcode-cloud" }; + const reviewMatch = path.match(/^\/app\/([^/]+)\/review\/([^/]+)$/); + if (reviewMatch) return { appId: reviewMatch[1], versionId: null, subPage: "review", submissionId: reviewMatch[2] }; const appMatch = path.match(/^\/app\/([^/]+)$/); if (appMatch) return { appId: appMatch[1], versionId: null, subPage: null }; return { appId: null, versionId: null, subPage: null }; @@ -114,6 +117,7 @@ export default function AppStoreManager() { const [selectedBuildRun, setSelectedBuildRun] = useState(null); const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); + const [selectedSubmissionId, setSelectedSubmissionId] = useState(null); const navigateToWorkflowEdit = useCallback((workflow, app) => { setSelectedApp(app); @@ -140,6 +144,18 @@ export default function AppStoreManager() { ); }, []); + const navigateToReviewDetail = useCallback((submissionId, app) => { + setSelectedApp(app); + setSelectedVersion(null); + setSelectedSubmissionId(submissionId); + setCurrentView("review"); + window.history.pushState( + { appId: app.id, submissionId, subPage: "review" }, + "", + `/app/${app.id}/review/${submissionId}` + ); + }, []); + const loadData = useCallback(async (fresh = false) => { try { setError(null); @@ -187,6 +203,13 @@ export default function AppStoreManager() { // Build deep-link resolution failed, page will show with available data } } + } else if (route.subPage === "review") { + const appMatch = appsList.find((a) => a.id === route.appId); + if (appMatch) { + setSelectedApp(appMatch); + setSelectedSubmissionId(route.submissionId); + setCurrentView("review"); + } } else if (route.subPage === "xcode-cloud") { const appMatch = appsList.find((a) => a.id === route.appId); if (appMatch) { @@ -279,6 +302,20 @@ export default function AppStoreManager() { return; } + if (route.subPage === "review") { + const appMatch = apps.find((a) => a.id === route.appId); + if (appMatch) { + setSelectedApp(appMatch); + setSelectedSubmissionId(route.submissionId); + setCurrentView("review"); + } else { + setSelectedApp(null); + setSelectedSubmissionId(null); + setCurrentView(null); + } + return; + } + if (route.subPage === "xcode-cloud") { const appMatch = apps.find((a) => a.id === route.appId); setSelectedApp(appMatch || null); @@ -337,6 +374,18 @@ export default function AppStoreManager() { ); } + if (currentView === "review" && selectedApp && selectedSubmissionId) { + return ( +
+ +
+ ); + } + if (currentView === "xcode-cloud-workflow" && selectedApp && selectedWorkflowId) { return (
@@ -386,6 +435,7 @@ export default function AppStoreManager() { onSelectVersion={(version) => selectVersion(version, selectedApp)} onViewProducts={() => navigateToProducts(selectedApp)} onViewXcodeCloud={() => navigateToXcodeCloud(selectedApp)} + onViewReviewDetail={(submissionId) => navigateToReviewDetail(submissionId, selectedApp)} />
); diff --git a/src/components/ReviewSubmissionDetail.jsx b/src/components/ReviewSubmissionDetail.jsx new file mode 100644 index 0000000..9468d72 --- /dev/null +++ b/src/components/ReviewSubmissionDetail.jsx @@ -0,0 +1,163 @@ +import { useState, useEffect } from "react"; +import { fetchReviewSubmissionDetail } from "../api/index.js"; +import { ReviewStatus, formatDate } from "./AppReviewSection.jsx"; +import AppIcon from "./AppIcon.jsx"; + +const ITEM_STATE_DISPLAY = { + READY_FOR_REVIEW: "Ready for Review", + ACCEPTED: "Accepted", + APPROVED: "Approved", + REJECTED: "Rejected", + REMOVED: "Removed", +}; + +const ITEM_STATE_COLORS = { + "Ready for Review": "#ff9f0a", + Accepted: "#30d158", + Approved: "#30d158", + Rejected: "#ff453a", + Removed: "#8e8e93", +}; + +const ITEM_TYPE_LABELS = { + appStoreVersion: "App Store Version", + appCustomProductPage: "Custom Product Page", + appStoreVersionExperiment: "Product Page Experiment", + appEvent: "App Event", +}; + +function DetailRow({ label, children }) { + return ( +
+ {label} + {children} +
+ ); +} + +export default function ReviewSubmissionDetail({ app, submissionId, isMobile }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + fetchReviewSubmissionDetail(app.id, submissionId, app.accountId) + .then((result) => { if (!cancelled) setData(result); }) + .catch((err) => { if (!cancelled) setError(err.message); }) + .finally(() => { if (!cancelled) setLoading(false); }); + + return () => { cancelled = true; }; + }, [app.id, submissionId, app.accountId]); + + return ( +
+ {/* Breadcrumb */} +
+
+ +
+
+ +
+ {/* Header */} +
+ +
+

Review Submission

+

+ {data ? data.versions : "Loading..."} +

+
+ {data && ( +
+ +
+ )} +
+ + {loading && ( +
+ Loading submission details... +
+ )} + + {error && ( +
+ Failed to load submission details. +
+ )} + + {data && ( +
+ {/* Submission Info */} +
+

Details

+ + {formatDate(data.submittedDate)} + {data.platform || "Unknown"} + {data.versions} + {data.submittedBy && {data.submittedBy}} + {data.lastUpdatedBy && {data.lastUpdatedBy}} +
+ + {/* Items */} + {data.items && data.items.length > 0 && ( +
+

+ Submission Items ({data.items.length}) +

+
+ + + + + + + + + + + {data.items.map((item) => { + const displayState = ITEM_STATE_DISPLAY[item.state] || item.displayState || item.state; + const stateColor = ITEM_STATE_COLORS[displayState] || "#8e8e93"; + return ( + + + + + + + ); + })} + +
TypeVersionPlatformState
+ {ITEM_TYPE_LABELS[item.type] || item.type} + + {item.versionString || "\u2014"} + + {item.platform || "\u2014"} + + + + {displayState} + +
+
+
+ )} +
+ )} +
+
+ ); +}