Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,457 changes: 1,428 additions & 29 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
"server": "node --watch server/index.js",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"adm-zip": "^0.5.16",
Expand All @@ -37,8 +39,14 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^29.0.1",
"supertest": "^7.2.2",
"tailwindcss": "^4.2.2",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vitest": "^4.1.0"
}
}
132 changes: 132 additions & 0 deletions server/routes/apps.js
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,138 @@ router.patch("/:appId/versions/:versionId/build", async (req, res) => {
}
});

// ── Version Localizations ───────────────────────────────────────────────────

function normalizeVersionLocalization(item) {
return {
id: item.id,
locale: item.attributes.locale,
description: item.attributes.description,
whatsNew: item.attributes.whatsNew,
keywords: item.attributes.keywords,
promotionalText: item.attributes.promotionalText,
supportUrl: item.attributes.supportUrl,
marketingUrl: item.attributes.marketingUrl,
};
}

router.get("/:appId/versions/:versionId/localizations", async (req, res) => {
const { versionId } = req.params;
const { accountId } = req.query;

const cacheKey = `apps:version-locs:${versionId}:${accountId || "default"}`;
const cached = apiCache.get(cacheKey);
if (cached) return res.json(cached);

const accounts = getAccounts();
const account = accounts.find((a) => a.id === accountId) || accounts[0];

try {
const data = await ascFetch(
account,
`/v1/appStoreVersions/${versionId}/appStoreVersionLocalizations?fields[appStoreVersionLocalizations]=locale,description,whatsNew,keywords,promotionalText,supportUrl,marketingUrl`
);
const locs = (data.data || []).map(normalizeVersionLocalization);
apiCache.set(cacheKey, locs);
res.json(locs);
} catch (err) {
console.error(`Failed to fetch version localizations for ${versionId}:`, err.message);
res.status(502).json({ error: err.message });
}
});

router.post("/:appId/versions/:versionId/localizations", async (req, res) => {
const { versionId } = req.params;
const { accountId, locale, description, whatsNew, keywords, promotionalText, supportUrl, marketingUrl } = req.body;

if (!accountId || !locale) {
return res.status(400).json({ error: "accountId and locale 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 attributes = { locale };
if (description !== undefined) attributes.description = description;
if (whatsNew !== undefined) attributes.whatsNew = whatsNew;
if (keywords !== undefined) attributes.keywords = keywords;
if (promotionalText !== undefined) attributes.promotionalText = promotionalText;
if (supportUrl !== undefined) attributes.supportUrl = supportUrl;
if (marketingUrl !== undefined) attributes.marketingUrl = marketingUrl;

try {
const data = await ascFetch(account, "/v1/appStoreVersionLocalizations", {
method: "POST",
body: {
data: {
type: "appStoreVersionLocalizations",
attributes,
relationships: {
appStoreVersion: { data: { type: "appStoreVersions", id: versionId } },
},
},
},
});
apiCache.deleteByPrefix(`apps:version-locs:${versionId}:`);
res.json(normalizeVersionLocalization(data.data));
} catch (err) {
console.error(`Failed to create version localization for ${versionId}:`, err.message);
res.status(502).json({ error: err.message });
}
});

router.patch("/:appId/versions/:versionId/localizations/:locId", async (req, res) => {
const { versionId, locId } = req.params;
const { accountId, description, whatsNew, keywords, promotionalText, supportUrl, marketingUrl } = 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 (description !== undefined) attributes.description = description;
if (whatsNew !== undefined) attributes.whatsNew = whatsNew;
if (keywords !== undefined) attributes.keywords = keywords;
if (promotionalText !== undefined) attributes.promotionalText = promotionalText;
if (supportUrl !== undefined) attributes.supportUrl = supportUrl;
if (marketingUrl !== undefined) attributes.marketingUrl = marketingUrl;

try {
const data = await ascFetch(account, `/v1/appStoreVersionLocalizations/${locId}`, {
method: "PATCH",
body: {
data: { type: "appStoreVersionLocalizations", id: locId, attributes },
},
});
apiCache.deleteByPrefix(`apps:version-locs:${versionId}:`);
res.json(normalizeVersionLocalization(data.data));
} catch (err) {
console.error(`Failed to update version localization ${locId}:`, err.message);
res.status(502).json({ error: err.message });
}
});

router.delete("/:appId/versions/:versionId/localizations/:locId", async (req, res) => {
const { versionId, locId } = 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/appStoreVersionLocalizations/${locId}`, { method: "DELETE" });
apiCache.deleteByPrefix(`apps:version-locs:${versionId}:`);
res.json({ success: true });
} catch (err) {
console.error(`Failed to delete version localization ${locId}:`, err.message);
res.status(502).json({ error: err.message });
}
});

router.post("/:appId/versions/:versionId/submit", async (req, res) => {
const { appId, versionId } = req.params;
const { accountId } = req.body;
Expand Down
45 changes: 45 additions & 0 deletions src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,51 @@ export async function fetchAppLookup(bundleId) {
return res.json();
}

// ── Version Localizations ───────────────────────────────────────────────────

export async function fetchVersionLocalizations(appId, versionId, accountId) {
const params = new URLSearchParams({ accountId });
const res = await fetch(`/api/apps/${appId}/versions/${versionId}/localizations?${params}`);
if (!res.ok) throw new Error(`Failed to fetch version localizations: ${res.status}`);
return res.json();
}

export async function createVersionLocalization(appId, versionId, { accountId, locale, ...fields }) {
const res = await fetch(`/api/apps/${appId}/versions/${versionId}/localizations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountId, locale, ...fields }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Failed to create version localization: ${res.status}`);
}
return res.json();
}

export async function updateVersionLocalization(appId, versionId, locId, { accountId, ...fields }) {
const res = await fetch(`/api/apps/${appId}/versions/${versionId}/localizations/${locId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountId, ...fields }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Failed to update version localization: ${res.status}`);
}
return res.json();
}

export async function deleteVersionLocalization(appId, versionId, locId, accountId) {
const params = new URLSearchParams({ accountId });
const res = await fetch(`/api/apps/${appId}/versions/${versionId}/localizations/${locId}?${params}`, { method: "DELETE" });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Failed to delete version localization: ${res.status}`);
}
return res.json();
}

// ── In-App Purchases ─────────────────────────────────────────────────────────

export async function fetchIAPs(appId, accountId) {
Expand Down
108 changes: 45 additions & 63 deletions src/components/BuildSelector.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export default function BuildSelector({ builds, attachedBuild, loading, attaching, attachingBuildId, error, onAttach }) {
import { useState } from "react";
import BuildSelectorModal from "./BuildSelectorModal.jsx";

export default function BuildSelector({ builds, attachedBuild, loading, attaching, attachingBuildId, error, onAttach, isMobile }) {
const [showModal, setShowModal] = useState(false);

function formatDate(dateString) {
if (!dateString) return "\u2014";
const d = new Date(dateString);
Expand All @@ -25,6 +30,11 @@ export default function BuildSelector({ builds, attachedBuild, loading, attachin
}
}

async function handleAttachFromModal(buildId) {
await onAttach(buildId);
setShowModal(false);
}

if (loading) {
return (
<div className="text-center py-8 text-dark-dim">
Expand All @@ -44,11 +54,9 @@ export default function BuildSelector({ builds, attachedBuild, loading, attachin
}

return (
<div className="space-y-3">
{/* Attached build card */}
{attachedBuild && (
<>
{attachedBuild ? (
<div className="border border-success/30 bg-success/5 rounded-[10px] px-4 py-3">
<div className="text-[10px] text-success font-bold uppercase tracking-wide mb-2">Selected Build</div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-[13px] font-semibold text-dark-text font-mono">
Expand All @@ -65,68 +73,42 @@ export default function BuildSelector({ builds, attachedBuild, loading, attachin
)}
</div>
</div>
<button
onClick={() => setShowModal(true)}
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-accent/10 text-accent border border-accent/20 cursor-pointer font-sans hover:bg-accent/20 transition-colors shrink-0"
>
Change
</button>
</div>
</div>
)}

{/* Build list */}
{builds.length === 0 ? (
<div className="text-center py-8 text-dark-ghost">
<div className="text-xs font-semibold">No builds available</div>
<div className="text-[11px] text-dark-dim mt-1">Upload a build via Xcode or Transporter</div>
</div>
) : (
<div className="space-y-1.5">
{builds.map((b) => {
const isAttached = attachedBuild?.id === b.id;
const isAttaching = attaching && attachingBuildId === b.id;
const canSelect = b.processingState === "VALID" && !isAttached;

return (
<div key={b.id} className="bg-dark-surface rounded-[10px] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-[13px] font-semibold text-dark-text font-mono">
Build {b.version}
</div>
<div className="flex items-center gap-3 mt-1 flex-wrap">
<span className="flex items-center gap-1 text-[11px]">
<span className="w-1.5 h-1.5 rounded-full" style={{ background: stateColor(b.processingState) }} />
<span style={{ color: stateColor(b.processingState) }}>{stateLabel(b.processingState)}</span>
</span>
<span className="text-[11px] text-dark-dim">{formatDate(b.uploadedDate)}</span>
{b.minOsVersion && (
<span className="text-[11px] text-dark-dim">Min OS {b.minOsVersion}</span>
)}
</div>
</div>
<div className="shrink-0">
{isAttached ? (
<span className="px-3 py-1.5 rounded-lg text-[11px] font-semibold text-success bg-success/10 border border-success/20">
Selected
</span>
) : canSelect ? (
<button
onClick={() => onAttach(b.id)}
disabled={attaching}
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-accent/10 text-accent border border-accent/20 cursor-pointer font-sans hover:bg-accent/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isAttaching ? (
<span className="inline-block" style={{ animation: "asc-spin 1s linear infinite" }}>{"\u21bb"}</span>
) : "Select"}
</button>
) : (
<span className="px-3 py-1.5 rounded-lg text-[11px] font-semibold text-dark-ghost bg-dark-hover">
{stateLabel(b.processingState)}
</span>
)}
</div>
</div>
</div>
);
})}
<div className="bg-dark-surface rounded-[10px] px-4 py-3 flex items-center justify-between">
<div>
<div className="text-[13px] text-dark-dim font-medium">No build selected</div>
<div className="text-[11px] text-dark-ghost mt-0.5">Select a build to attach to this version</div>
</div>
{builds.length > 0 && (
<button
onClick={() => setShowModal(true)}
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-accent/10 text-accent border border-accent/20 cursor-pointer font-sans hover:bg-accent/20 transition-colors shrink-0"
>
Select Build
</button>
)}
</div>
)}
</div>

{showModal && (
<BuildSelectorModal
builds={builds}
attachedBuild={attachedBuild}
attaching={attaching}
attachingBuildId={attachingBuildId}
onAttach={handleAttachFromModal}
onClose={() => setShowModal(false)}
isMobile={isMobile}
/>
)}
</>
);
}
Loading
Loading