Skip to content

Commit 87bcf6a

Browse files
committed
Add locale and remove locale dialogs with ASC error resilience
- Add locale dialog: translates fields from primary locale, generates keywords with forbidden-word rules, creates localizations across store listing and app details in one step - Remove locale dialog: choose which sections to delete from with immediate ASC deletion, replacing the old per-page AlertDialog - Handle ASC transient 500 errors gracefully: CREATE 409 falls back to update, DELETE 404 treated as success - Extract shared keyword forbidden-word utilities (splitMetaWords, buildForbiddenKeywords) to eliminate duplicated logic - Fix keywords save deleting unchanged localizations - Fix AI keyword prompt leaking reasoning text - Add refresh button support to store listing page
1 parent e1a6dd4 commit 87bcf6a

16 files changed

Lines changed: 1349 additions & 194 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
## 1.3.0
44

5+
- Add locale dialog – translates all fields from the primary locale, generates keywords with forbidden-word rules, and creates localizations across store listing and app details in one step
6+
- Remove locale dialog – choose which sections (store listing, app details, screenshots) to delete from, with immediate ASC deletion
7+
- Refresh button on the store listing page now reloads localizations from App Store Connect
8+
- Fix AI keyword generation including subtitle words as keywords – forbidden words now use the translated subtitle
9+
- Fix keywords save deleting unchanged localizations – only changed locales are sent to the sync endpoint
10+
- Fix App Store Connect 409 duplicate errors on locale creation – automatically falls back to updating the existing localization
11+
- Extract shared keyword forbidden-word utilities to eliminate duplicated logic across store listing, keyword insights, and AI dialogs
512
- Add keywords insights page with per-locale keyword analysis, cross-locale duplicate detection, and storefront view
613
- Add "Fix all issues" bulk AI keyword optimisation across all locales
714
- Add per-locale "Fix issues" AI keyword improvement that removes name/subtitle overlaps and cross-locale duplicates

src/app/dashboard/apps/[appId]/aso/keywords/_components/fix-all-dialog.tsx

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
DialogTitle,
1313
} from "@/components/ui/dialog";
1414
import { localeName, FIELD_LIMITS } from "@/lib/asc/locale-names";
15+
import { buildForbiddenKeywords } from "@/lib/asc/keyword-utils";
1516
import { CharCount } from "@/components/char-count";
1617
import type { LocaleKeywordData, StorefrontAnalysis } from "./keyword-analysis";
1718

@@ -40,24 +41,13 @@ function buildForbiddenWords(
4041
appName: string | undefined,
4142
appSubtitle: string | null,
4243
): string[] {
43-
const forbidden = new Set<string>();
44-
for (const ld of analysis.localeData) {
45-
if (ld.locale === locale) continue;
46-
for (const kw of ld.keywords) {
47-
forbidden.add(kw.toLowerCase());
48-
}
49-
}
50-
if (appName) {
51-
for (const w of appName.toLowerCase().split(/[\s\-/&]+/)) {
52-
if (w.length > 1) forbidden.add(w);
53-
}
54-
}
55-
if (appSubtitle) {
56-
for (const w of appSubtitle.toLowerCase().split(/[\s\-/&]+/)) {
57-
if (w.length > 1) forbidden.add(w);
58-
}
59-
}
60-
return [...forbidden];
44+
return buildForbiddenKeywords({
45+
appName,
46+
subtitle: appSubtitle ?? undefined,
47+
otherLocaleKeywords: analysis.localeData
48+
.filter((ld) => ld.locale !== locale)
49+
.map((ld) => ld.keywords.join(",")),
50+
});
6151
}
6252

6353
function cleanKeywords(

src/app/dashboard/apps/[appId]/aso/keywords/_components/keywords-context.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,20 @@ export function KeywordsProvider({ children }: { children: React.ReactNode }) {
106106
return;
107107
}
108108
try {
109+
// Only send changed locales + their IDs – never trigger creates or deletes
110+
const changedLocaleIds: Record<string, string> = {};
111+
for (const locale of Object.keys(changed)) {
112+
const id = originalLocaleIdsRef.current[locale];
113+
if (id) changedLocaleIds[locale] = id;
114+
}
109115
const res = await fetch(
110116
`/api/apps/${appId}/versions/${versionId}/localizations`,
111117
{
112118
method: "PUT",
113119
headers: { "Content-Type": "application/json" },
114120
body: JSON.stringify({
115121
locales: changed,
116-
originalLocaleIds: originalLocaleIdsRef.current,
122+
originalLocaleIds: changedLocaleIds,
117123
}),
118124
},
119125
);

src/app/dashboard/apps/[appId]/aso/keywords/_components/locale-card.tsx

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
CheckCircle,
1313
} from "@phosphor-icons/react";
1414
import { localeName, FIELD_LIMITS } from "@/lib/asc/locale-names";
15+
import { buildForbiddenKeywords } from "@/lib/asc/keyword-utils";
1516
import { KeywordTagInput } from "@/components/keyword-tag-input";
1617
import { CharCount } from "@/components/char-count";
1718
import { AICompareDialog } from "@/components/ai-compare-dialog";
@@ -99,24 +100,11 @@ export function LocaleCard({
99100
})
100101
.join(",");
101102

102-
// Build forbidden words: other locale keywords + app name/subtitle words
103-
const forbiddenSet = new Set<string>();
104-
for (const kw of Object.values(otherLocaleKeywords)) {
105-
for (const w of kw.split(",")) {
106-
const trimmed = w.trim().toLowerCase();
107-
if (trimmed) forbiddenSet.add(trimmed);
108-
}
109-
}
110-
if (appName) {
111-
for (const w of appName.toLowerCase().split(/[\s\-/&]+/)) {
112-
if (w.length > 1) forbiddenSet.add(w);
113-
}
114-
}
115-
if (appSubtitle) {
116-
for (const w of appSubtitle.toLowerCase().split(/[\s\-/&]+/)) {
117-
if (w.length > 1) forbiddenSet.add(w);
118-
}
119-
}
103+
const forbiddenWords = buildForbiddenKeywords({
104+
appName,
105+
subtitle: appSubtitle ?? undefined,
106+
otherLocaleKeywords,
107+
});
120108

121109
setCompareState({
122110
title: `Improve keywords – ${localeName(data.locale)}`,
@@ -129,7 +117,7 @@ export function LocaleCard({
129117
subtitle: appSubtitle,
130118
charLimit: FIELD_LIMITS.keywords,
131119
description,
132-
forbiddenWords: [...forbiddenSet],
120+
forbiddenWords,
133121
},
134122
});
135123
}

src/app/dashboard/apps/[appId]/details/page.tsx

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useCallback, useEffect, useRef } from "react";
3+
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
44
import { useParams, useSearchParams } from "next/navigation";
55
import { Card, CardContent } from "@/components/ui/card";
66
import { Input } from "@/components/ui/input";
@@ -29,17 +29,20 @@ import { CATEGORIES, categoryName } from "@/lib/asc/categories";
2929
import { CharCount } from "@/components/char-count";
3030
import { useRegisterHeaderLocale } from "@/lib/header-locale-context";
3131
import { useLocaleManagement } from "@/lib/hooks/use-locale-management";
32-
import { useLocaleHandlers } from "@/lib/hooks/use-locale-handlers";
32+
import { RemoveLocaleDialog } from "@/components/remove-locale-dialog";
3333
import { apiFetch } from "@/lib/api-fetch";
3434
import { useSubmissionChecklist } from "@/lib/submission-checklist-context";
3535
import { computeAppDetailsFlags } from "@/lib/submission-checklist-utils";
3636
import { MagicWandButton, wandProps } from "@/components/magic-wand-button";
3737
import type { MagicWandLocaleProps } from "@/components/magic-wand-button";
3838
import { BulkAIDialog, type BulkField } from "@/components/bulk-ai-dialog";
3939
import { BulkAllAIDialog } from "@/components/bulk-all-ai-dialog";
40+
import { AddLocaleDialog } from "@/components/add-locale-dialog";
4041
import { EmptyState } from "@/components/empty-state";
4142
import { useTabNavigation } from "@/lib/hooks/use-tab-navigation";
4243
import { useRegisterRefresh } from "@/lib/refresh-context";
44+
import { useVersions } from "@/lib/versions-context";
45+
import { resolveVersion } from "@/lib/asc/version-types";
4346

4447
const SORTED_CATEGORIES = Object.keys(CATEGORIES).sort((a, b) =>
4548
CATEGORIES[a].localeCompare(CATEGORIES[b]),
@@ -107,6 +110,13 @@ export default function AppDetailsPage() {
107110
busy: refreshLoading,
108111
});
109112

113+
const { versions } = useVersions();
114+
const selectedVersion = useMemo(
115+
() => resolveVersion(versions, searchParams.get("version")),
116+
[versions, searchParams],
117+
);
118+
const versionId = selectedVersion?.id ?? "";
119+
110120
const primaryLocale = app?.primaryLocale ?? "";
111121

112122
const [localeData, setLocaleData] = useState<
@@ -156,6 +166,8 @@ export default function AppDetailsPage() {
156166

157167
const [bulkMode, setBulkMode] = useState<"translate" | "copy" | null>(null);
158168
const [bulkAllMode, setBulkAllMode] = useState<{ mode: "translate" | "copy"; field?: string } | null>(null);
169+
const [addLocaleCode, setAddLocaleCode] = useState<string | null>(null);
170+
const [removeLocaleCode, setRemoveLocaleCode] = useState<string | null>(null);
159171

160172
function handleBulkApply(updates: Record<string, Record<string, string>>) {
161173
setLocaleData((prev) => {
@@ -407,26 +419,14 @@ export default function AppDetailsPage() {
407419
[selectedLocale, setDirty],
408420
);
409421

410-
const { handleAddLocale, handleBulkAddLocales, handleDeleteLocale } = useLocaleHandlers({
411-
localeData,
412-
setLocaleData,
413-
setLocales,
414-
selectedLocale,
415-
changeLocale,
416-
primaryLocale,
417-
setDirty,
418-
emptyFields: emptyLocaleFields,
419-
});
420-
421422
// Register locale picker in the header bar
422423
useRegisterHeaderLocale({
423424
locales,
424425
selectedLocale,
425426
primaryLocale,
426427
onLocaleChange: changeLocale,
427-
onLocaleAdd: handleAddLocale,
428-
onLocalesAdd: handleBulkAddLocales,
429-
onLocaleDelete: handleDeleteLocale,
428+
onLocaleAdd: (code: string) => setAddLocaleCode(code),
429+
onLocaleDelete: (code: string) => setRemoveLocaleCode(code),
430430
onBulkTranslate: () => setBulkMode("translate"),
431431
onBulkCopy: () => setBulkMode("copy"),
432432
onBulkTranslateAll: () => setBulkAllMode({ mode: "translate" }),
@@ -603,6 +603,40 @@ export default function AppDetailsPage() {
603603
appName={app?.name}
604604
onApply={handleBulkApply}
605605
/>
606+
<AddLocaleDialog
607+
open={addLocaleCode !== null}
608+
onOpenChange={(open) => { if (!open) setAddLocaleCode(null); }}
609+
locale={addLocaleCode ?? ""}
610+
appId={appId}
611+
primaryLocale={primaryLocale}
612+
appName={app?.name}
613+
versionId={versionId}
614+
appInfoId={appInfoId}
615+
onCreated={() => {
616+
refreshLocalizations();
617+
if (addLocaleCode) changeLocale(addLocaleCode);
618+
}}
619+
/>
620+
<RemoveLocaleDialog
621+
open={removeLocaleCode !== null}
622+
onOpenChange={(open) => { if (!open) setRemoveLocaleCode(null); }}
623+
locale={removeLocaleCode ?? ""}
624+
appId={appId}
625+
versionId={versionId}
626+
appInfoId={appInfoId}
627+
sections={{
628+
storeListing: otherSectionLocales["store-listing"]?.includes(removeLocaleCode ?? "") ?? false,
629+
appDetails: locales.includes(removeLocaleCode ?? ""),
630+
screenshots: otherSectionLocales.screenshots?.includes(removeLocaleCode ?? "") ?? false,
631+
}}
632+
onRemoved={() => {
633+
if (removeLocaleCode === selectedLocale) {
634+
const remaining = locales.filter((l) => l !== removeLocaleCode);
635+
changeLocale(remaining[0] ?? primaryLocale);
636+
}
637+
refreshLocalizations();
638+
}}
639+
/>
606640

607641
{/* Categories */}
608642
<section className="space-y-2">

src/app/dashboard/apps/[appId]/screenshots/page.tsx

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ import { useApps } from "@/lib/apps-context";
4141
import { useVersions } from "@/lib/versions-context";
4242
import { resolveVersion, EDITABLE_STATES } from "@/lib/asc/version-types";
4343
import { useLocalizations } from "@/lib/hooks/use-localizations";
44+
import { useAppInfo } from "@/lib/hooks/use-app-info";
45+
import { pickAppInfo } from "@/lib/asc/app-info-utils";
4446
import { useScreenshotSets } from "@/lib/hooks/use-screenshot-sets";
47+
import { RemoveLocaleDialog } from "@/components/remove-locale-dialog";
4548
import { localeName, sortLocales } from "@/lib/asc/locale-names";
4649
import {
4750
screenshotImageUrl,
@@ -569,7 +572,10 @@ export default function ScreenshotsPage() {
569572
appId,
570573
versionId,
571574
);
575+
const { appInfos } = useAppInfo(appId);
576+
const appInfoId = useMemo(() => pickAppInfo(appInfos)?.id ?? "", [appInfos]);
572577
const primaryLocale = app?.primaryLocale ?? "";
578+
const [removeLocaleCode, setRemoveLocaleCode] = useState<string | null>(null);
573579

574580
const {
575581
locales, setLocales,
@@ -768,23 +774,6 @@ export default function ScreenshotsPage() {
768774
}
769775
}
770776

771-
function handleDeleteLocale(code: string) {
772-
const needsLocaleSwitch = selectedLocale === code;
773-
setLocales((prev) => prev.filter((l) => l !== code));
774-
if (needsLocaleSwitch) {
775-
const remaining = locales.filter((l) => l !== code);
776-
changeLocale(remaining[0] ?? "");
777-
}
778-
toast(`Removed ${localeName(code)}`, {
779-
action: {
780-
label: "Undo",
781-
onClick: () => {
782-
setLocales((prev) => sortLocales([...prev, code], primaryLocale));
783-
},
784-
},
785-
});
786-
}
787-
788777
// Register locale picker in the header bar
789778
useRegisterHeaderLocale({
790779
locales,
@@ -793,7 +782,7 @@ export default function ScreenshotsPage() {
793782
onLocaleChange: changeLocale,
794783
onLocaleAdd: handleAddLocale,
795784
onLocalesAdd: handleBulkAddLocales,
796-
onLocaleDelete: handleDeleteLocale,
785+
onLocaleDelete: (code: string) => setRemoveLocaleCode(code),
797786
section: "screenshots",
798787
otherSectionLocales,
799788
readOnly,
@@ -885,6 +874,26 @@ export default function ScreenshotsPage() {
885874
)}
886875
</>
887876
)}
877+
<RemoveLocaleDialog
878+
open={removeLocaleCode !== null}
879+
onOpenChange={(open) => { if (!open) setRemoveLocaleCode(null); }}
880+
locale={removeLocaleCode ?? ""}
881+
appId={appId}
882+
versionId={versionId}
883+
appInfoId={appInfoId}
884+
sections={{
885+
storeListing: otherSectionLocales["store-listing"]?.includes(removeLocaleCode ?? "") ?? false,
886+
appDetails: otherSectionLocales.details?.includes(removeLocaleCode ?? "") ?? false,
887+
screenshots: locales.includes(removeLocaleCode ?? ""),
888+
}}
889+
onRemoved={() => {
890+
if (removeLocaleCode === selectedLocale) {
891+
const remaining = locales.filter((l) => l !== removeLocaleCode);
892+
changeLocale(remaining[0] ?? primaryLocale);
893+
}
894+
refreshLocalizations();
895+
}}
896+
/>
888897
</div>
889898
);
890899
}

0 commit comments

Comments
 (0)