Procházet zdrojové kódy

Add Traditional Chinese localization and fix CV Maker gallery copy.

Introduce zh-Hant strings, locale detection, and template name translation for built-in and AI catalogs. Resolve the gallery subtitle staying in English after template fetch and refresh header text on language changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 před 4 dny
rodič
revize
f5a78d2e8f

+ 1 - 0
App for Indeed.xcodeproj/project.pbxproj

@@ -103,6 +103,7 @@
103
 				Base,
103
 				Base,
104
 				ar,
104
 				ar,
105
 				"zh-Hans",
105
 				"zh-Hans",
106
+				"zh-Hant",
106
 			);
107
 			);
107
 			mainGroup = 27D852772FB1D367008DF557;
108
 			mainGroup = 27D852772FB1D367008DF557;
108
 			minimizedProjectReferenceProxies = 1;
109
 			minimizedProjectReferenceProxies = 1;

+ 116 - 3
App for Indeed/Services/AppLocalization.swift

@@ -12,6 +12,7 @@ enum AppLanguage: CaseIterable {
12
     case english
12
     case english
13
     case arabic
13
     case arabic
14
     case chineseSimplified
14
     case chineseSimplified
15
+    case chineseTraditional
15
 
16
 
16
     var localeIdentifier: String {
17
     var localeIdentifier: String {
17
         switch self {
18
         switch self {
@@ -21,12 +22,22 @@ enum AppLanguage: CaseIterable {
21
             return "ar"
22
             return "ar"
22
         case .chineseSimplified:
23
         case .chineseSimplified:
23
             return "zh-Hans"
24
             return "zh-Hans"
25
+        case .chineseTraditional:
26
+            return "zh-Hant"
24
         }
27
         }
25
     }
28
     }
26
 
29
 
27
     static var systemLanguage: AppLanguage {
30
     static var systemLanguage: AppLanguage {
28
         let preferred = Locale.preferredLanguages.first ?? "en"
31
         let preferred = Locale.preferredLanguages.first ?? "en"
29
-        if preferred.lowercased().hasPrefix("zh") {
32
+        let lower = preferred.lowercased()
33
+        if lower.hasPrefix("zh-hant")
34
+            || lower.hasPrefix("zh-tw")
35
+            || lower.hasPrefix("zh-hk")
36
+            || lower.hasPrefix("zh-mo")
37
+            || lower.contains("-hant") {
38
+            return .chineseTraditional
39
+        }
40
+        if lower.hasPrefix("zh") {
30
             return .chineseSimplified
41
             return .chineseSimplified
31
         }
42
         }
32
         for language in AppLanguage.allCases where preferred.hasPrefix(language.localeIdentifier) {
43
         for language in AppLanguage.allCases where preferred.hasPrefix(language.localeIdentifier) {
@@ -44,6 +55,8 @@ enum AppLanguage: CaseIterable {
44
             return "Arabic"
55
             return "Arabic"
45
         case .chineseSimplified:
56
         case .chineseSimplified:
46
             return "Chinese (Simplified)"
57
             return "Chinese (Simplified)"
58
+        case .chineseTraditional:
59
+            return "Chinese (Traditional)"
47
         }
60
         }
48
     }
61
     }
49
 }
62
 }
@@ -66,11 +79,111 @@ func L(_ key: String) -> String {
66
     appLocalized(key, language: currentAppLanguage())
79
     appLocalized(key, language: currentAppLanguage())
67
 }
80
 }
68
 
81
 
69
-/// Localized CV template title; `name` is always the English localization key.
82
+/// Localized CV template title. Built-in templates use English keys in `Localizable.strings`;
83
+/// AI-generated titles fall back to per-word translation when no exact key exists.
70
 func localizedTemplateName(_ nameKey: String) -> String {
84
 func localizedTemplateName(_ nameKey: String) -> String {
71
-    L(nameKey)
85
+    let language = currentAppLanguage()
86
+    let trimmed = nameKey.trimmingCharacters(in: .whitespacesAndNewlines)
87
+    guard !trimmed.isEmpty else { return nameKey }
88
+
89
+    let exact = appLocalized(trimmed, language: language)
90
+    if exact != trimmed { return exact }
91
+
92
+    guard language != .english else { return trimmed }
93
+    return translateTemplateNameByTokens(trimmed, language: language)
72
 }
94
 }
73
 
95
 
96
+private func translateTemplateNameByTokens(_ name: String, language: AppLanguage) -> String {
97
+    let tokens = name.split(separator: " ").map(String.init)
98
+    guard !tokens.isEmpty else { return name }
99
+
100
+    let translated = tokens.map { token -> String in
101
+        let perWord = appLocalized(token, language: language)
102
+        if perWord != token { return perWord }
103
+        return templateNameTokenTranslation(token, language: language) ?? token
104
+    }
105
+
106
+    switch language {
107
+    case .chineseSimplified, .chineseTraditional:
108
+        return translated.joined()
109
+    case .arabic, .english:
110
+        return translated.joined(separator: " ")
111
+    }
112
+}
113
+
114
+/// Vocabulary for AI-invented template titles (e.g. “Creative Cascade”) when no full-string key exists.
115
+private func templateNameTokenTranslation(_ token: String, language: AppLanguage) -> String? {
116
+    let key = token.trimmingCharacters(in: .whitespacesAndNewlines)
117
+    guard !key.isEmpty else { return nil }
118
+
119
+    switch language {
120
+    case .chineseTraditional:
121
+        return TemplateNameTokenLexicon.zhHant[key] ?? TemplateNameTokenLexicon.zhHant[key.capitalized]
122
+    case .chineseSimplified:
123
+        return TemplateNameTokenLexicon.zhHans[key] ?? TemplateNameTokenLexicon.zhHans[key.capitalized]
124
+    case .arabic:
125
+        return TemplateNameTokenLexicon.ar[key] ?? TemplateNameTokenLexicon.ar[key.capitalized]
126
+    case .english:
127
+        return nil
128
+    }
129
+}
130
+
131
+private enum TemplateNameTokenLexicon {
132
+    static let zhHant: [String: String] = [
133
+        "AI": "人工智慧", "UI": "介面", "UX": "體驗", "ATS": "ATS",
134
+        "Airy": "通透", "Atlas": "地圖", "Axis": "軸線", "Bloom": "綻放",
135
+        "Blue": "藍", "Bold": "大膽", "Briefing": "簡報", "Cascade": "層疊",
136
+        "Chairman": "主席", "Charter": "憲章", "Circuit": "電路", "Clear": "清晰",
137
+        "Conduit": "管道", "Core": "核心", "Corporate": "企業", "Craft": "工藝",
138
+        "Creative": "創意", "Design": "設計", "Docket": "待辦", "Dynamo": "動力",
139
+        "Echo": "回音", "Edge": "邊緣", "Ember": "餘燼", "Estate": "莊園",
140
+        "Executive": "高階", "Facet": "刻面", "Flow": "流動", "Flux": "流變",
141
+        "Forge": "鍛造", "Frame": "框架", "Grid": "網格", "Harbor": "港灣",
142
+        "Horizon": "地平線", "Impact": "影響", "Kite": "風箏", "Lattice": "格柵",
143
+        "Ledger": "帳本", "Lens": "鏡頭", "Linea": "線條", "Marigold": "金盞花",
144
+        "Mesh": "網狀", "Minimal": "簡約", "Modern": "現代", "Mono": "單色",
145
+        "Monarch": "君主", "Nova": "新星", "North": "北方", "Ocean": "海洋",
146
+        "Path": "路徑", "Peak": "峰", "Pixel": "像素", "Pinstripe": "細條紋",
147
+        "Prime": "首要", "Prism": "稜鏡", "Professional": "專業", "Pulse": "脈動",
148
+        "Pure": "純淨", "Quorum": "法定人數", "Regent": "攝政", "River": "河",
149
+        "Sculptor": "雕刻", "Shift": "轉換", "Slate": "石板", "Spark": "火花",
150
+        "Sterling": "純正", "Stone": "石", "Studio": "工作室", "Summit": "頂峰",
151
+        "Swiss": "瑞士", "Swift": "迅捷", "Tabular": "表格", "Vale": "谷",
152
+        "Vertex": "頂點", "Wave": "波浪", "White": "白"
153
+    ]
154
+
155
+    static let zhHans: [String: String] = [
156
+        "AI": "人工智能", "UI": "界面", "UX": "体验", "ATS": "ATS",
157
+        "Airy": "通透", "Atlas": "地图", "Axis": "轴线", "Bloom": "绽放",
158
+        "Blue": "蓝", "Bold": "大胆", "Briefing": "简报", "Cascade": "层叠",
159
+        "Chairman": "主席", "Charter": "宪章", "Circuit": "电路", "Clear": "清晰",
160
+        "Conduit": "管道", "Core": "核心", "Corporate": "企业", "Craft": "工艺",
161
+        "Creative": "创意", "Design": "设计", "Docket": "待办", "Dynamo": "动力",
162
+        "Echo": "回音", "Edge": "边缘", "Ember": "余烬", "Estate": "庄园",
163
+        "Executive": "高管", "Facet": "刻面", "Flow": "流动", "Flux": "流变",
164
+        "Forge": "锻造", "Frame": "框架", "Grid": "网格", "Harbor": "港湾",
165
+        "Horizon": "地平线", "Impact": "影响", "Kite": "风筝", "Lattice": "格栅",
166
+        "Ledger": "账本", "Lens": "镜头", "Linea": "线条", "Marigold": "金盏花",
167
+        "Mesh": "网状", "Minimal": "简约", "Modern": "现代", "Mono": "单色",
168
+        "Monarch": "君主", "Nova": "新星", "North": "北方", "Ocean": "海洋",
169
+        "Path": "路径", "Peak": "峰", "Pixel": "像素", "Pinstripe": "细条纹",
170
+        "Prime": "首要", "Prism": "棱镜", "Professional": "专业", "Pulse": "脉动",
171
+        "Pure": "纯净", "Quorum": "法定人数", "Regent": "摄政", "River": "河",
172
+        "Sculptor": "雕刻", "Shift": "转换", "Slate": "石板", "Spark": "火花",
173
+        "Sterling": "纯正", "Stone": "石", "Studio": "工作室", "Summit": "顶峰",
174
+        "Swiss": "瑞士", "Swift": "迅捷", "Tabular": "表格", "Vale": "谷",
175
+        "Vertex": "顶点", "Wave": "波浪", "White": "白"
176
+    ]
177
+
178
+    static let ar: [String: String] = [
179
+        "Creative": "إبداعي", "Cascade": "تتالي", "Design": "تصميم", "Dynamo": "ديناميكي",
180
+        "Modern": "عصري", "Professional": "احترافي", "Executive": "تنفيذي", "Minimal": "بسيط",
181
+        "Ocean": "محيط", "Blue": "أزرق", "Summit": "قمة", "Horizon": "أفق", "Harbor": "ميناء",
182
+        "Studio": "استوديو", "Craft": "حرفة", "Sculptor": "نحات", "UI": "واجهة"
183
+    ]
184
+}
185
+
186
+
74
 @MainActor
187
 @MainActor
75
 final class AppLanguageManager {
188
 final class AppLanguageManager {
76
     static let shared = AppLanguageManager()
189
     static let shared = AppLanguageManager()

+ 56 - 17
App for Indeed/Services/CVTemplateFetchService.swift

@@ -30,15 +30,35 @@ final class CVTemplateFetchService {
30
         `minimal`, `professional`, and `executive` appear under Profession-Based (ATS-friendly / corporate / leadership). \
30
         `minimal`, `professional`, and `executive` appear under Profession-Based (ATS-friendly / corporate / leadership). \
31
         Output must strictly match the JSON schema — no markdown or extra keys.
31
         Output must strictly match the JSON schema — no markdown or extra keys.
32
         """
32
         """
33
-        static let userInput = """
34
-        Generate the template catalog now. Exactly six entries per family: professional, modern, creative, minimal, executive. \
35
-        Use both singleColumn and twoColumn layouts across the 30 rows. For twoColumn rows vary leading vs trailing sidebars and tinted true/false. \
36
-        Keep modern and creative entries suitable for design-led résumés; keep minimal, professional, and executive suitable for traditional industries.
37
-        """
33
+        static func userInput(language: AppLanguage) -> String {
34
+            """
35
+            Generate the template catalog now. Exactly six entries per family: professional, modern, creative, minimal, executive. \
36
+            Use both singleColumn and twoColumn layouts across the 30 rows. For twoColumn rows vary leading vs trailing sidebars and tinted true/false. \
37
+            Keep modern and creative entries suitable for design-led résumés; keep minimal, professional, and executive suitable for traditional industries. \
38
+            \(displayNameRule(for: language))
39
+            """
40
+        }
41
+
42
+        /// English `name` values double as localization keys in the app; keep them ASCII words.
43
+        static func displayNameRule(for language: AppLanguage) -> String {
44
+            switch language {
45
+            case .english:
46
+                return "Each `name` must be 1–3 English words (ASCII letters and spaces only), e.g. \"Creative Cascade\"."
47
+            case .chineseTraditional:
48
+                return "Each `name` must be 1–3 English words (ASCII letters and spaces only) suitable as localization keys, e.g. \"Creative Cascade\" — do not use Chinese characters in `name`."
49
+            case .chineseSimplified:
50
+                return "Each `name` must be 1–3 English words (ASCII letters and spaces only) suitable as localization keys, e.g. \"Design Dynamo\" — do not use Chinese characters in `name`."
51
+            case .arabic:
52
+                return "Each `name` must be 1–3 English words (ASCII letters and spaces only) suitable as localization keys, e.g. \"UI Sculptor\" — do not use Arabic script in `name`."
53
+            }
54
+        }
38
     }
55
     }
39
 
56
 
40
     /// Loads templates from OpenAI (one automatic retry on transient network / parse failures).
57
     /// Loads templates from OpenAI (one automatic retry on transient network / parse failures).
41
-    func fetchTemplates(completion: @escaping (Result<[CVTemplate], Error>) -> Void) {
58
+    func fetchTemplates(
59
+        language: AppLanguage = currentAppLanguage(),
60
+        completion: @escaping (Result<[CVTemplate], Error>) -> Void
61
+    ) {
42
         let apiKey = OpenAIConfiguration.apiKey
62
         let apiKey = OpenAIConfiguration.apiKey
43
         guard OpenAIConfiguration.hasAPIKey else {
63
         guard OpenAIConfiguration.hasAPIKey else {
44
             completion(.failure(Self.missingKeyError))
64
             completion(.failure(Self.missingKeyError))
@@ -52,22 +72,29 @@ final class CVTemplateFetchService {
52
         request.timeoutInterval = 60
72
         request.timeoutInterval = 60
53
 
73
 
54
         do {
74
         do {
55
-            request.httpBody = try Self.encodeCatalogRequestBody()
75
+            request.httpBody = try Self.encodeCatalogRequestBody(language: language)
56
         } catch {
76
         } catch {
57
             completion(.failure(error))
77
             completion(.failure(error))
58
             return
78
             return
59
         }
79
         }
60
 
80
 
61
         session.dataTask(with: request) { data, response, error in
81
         session.dataTask(with: request) { data, response, error in
62
-            Self.handleFetchData(data, response: response, error: error, attempt: 0, completion: completion)
82
+            Self.handleFetchData(
83
+                data,
84
+                response: response,
85
+                error: error,
86
+                language: language,
87
+                attempt: 0,
88
+                completion: completion
89
+            )
63
         }.resume()
90
         }.resume()
64
     }
91
     }
65
 
92
 
66
-    private static func encodeCatalogRequestBody() throws -> Data {
93
+    private static func encodeCatalogRequestBody(language: AppLanguage) throws -> Data {
67
         let payload = CVTemplateOpenAIRequest.catalogPayload(
94
         let payload = CVTemplateOpenAIRequest.catalogPayload(
68
             model: "gpt-4o-mini",
95
             model: "gpt-4o-mini",
69
             instructions: CVTemplateCatalogPrompt.instructions,
96
             instructions: CVTemplateCatalogPrompt.instructions,
70
-            input: CVTemplateCatalogPrompt.userInput
97
+            input: CVTemplateCatalogPrompt.userInput(language: language)
71
         )
98
         )
72
         return try JSONEncoder().encode(payload)
99
         return try JSONEncoder().encode(payload)
73
     }
100
     }
@@ -76,13 +103,14 @@ final class CVTemplateFetchService {
76
         _ data: Data?,
103
         _ data: Data?,
77
         response: URLResponse?,
104
         response: URLResponse?,
78
         error: Error?,
105
         error: Error?,
106
+        language: AppLanguage,
79
         attempt: Int,
107
         attempt: Int,
80
         completion: @escaping (Result<[CVTemplate], Error>) -> Void
108
         completion: @escaping (Result<[CVTemplate], Error>) -> Void
81
     ) {
109
     ) {
82
         if let error {
110
         if let error {
83
             if attempt == 0 {
111
             if attempt == 0 {
84
                 DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) {
112
                 DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) {
85
-                    refetchSameRequest(attempt: 1, completion: completion)
113
+                    refetchSameRequest(language: language, attempt: 1, completion: completion)
86
                 }
114
                 }
87
                 return
115
                 return
88
             }
116
             }
@@ -92,7 +120,7 @@ final class CVTemplateFetchService {
92
         guard let data else {
120
         guard let data else {
93
             if attempt == 0 {
121
             if attempt == 0 {
94
                 DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) {
122
                 DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) {
95
-                    refetchSameRequest(attempt: 1, completion: completion)
123
+                    refetchSameRequest(language: language, attempt: 1, completion: completion)
96
                 }
124
                 }
97
                 return
125
                 return
98
             }
126
             }
@@ -102,7 +130,7 @@ final class CVTemplateFetchService {
102
         if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
130
         if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
103
             if attempt == 0, (500...599).contains(http.statusCode) || http.statusCode == 429 {
131
             if attempt == 0, (500...599).contains(http.statusCode) || http.statusCode == 429 {
104
                 DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.5) {
132
                 DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.5) {
105
-                    refetchSameRequest(attempt: 1, completion: completion)
133
+                    refetchSameRequest(language: language, attempt: 1, completion: completion)
106
                 }
134
                 }
107
                 return
135
                 return
108
             }
136
             }
@@ -138,7 +166,7 @@ final class CVTemplateFetchService {
138
         } catch {
166
         } catch {
139
             if attempt == 0 {
167
             if attempt == 0 {
140
                 DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) {
168
                 DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2.0) {
141
-                    refetchSameRequest(attempt: 1, completion: completion)
169
+                    refetchSameRequest(language: language, attempt: 1, completion: completion)
142
                 }
170
                 }
143
                 return
171
                 return
144
             }
172
             }
@@ -146,7 +174,11 @@ final class CVTemplateFetchService {
146
         }
174
         }
147
     }
175
     }
148
 
176
 
149
-    private static func refetchSameRequest(attempt: Int, completion: @escaping (Result<[CVTemplate], Error>) -> Void) {
177
+    private static func refetchSameRequest(
178
+        language: AppLanguage,
179
+        attempt: Int,
180
+        completion: @escaping (Result<[CVTemplate], Error>) -> Void
181
+    ) {
150
         let apiKey = OpenAIConfiguration.apiKey
182
         let apiKey = OpenAIConfiguration.apiKey
151
         guard OpenAIConfiguration.hasAPIKey else {
183
         guard OpenAIConfiguration.hasAPIKey else {
152
             completion(.failure(missingKeyError))
184
             completion(.failure(missingKeyError))
@@ -157,13 +189,20 @@ final class CVTemplateFetchService {
157
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
189
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
158
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
190
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
159
         request.timeoutInterval = 60
191
         request.timeoutInterval = 60
160
-        guard let body = try? encodeCatalogRequestBody() else {
192
+        guard let body = try? encodeCatalogRequestBody(language: language) else {
161
             completion(.failure(NSError(domain: "CVTemplateFetchService", code: 11, userInfo: [NSLocalizedDescriptionKey: "Could not encode request."])))
193
             completion(.failure(NSError(domain: "CVTemplateFetchService", code: 11, userInfo: [NSLocalizedDescriptionKey: "Could not encode request."])))
162
             return
194
             return
163
         }
195
         }
164
         request.httpBody = body
196
         request.httpBody = body
165
         URLSession(configuration: .ephemeral).dataTask(with: request) { data, response, error in
197
         URLSession(configuration: .ephemeral).dataTask(with: request) { data, response, error in
166
-            handleFetchData(data, response: response, error: error, attempt: attempt, completion: completion)
198
+            handleFetchData(
199
+                data,
200
+                response: response,
201
+                error: error,
202
+                language: language,
203
+                attempt: attempt,
204
+                completion: completion
205
+            )
167
         }.resume()
206
         }.resume()
168
     }
207
     }
169
 
208
 

+ 66 - 14
App for Indeed/Views/CVMakerPageView.swift

@@ -583,13 +583,19 @@ final class CVMakerPageView: NSView {
583
 
583
 
584
     private var appearanceObserver: NSObjectProtocol?
584
     private var appearanceObserver: NSObjectProtocol?
585
     private var languageObserver: NSObjectProtocol?
585
     private var languageObserver: NSObjectProtocol?
586
+    /// AI catalog keyed by `AppLanguage.localeIdentifier` so switching language can restore without a network round-trip.
587
+    private var aiCatalogByLocale: [String: [CVTemplate]] = [:]
588
+    /// Ignores stale OpenAI responses after a newer fetch or language change started.
589
+    private var aiCatalogFetchGeneration = 0
586
 
590
 
587
     private let pageGradientLayer = CAGradientLayer()
591
     private let pageGradientLayer = CAGradientLayer()
588
     private let filterChrome = NSVisualEffectView()
592
     private let filterChrome = NSVisualEffectView()
589
     private let filterStack = NSStackView()
593
     private let filterStack = NSStackView()
590
 
594
 
591
-    private let titleLabel = NSTextField(labelWithString: L("Templates"))
592
-    private let subtitleLabel = NSTextField(labelWithString: L("Polished layouts with live previews — pick a style that fits your story."))
595
+    private static let gallerySubtitleKey = "Polished layouts with live previews — pick a style that fits your story."
596
+
597
+    private let titleLabel = NSTextField(labelWithString: "")
598
+    private let subtitleLabel = NSTextField(labelWithString: "")
593
     private let groupTabsRow = NSStackView()
599
     private let groupTabsRow = NSStackView()
594
     private let familyChipsRow = NSStackView()
600
     private let familyChipsRow = NSStackView()
595
     private let scrollView = NSScrollView()
601
     private let scrollView = NSScrollView()
@@ -644,7 +650,6 @@ final class CVMakerPageView: NSView {
644
         reloadFamilyChips()
650
         reloadFamilyChips()
645
         reloadTemplateGrid()
651
         reloadTemplateGrid()
646
         updateSelectedChipStates()
652
         updateSelectedChipStates()
647
-        beginLoadingAICatalogIfPossible()
648
         appearanceObserver = NotificationCenter.default.addObserver(
653
         appearanceObserver = NotificationCenter.default.addObserver(
649
             forName: AppAppearanceManager.didChangeNotification,
654
             forName: AppAppearanceManager.didChangeNotification,
650
             object: nil,
655
             object: nil,
@@ -658,9 +663,11 @@ final class CVMakerPageView: NSView {
658
             queue: .main
663
             queue: .main
659
         ) { [weak self] _ in
664
         ) { [weak self] _ in
660
             self?.applyLocalizedStrings()
665
             self?.applyLocalizedStrings()
666
+            self?.restoreOrLoadAICatalogForCurrentLanguage()
661
         }
667
         }
662
         applyCurrentAppearance()
668
         applyCurrentAppearance()
663
         applyLocalizedStrings()
669
         applyLocalizedStrings()
670
+        beginLoadingAICatalogIfPossible()
664
     }
671
     }
665
 
672
 
666
     deinit {
673
     deinit {
@@ -700,9 +707,17 @@ final class CVMakerPageView: NSView {
700
         updateSelectedChipStates()
707
         updateSelectedChipStates()
701
     }
708
     }
702
 
709
 
703
-    func applyLocalizedStrings() {
710
+    private func gallerySubtitleText() -> String {
711
+        L(Self.gallerySubtitleKey)
712
+    }
713
+
714
+    private func applyGalleryHeaderCopy() {
704
         titleLabel.stringValue = L("Templates")
715
         titleLabel.stringValue = L("Templates")
705
-        subtitleLabel.stringValue = L("Polished layouts with live previews — pick a style that fits your story.")
716
+        subtitleLabel.stringValue = gallerySubtitleText()
717
+    }
718
+
719
+    func applyLocalizedStrings() {
720
+        applyGalleryHeaderCopy()
706
         styleCTAButton(ctaButton)
721
         styleCTAButton(ctaButton)
707
         configureGroupTabs()
722
         configureGroupTabs()
708
         reloadFamilyChips()
723
         reloadFamilyChips()
@@ -755,7 +770,7 @@ final class CVMakerPageView: NSView {
755
         subtitleLabel.font = .systemFont(ofSize: 13, weight: .regular)
770
         subtitleLabel.font = .systemFont(ofSize: 13, weight: .regular)
756
         subtitleLabel.textColor = Palette.secondaryText
771
         subtitleLabel.textColor = Palette.secondaryText
757
         subtitleLabel.alignment = .left
772
         subtitleLabel.alignment = .left
758
-        subtitleLabel.maximumNumberOfLines = 1
773
+        subtitleLabel.maximumNumberOfLines = 2
759
 
774
 
760
         let headerStack = NSStackView(views: [titleLabel, subtitleLabel])
775
         let headerStack = NSStackView(views: [titleLabel, subtitleLabel])
761
         headerStack.orientation = .vertical
776
         headerStack.orientation = .vertical
@@ -1105,17 +1120,49 @@ final class CVMakerPageView: NSView {
1105
         }
1120
         }
1106
     }
1121
     }
1107
 
1122
 
1123
+    private func restoreOrLoadAICatalogForCurrentLanguage() {
1124
+        aiCatalogFetchGeneration += 1
1125
+        applyGalleryHeaderCopy()
1126
+        let locale = currentAppLanguage().localeIdentifier
1127
+        if let cached = aiCatalogByLocale[locale], !cached.isEmpty {
1128
+            activeCatalog = cached
1129
+            configureGroupTabs()
1130
+            reloadFamilyChips()
1131
+            reloadTemplateGrid()
1132
+            updateSelectedChipStates()
1133
+            return
1134
+        }
1135
+        beginLoadingAICatalogIfPossible()
1136
+    }
1137
+
1108
     private func beginLoadingAICatalogIfPossible() {
1138
     private func beginLoadingAICatalogIfPossible() {
1109
-        guard OpenAIConfiguration.hasAPIKey else { return }
1110
-        let defaultSubtitle = L("Polished layouts with live previews — pick a style that fits your story.")
1139
+        guard OpenAIConfiguration.hasAPIKey else {
1140
+            applyGalleryHeaderCopy()
1141
+            return
1142
+        }
1143
+        let language = currentAppLanguage()
1144
+        let locale = language.localeIdentifier
1145
+        if let cached = aiCatalogByLocale[locale], !cached.isEmpty {
1146
+            activeCatalog = cached
1147
+            applyGalleryHeaderCopy()
1148
+            configureGroupTabs()
1149
+            reloadFamilyChips()
1150
+            reloadTemplateGrid()
1151
+            updateSelectedChipStates()
1152
+            return
1153
+        }
1154
+
1155
+        aiCatalogFetchGeneration += 1
1156
+        let generation = aiCatalogFetchGeneration
1111
         subtitleLabel.stringValue = L("Fetching AI-curated templates…")
1157
         subtitleLabel.stringValue = L("Fetching AI-curated templates…")
1112
-        CVTemplateFetchService.shared.fetchTemplates { [weak self] result in
1158
+        CVTemplateFetchService.shared.fetchTemplates(language: language) { [weak self] result in
1113
             DispatchQueue.main.async {
1159
             DispatchQueue.main.async {
1114
-                guard let self else { return }
1160
+                guard let self, self.aiCatalogFetchGeneration == generation else { return }
1115
                 switch result {
1161
                 switch result {
1116
                 case .success(let list):
1162
                 case .success(let list):
1117
-                    self.subtitleLabel.stringValue = defaultSubtitle
1163
+                    self.applyGalleryHeaderCopy()
1118
                     if !list.isEmpty {
1164
                     if !list.isEmpty {
1165
+                        self.aiCatalogByLocale[locale] = list
1119
                         self.activeCatalog = list
1166
                         self.activeCatalog = list
1120
                         self.configureGroupTabs()
1167
                         self.configureGroupTabs()
1121
                         self.reloadFamilyChips()
1168
                         self.reloadFamilyChips()
@@ -1125,7 +1172,8 @@ final class CVMakerPageView: NSView {
1125
                 case .failure:
1172
                 case .failure:
1126
                     self.subtitleLabel.stringValue = L("Couldn’t load AI templates — showing the built-in gallery.")
1173
                     self.subtitleLabel.stringValue = L("Couldn’t load AI templates — showing the built-in gallery.")
1127
                     DispatchQueue.main.asyncAfter(deadline: .now() + 5.5) { [weak self] in
1174
                     DispatchQueue.main.asyncAfter(deadline: .now() + 5.5) { [weak self] in
1128
-                        self?.subtitleLabel.stringValue = defaultSubtitle
1175
+                        guard let self, self.aiCatalogFetchGeneration == generation else { return }
1176
+                        self.applyGalleryHeaderCopy()
1129
                     }
1177
                     }
1130
                 }
1178
                 }
1131
             }
1179
             }
@@ -1408,7 +1456,7 @@ private final class CVTemplateCard: NSView {
1408
         preview.translatesAutoresizingMaskIntoConstraints = false
1456
         preview.translatesAutoresizingMaskIntoConstraints = false
1409
         previewSurface.addSubview(preview)
1457
         previewSurface.addSubview(preview)
1410
 
1458
 
1411
-        nameLabel.stringValue = template.localizedName
1459
+        refreshLocalizedTitle()
1412
         nameLabel.font = .systemFont(ofSize: 14, weight: .semibold)
1460
         nameLabel.font = .systemFont(ofSize: 14, weight: .semibold)
1413
         nameLabel.textColor = palette.primaryText
1461
         nameLabel.textColor = palette.primaryText
1414
         nameLabel.isBordered = false
1462
         nameLabel.isBordered = false
@@ -1416,7 +1464,6 @@ private final class CVTemplateCard: NSView {
1416
         nameLabel.isEditable = false
1464
         nameLabel.isEditable = false
1417
         nameLabel.isSelectable = false
1465
         nameLabel.isSelectable = false
1418
 
1466
 
1419
-        categoryLabel.stringValue = "\(template.category) · \(template.layoutType.gallerySubtitle)"
1420
         categoryLabel.font = .systemFont(ofSize: 11.5, weight: .regular)
1467
         categoryLabel.font = .systemFont(ofSize: 11.5, weight: .regular)
1421
         categoryLabel.textColor = palette.secondaryText
1468
         categoryLabel.textColor = palette.secondaryText
1422
         categoryLabel.isBordered = false
1469
         categoryLabel.isBordered = false
@@ -1468,6 +1515,11 @@ private final class CVTemplateCard: NSView {
1468
         fatalError("init(coder:) has not been implemented")
1515
         fatalError("init(coder:) has not been implemented")
1469
     }
1516
     }
1470
 
1517
 
1518
+    func refreshLocalizedTitle() {
1519
+        nameLabel.stringValue = template.localizedName
1520
+        categoryLabel.stringValue = "\(template.category) · \(template.layoutType.gallerySubtitle)"
1521
+    }
1522
+
1471
     override func layout() {
1523
     override func layout() {
1472
         super.layout()
1524
         super.layout()
1473
         layer?.shadowPath = CGPath(roundedRect: bounds, cornerWidth: 24, cornerHeight: 24, transform: nil)
1525
         layer?.shadowPath = CGPath(roundedRect: bounds, cornerWidth: 24, cornerHeight: 24, transform: nil)

+ 353 - 0
App for Indeed/zh-Hant.lproj/Localizable.strings

@@ -0,0 +1,353 @@
1
+/* Localizable.strings (繁體中文) */
2
+
3
+// MARK: - 通用
4
+"OK" = "確定";
5
+"Cancel" = "取消";
6
+"Delete" = "刪除";
7
+"Remove" = "移除";
8
+"Dismiss" = "關閉";
9
+
10
+// MARK: - 啟動畫面
11
+"AI-POWERED" = "人工智慧驅動";
12
+"Find your perfect job with the power of AI." = "藉助人工智慧的力量,找到您的理想工作。";
13
+"Starting up…" = "啟動中…";
14
+"Loading progress" = "載入進度";
15
+
16
+// MARK: - 啟動狀態
17
+"Checking your Pro subscription…" = "正在檢查您的專業版訂閱…";
18
+"Loading premium plans from the App Store…" = "正在從 App Store 載入高級方案…";
19
+"Preparing your job search workspace…" = "正在準備您的工作搜尋空間…";
20
+"Almost ready…" = "即將就緒…";
21
+
22
+// MARK: - 側邊欄
23
+"Home" = "首頁";
24
+"Saved Jobs" = "已儲存的工作";
25
+"CV Maker" = "履歷製作器";
26
+"Profile" = "個人資料";
27
+"Settings" = "設定";
28
+"Premium" = "高級版";
29
+"Indeed" = "Indeed";
30
+"Open Indeed to search and apply for jobs" = "開啟 Indeed 搜尋並申請工作";
31
+
32
+// MARK: - 儀表板 / 首頁
33
+"Welcome" = "歡迎";
34
+"Send" = "傳送";
35
+"Clear chat" = "清空聊天";
36
+"Remove all messages and start a new conversation" = "刪除所有訊息並開始新對話";
37
+"Ask for roles, skills, salary, or job descriptions..." = "詢問職位、技能、薪資或職位描述...";
38
+"Ask AI" = "詢問人工智慧";
39
+"1 reply left" = "剩餘 1 次回覆";
40
+"Apply" = "申請";
41
+"Save" = "儲存";
42
+"Saved" = "已儲存";
43
+"Remove from saved" = "從已儲存中移除";
44
+"Show more jobs" = "顯示更多工作";
45
+"This area is not available in the preview build. Use Home to search jobs." = "此區域在預覽版中不可用。請使用首頁搜尋工作。";
46
+"Save jobs from Home to see them here." = "從首頁儲存的工作將顯示在此處。";
47
+"No saved jobs yet. Search on Home, then tap Save on a listing." = "暫無已儲存的工作。在首頁搜尋,然後點擊列表上的儲存。";
48
+"Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary." = "告訴我您想要的職位,我將返回職位描述、關鍵技能和快速配對摘要。";
49
+"1 saved position" = "1 個已儲存職位";
50
+"Delete this profile?" = "刪除此個人資料?";
51
+"Find roles similar to: " = "尋找類似職位:";
52
+"Find jobs at company: " = "尋找公司職位:";
53
+"Find jobs that require skill: " = "尋找需要該技能的工作:";
54
+"match" = "配對";
55
+"matches" = "配對";
56
+
57
+// MARK: - 功能捷徑
58
+"Role" = "職位";
59
+"Explore similar or better job roles" = "探索相似或更好的職位";
60
+"Company" = "公司";
61
+"Find opportunities at other companies" = "尋找其他公司的機會";
62
+"Skill" = "技能";
63
+"Match jobs that fit your skills" = "配對適合您技能的工作";
64
+
65
+// MARK: - 專業版 / 訂閱
66
+"Upgrade to Pro" = "升級到專業版";
67
+"You're on Pro" = "您正在使用專業版";
68
+"Unlimited AI matches, smart alerts, and interview prep—all in one place." = "無限人工智慧配對、智慧提醒和面試準備——所有這些都在一處。";
69
+"Manage billing, renewals, and plans in Premium." = "在高級版中管理帳單、續訂和方案。";
70
+"Try Pro" = "試用專業版";
71
+"Manage Subscription" = "管理訂閱";
72
+"Premium Plans" = "高級方案";
73
+"Unlock unlimited access to premium tools and boost your productivity." = "解鎖無限存取高級工具,提升您的生產力。";
74
+"Continue with free plan" = "繼續使用免費方案";
75
+"Restore Purchase" = "回復購買";
76
+"You're subscribed" = "您已訂閱";
77
+"Thank you — Pro features are now available." = "感謝您 — 專業版功能現已可用。";
78
+"Pro" = "專業版";
79
+"Purchases restored" = "購買已回復";
80
+"Your subscription is active." = "您的訂閱有效。";
81
+"No subscription found" = "未找到訂閱";
82
+"There was nothing to restore for this Apple ID." = "此 Apple ID 沒有可回復的內容。";
83
+"Something went wrong" = "出了點問題";
84
+"That subscription isn’t available from the App Store right now." = "該訂閱目前無法從 App Store 取得。";
85
+"Unlimited AI job search on Home" = "首頁無限人工智慧工作搜尋";
86
+"Save jobs & open listings in-app" = "儲存工作並在應用程式內開啟列表";
87
+"CV Maker, profiles & PDF export" = "履歷製作器、個人資料和可攜式文件匯出";
88
+"Role, company & skill shortcuts" = "職位、公司和技能捷徑";
89
+
90
+// MARK: - 付費牆方案
91
+"Weekly" = "每週";
92
+"Flexible and commitment-free" = "彈性且無承諾";
93
+"Monthly" = "每月";
94
+"Balanced for regular productivity" = "為常規生產力平衡設計";
95
+"Yearly" = "每年";
96
+"Best value for long-term users" = "長期使用者的最佳價值";
97
+"/ week" = "/週";
98
+"/ month" = "/月";
99
+"/ year" = "/年";
100
+"3 days free trial" = "3天免費試用";
101
+"Perfect for short-term job hunts" = "非常適合短期求職";
102
+"Cancel anytime" = "隨時取消";
103
+"Best for regular job seekers" = "最適合常規求職者";
104
+"Priority support" = "優先支援";
105
+"Lowest effective monthly cost" = "最低有效月成本";
106
+"Ideal for long-term use" = "非常適合長期使用";
107
+
108
+// MARK: - 付費牆信任
109
+"Secure Payments" = "安全付款";
110
+"Your payment is 100% secure." = "您的付款 100% 安全。";
111
+"Cancel Anytime" = "隨時取消";
112
+"No commitment, cancel anytime." = "無承諾,隨時取消。";
113
+"24/7 Support" = "24/7 支援";
114
+"We're here to help you anytime." = "我們隨時為您提供協助。";
115
+"Privacy First" = "隱私優先";
116
+"Your data is safe with us." = "您的資料在我們這裡很安全。";
117
+
118
+// MARK: - 設定
119
+"Appearance" = "外觀";
120
+"Theme" = "主題";
121
+"Language" = "語言";
122
+"Share App" = "分享應用程式";
123
+"More Apps" = "更多應用程式";
124
+"About" = "關於";
125
+"Website" = "網站";
126
+"Support" = "支援";
127
+"Terms of Use" = "使用條款";
128
+"Privacy Policy" = "隱私權政策";
129
+"System" = "跟隨系統";
130
+"Light" = "淺色";
131
+"Dark" = "深色";
132
+
133
+// MARK: - 個人資料
134
+"Profiles" = "個人資料";
135
+"Add new profile" = "新增資料";
136
+"Create and manage CV profiles. Each profile stores your details on this Mac." = "建立和管理履歷資料。每個資料都會將您的詳細資訊儲存在此 Mac 上。";
137
+"No profiles yet. Tap “Add new profile” to create your first one." = "暫無個人資料。點擊「新增資料」建立您的第一個資料。";
138
+"Build CV" = "製作履歷";
139
+"Edit" = "編輯";
140
+"Untitled profile" = "未命名資料";
141
+"No contact details yet" = "暫無聯絡方式";
142
+"← Profiles" = "← 個人資料";
143
+
144
+// MARK: - 資料編輯器
145
+"Save Profile  →" = "儲存資料 →";
146
+"← All profiles" = "← 所有資料";
147
+"New profile" = "新建資料";
148
+"Edit profile" = "編輯資料";
149
+"Profile Name *" = "資料名稱 *";
150
+"Marketing Director Profile" = "行銷總監資料";
151
+"Personal Information" = "個人資訊";
152
+"Full Name *" = "全名 *";
153
+"John Doe" = "張三";
154
+"Email *" = "電子郵件 *";
155
+"john@example.com" = "zhangsan@example.com";
156
+"Phone" = "電話";
157
+"+1 (555) 123-4567" = "+886 912 345 678";
158
+"Job Title *" = "職位名稱 *";
159
+"Software Engineer" = "軟體工程師";
160
+"Address" = "地址";
161
+"123 Main St, City, State, ZIP" = "示例街道 123 號,城市,縣/市,郵遞區號";
162
+"Certificates / Rewards" = "證書 / 獎項";
163
+"List your certificates and awards..." = "列出您的證書和獎項...";
164
+"Interests" = "興趣愛好";
165
+"List your interests and hobbies..." = "列出您的興趣和愛好...";
166
+"Languages" = "語言";
167
+"List languages you speak (e.g., English - Native, Spanish - Fluent)..." = "列出您所說的語言(例如:中文 - 母語,英文 - 流利)...";
168
+"Career Summary" = "職業摘要";
169
+"Brief overview of your professional background and key achievements..." = "您的專業背景和主要成就的簡要概述...";
170
+"Referral (Optional)" = "推薦人(選填)";
171
+"Referred by (Company/Person Name)" = "推薦人(公司/個人名稱)";
172
+"If someone referred you for this job, enter their name or company here" = "如果有人推薦您申請此職位,請在此輸入其姓名或公司";
173
+"Work Experience" = "工作經歷";
174
+"Education" = "教育背景";
175
+"+ Add Another" = "+ 新增另一個";
176
+"Complete required fields" = "填寫必填欄位";
177
+"Remove experience" = "刪除經歷";
178
+"Remove education" = "刪除教育";
179
+"Company Name *" = "公司名稱 *";
180
+"Duration *" = "持續時間 *";
181
+"Description" = "描述";
182
+"e.g., Software Engineer" = "例如:軟體工程師";
183
+"e.g., Google" = "例如:Google";
184
+"e.g., Jan 2020 - Present" = "例如:2020年1月 - 至今";
185
+"Describe your responsibilities and achievements..." = "描述您的職責和成就...";
186
+"Degree / program *" = "學位 / 學程 *";
187
+"Institution *" = "學校 / 機構 *";
188
+"Year *" = "年份 *";
189
+"e.g., BSc Computer Science" = "例如:資訊工程學士";
190
+"e.g., MIT" = "例如:台灣大學";
191
+"e.g., 2020" = "例如:2020";
192
+"Profile name" = "資料名稱";
193
+"Full Name" = "全名";
194
+"Email" = "電子郵件";
195
+"Job Title" = "職位名稱";
196
+
197
+// MARK: - 履歷製作器
198
+"Templates" = "範本";
199
+"Polished layouts with live previews — pick a style that fits your story." = "精美的佈局,即時預覽 — 選擇適合您故事的風格。";
200
+"Use Template & Select Profile  →" = "使用範本並選擇資料 →";
201
+"All" = "全部";
202
+"No templates yet for this category." = "此類別暫無範本。";
203
+"Pick a template" = "選擇範本";
204
+"Select a template first, then choose a profile to continue." = "先選擇範本,然後選擇個人資料以繼續。";
205
+"Fetching AI-curated templates…" = "正在取得人工智慧策劃的範本…";
206
+"Couldn’t load AI templates — showing the built-in gallery." = "無法載入人工智慧範本 — 顯示內建圖庫。";
207
+"Design-Based" = "基於設計";
208
+"Profession-Based" = "基於職業";
209
+"Professional" = "專業";
210
+"Modern" = "現代";
211
+"Creative" = "創意";
212
+"Minimal" = "簡約";
213
+"Executive" = "高階主管";
214
+"ATS layout" = "ATS 佈局";
215
+"Sidebar left" = "左側邊欄";
216
+"Sidebar right" = "右側邊欄";
217
+
218
+// MARK: - 履歷預覽
219
+"CV preview" = "履歷預覽";
220
+"Export PDF…" = "匯出可攜式文件…";
221
+"Layout matches the CV Maker thumbnail for this template. Export a PDF that matches what you see here (fonts, columns, colours, and rules)." = "佈局與此範本的履歷製作器縮圖匹配。匯出與您在此處看到的內容匹配的可攜式文件(字型、欄、顏色和規則)。";
222
+"The résumé could not be rendered to PDF (empty output). Try scrolling the preview so it lays out, then export again." = "無法將履歷轉譯為可攜式文件(輸出為空)。嘗試捲動預覽使其佈局完整,然後再次匯出。";
223
+"Couldn’t save PDF" = "無法儲存可攜式文件";
224
+"Your name" = "您的姓名";
225
+"Professional headline" = "專業標題";
226
+"Experience" = "經歷";
227
+"Highlights" = "亮點";
228
+"Summary" = "摘要";
229
+"Contact" = "聯絡方式";
230
+"Skills" = "技能";
231
+"Tools" = "工具";
232
+"Languages & more" = "語言及其他";
233
+"Certificates" = "證書";
234
+"Referrals" = "推薦";
235
+"Professional Summary" = "專業摘要";
236
+"Selected Experience" = "精選經歷";
237
+"Core Competencies" = "核心能力";
238
+"Impact" = "影響力";
239
+"Add contact in your profile" = "在您的個人資料中加入聯絡方式";
240
+"Add contact details in your profile" = "在您的個人資料中加入詳細聯絡方式";
241
+"Add a career summary or interests in your profile to populate this column." = "在您的個人資料中加入職業摘要或興趣愛好以填充此欄位。";
242
+"CV" = "履歷";
243
+"Open to relocation" = "願意 relocate";
244
+"STRENGTHS" = "優勢";
245
+"PORTFOLIO SNAPSHOT" = "作品集概覽";
246
+"Close" = "關閉";
247
+"/ day" = "/天";
248
+"/ %d days" = "/%d天";
249
+"/ %d weeks" = "/%d週";
250
+"/ %d months" = "/%d個月";
251
+"/ %d years" = "/%d年";
252
+
253
+// MARK: - 履歷範本名稱
254
+"Paper White" = "純白";
255
+"Swiss" = "瑞士";
256
+"Mono" = "單色";
257
+"Airy" = "通透";
258
+"Tabular" = "表格";
259
+"Facet" = "刻面";
260
+"Corporate" = "企業";
261
+"Atlas" = "地圖";
262
+"Ledger" = "帳本";
263
+"Harbor" = "港灣";
264
+"Clear Path" = "清晰路徑";
265
+"Pinstripe" = "細條紋";
266
+"Briefing" = "簡報";
267
+"Quorum" = "法定人數";
268
+"Docket" = "待辦";
269
+"Conduit" = "管道";
270
+"Principal" = "校長";
271
+"Charter" = "憲章";
272
+"Vertex" = "頂點";
273
+"Linea" = "線條";
274
+"Prism" = "稜鏡";
275
+"Circuit" = "電路";
276
+"North" = "北方";
277
+"Axis" = "軸線";
278
+"Marigold" = "金盞花";
279
+"Ember" = "餘燼";
280
+"Lattice" = "格柵";
281
+"Bloom" = "綻放";
282
+"Studio" = "工作室";
283
+"Kite" = "風箏";
284
+"Regent" = "攝政";
285
+"Monarch" = "君主";
286
+"Sterling" = "純正";
287
+"Summit" = "頂峰";
288
+"Estate" = "莊園";
289
+"Chairman" = "主席";
290
+"Blue Ocean" = "藍海";
291
+
292
+// MARK: - 履歷示範預覽內容
293
+"Sarah Johnson" = "李雅婷";
294
+"Senior Product Manager" = "資深產品經理";
295
+"Group PM, Consumer Growth & Activation" = "產品經理組負責人,消費者成長與啟用";
296
+"Google · Mountain View, CA · 2019 – Present" = "Google · 加州山景城 · 2019 – 至今";
297
+"Stanford University" = "史丹佛大學";
298
+"M.S. Management Science & Engineering" = "管理科學與工程碩士";
299
+"2014 – 2016" = "2014 – 2016";
300
+"Mountain View, CA" = "加州山景城";
301
+"Product leader shipping roadmap, discovery, and analytics for high-scale consumer experiences." = "產品負責人,負責大規模消費者體驗的產品路線圖、探索和分析。";
302
+"Defined multi-year platform strategy with exec stakeholders and quarterly OKRs." = "與高階利益相關者共同制定多年平台策略和季度 OKR。";
303
+"Partnered with engineering and design to launch experiments improving activation by 12%." = "與工程和設計團隊合作推出實驗,使啟用率提高了 12%。";
304
+"Stood up quarterly business reviews with finance and GTM, aligning spend to north-star metrics." = "與財務和 GTM 團隊建立季度業務檢討,使支出與北極星指標對齊。";
305
+"Presented roadmap shifts to the leadership team and translated trade-offs into clear investment asks." = "向領導團隊展示路線圖的轉變,並將權衡轉化為清晰的投資需求。";
306
+"Figma · SQL · Amplitude · Jira · BigQuery" = "Figma · SQL · Amplitude · Jira · BigQuery";
307
+"Product Strategy" = "產品策略";
308
+"A/B Testing" = "A/B 測試";
309
+"Roadmapping" = "路線圖規劃";
310
+"CONTACT" = "聯絡方式";
311
+"SKILLS" = "技能";
312
+"PROFILE" = "個人資料";
313
+"EXPERIENCE" = "經歷";
314
+"EDUCATION" = "教育背景";
315
+"SUMMARY" = "摘要";
316
+"PROFESSIONAL SUMMARY" = "專業摘要";
317
+"SELECTED EXPERIENCE" = "精選經歷";
318
+"CORE COMPETENCIES" = "核心能力";
319
+"TOOLS" = "工具";
320
+"IMPACT" = "影響力";
321
+
322
+// MARK: - 工作瀏覽器
323
+"Return to the previous screen" = "返回上一畫面";
324
+
325
+// MARK: - 錯誤
326
+"We couldn't reach the server. Check your internet connection and try again." = "無法連線到伺服器。請檢查您的網路連線後再試一次。";
327
+"The search was cancelled. Try again when you're ready." = "搜尋已取消。準備就緒後請再試一次。";
328
+"Something went wrong while searching. Please try again in a moment." = "搜尋時發生錯誤。請稍後再試。";
329
+"Job search is unavailable." = "工作搜尋無法使用。";
330
+
331
+// MARK: - 提示
332
+"This profile will be removed from this Mac." = "此個人資料將從這台 Mac 上移除。";
333
+
334
+// MARK: - 格式化字串
335
+"Loading %@" = "正在載入 %@";
336
+"Loading %@. %@" = "正在載入 %@。%@";
337
+"Starting %@…" = "正在啟動 %@…";
338
+"%d replies left" = "剩餘 %d 次回覆";
339
+"%d saved positions" = "%d 個已儲存職位";
340
+"“%@” will be removed from this Mac." = "「%@」將從這台 Mac 上移除。";
341
+"%@ isn’t available yet" = "%@ 尚不可用";
342
+"I couldn't find new matches for “%@”. Try a different angle or a more specific keyword." = "我找不到「%@」的新配對。請嘗試不同的角度或更具體的關鍵字。";
343
+"No jobs found for “%@”. Try another title, skill, company, or location." = "找不到「%@」的工作。請嘗試其他職位名稱、技能、公司或地點。";
344
+"Here are %d more %@ for “%@”." = "這是「%@」的另外 %d 個 %@。";
345
+"Found %d %@ for “%@”. Tap Apply to open the listing or Save to revisit later." = "找到 %d 個「%@」的 %@。點擊申請開啟列表,或點擊儲存以便稍後查看。";
346
+"Get %@" = "取得 %@";
347
+"You chose the “%@” template. Tap Build CV on a profile to preview your résumé with that layout." = "您選擇了「%@」範本。點擊個人資料上的製作履歷,使用該佈局預覽您的履歷。";
348
+"Experience %d" = "經歷 %d";
349
+"Education %d" = "教育 %d";
350
+"Please fill in: %@." = "請填寫:%@。";
351
+
352
+// MARK: - 多行文字
353
+"Add your Mac App Store IDs in the target’s build settings:\n• AppStoreAppID — numeric app ID from App Store Connect\n• AppStoreDeveloperID — numeric developer ID (for your other apps page)" = "在目標的建置設定中新增您的 Mac App Store ID:\n• AppStoreAppID — 來自 App Store Connect 的數字應用程式 ID\n• AppStoreDeveloperID — 數字開發者 ID(用於您的其他應用程式頁面)";