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})
+
+
+
+
+
+ | Type |
+ Version |
+ Platform |
+ State |
+
+
+
+ {data.items.map((item) => {
+ const displayState = ITEM_STATE_DISPLAY[item.state] || item.displayState || item.state;
+ const stateColor = ITEM_STATE_COLORS[displayState] || "#8e8e93";
+ return (
+
+ |
+ {ITEM_TYPE_LABELS[item.type] || item.type}
+ |
+
+ {item.versionString || "\u2014"}
+ |
+
+ {item.platform || "\u2014"}
+ |
+
+
+
+ {displayState}
+
+ |
+
+ );
+ })}
+
+
+
+
+ )}
+
+ )}
+
+
+ );
+}