Преглед на файлове

Add French localization and refresh profile form labels on language change.

Users can select French in Settings; profile editor field labels and placeholders now update when the locale changes instead of staying in the previously selected language.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 преди 1 месец
родител
ревизия
70e23561b7

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

@@ -104,6 +104,7 @@
 				ar,
 				"zh-Hans",
 				"zh-Hant",
+				fr,
 			);
 			mainGroup = 27D852772FB1D367008DF557;
 			minimizedProjectReferenceProxies = 1;

+ 7 - 2
App for Indeed/Services/AppLocalization.swift

@@ -10,6 +10,7 @@ import Foundation
 
 enum AppLanguage: CaseIterable {
     case english
+    case french
     case arabic
     case chineseSimplified
     case chineseTraditional
@@ -18,6 +19,8 @@ enum AppLanguage: CaseIterable {
         switch self {
         case .english:
             return "en"
+        case .french:
+            return "fr"
         case .arabic:
             return "ar"
         case .chineseSimplified:
@@ -51,6 +54,8 @@ enum AppLanguage: CaseIterable {
         switch self {
         case .english:
             return "English"
+        case .french:
+            return "French"
         case .arabic:
             return "Arabic"
         case .chineseSimplified:
@@ -106,7 +111,7 @@ private func translateTemplateNameByTokens(_ name: String, language: AppLanguage
     switch language {
     case .chineseSimplified, .chineseTraditional:
         return translated.joined()
-    case .arabic, .english:
+    case .arabic, .english, .french:
         return translated.joined(separator: " ")
     }
 }
@@ -123,7 +128,7 @@ private func templateNameTokenTranslation(_ token: String, language: AppLanguage
         return TemplateNameTokenLexicon.zhHans[key] ?? TemplateNameTokenLexicon.zhHans[key.capitalized]
     case .arabic:
         return TemplateNameTokenLexicon.ar[key] ?? TemplateNameTokenLexicon.ar[key.capitalized]
-    case .english:
+    case .french, .english:
         return nil
     }
 }

+ 2 - 0
App for Indeed/Services/CVTemplateFetchService.swift

@@ -50,6 +50,8 @@ final class CVTemplateFetchService {
                 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`."
             case .arabic:
                 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`."
+            case .french:
+                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 French characters in `name`."
             }
         }
     }

+ 143 - 79
App for Indeed/Views/MyProfilePageView.swift

@@ -110,6 +110,64 @@ private extension NSStackView {
     }
 }
 
+/// Tags profile form controls with English localization keys so labels and placeholders refresh when the user changes language.
+private enum ProfileFormLocalization {
+    static let labelPrefix = "profileForm.label."
+    static let sectionPrefix = "profileForm.section."
+    static let placeholderPrefix = "profileForm.placeholder."
+    static let buttonPrefix = "profileForm.button."
+
+    static func tagLabel(_ label: NSTextField, key: String) {
+        label.identifier = NSUserInterfaceItemIdentifier(labelPrefix + key)
+    }
+
+    static func tagSection(_ label: NSTextField, key: String) {
+        label.identifier = NSUserInterfaceItemIdentifier(sectionPrefix + key)
+    }
+
+    static func tagPlaceholder(_ field: NSTextField, key: String) {
+        field.identifier = NSUserInterfaceItemIdentifier(placeholderPrefix + key)
+    }
+
+    static func tagButton(_ button: NSButton, key: String) {
+        button.identifier = NSUserInterfaceItemIdentifier(buttonPrefix + key)
+    }
+
+    static func placeholderAttributes() -> [NSAttributedString.Key: Any] {
+        [
+            .foregroundColor: ProfilePagePalette.secondaryText,
+            .font: NSFont.systemFont(ofSize: 14, weight: .regular),
+            .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
+        ]
+    }
+
+    static func applyPlaceholder(_ text: String, to field: NSTextField) {
+        field.placeholderAttributedString = NSAttributedString(string: text, attributes: placeholderAttributes())
+    }
+
+    static func refresh(in root: NSView) {
+        for view in root.profileSubviewsRecursive() {
+            guard let rawID = view.identifier?.rawValue else { continue }
+            if let label = view as? NSTextField, !label.isEditable {
+                if rawID.hasPrefix(labelPrefix) {
+                    label.stringValue = L(String(rawID.dropFirst(labelPrefix.count)))
+                } else if rawID.hasPrefix(sectionPrefix) {
+                    label.stringValue = L(String(rawID.dropFirst(sectionPrefix.count)))
+                }
+            } else if let field = view as? NSTextField, field.isEditable, rawID.hasPrefix(placeholderPrefix) {
+                applyPlaceholder(L(String(rawID.dropFirst(placeholderPrefix.count))), to: field)
+            } else if let button = view as? NSButton, rawID.hasPrefix(buttonPrefix) {
+                let key = String(rawID.dropFirst(buttonPrefix.count))
+                if button.image != nil {
+                    button.image = NSImage(systemSymbolName: "trash", accessibilityDescription: L(key))
+                } else {
+                    button.title = L(key)
+                }
+            }
+        }
+    }
+}
+
 /// Two fields side‑by‑side with a true 50/50 split, or stacked full‑width when compact. Avoids `NSStackView` collapsing paired columns to a narrow strip on the trailing edge.
 private final class ProfileDualFieldRow: NSView {
     private let leftView: NSView
@@ -282,6 +340,8 @@ final class MyProfilePageView: NSView {
         backButton.title = L("← All profiles")
         saveButton.title = L("Save Profile  →")
         contextLabel.stringValue = editingProfileID == nil ? L("New profile") : L("Edit profile")
+        ProfileFormLocalization.refresh(in: formStack)
+        referralHelperLabel?.stringValue = L("If someone referred you for this job, enter their name or company here")
         renumberWorkExperienceEntries()
         renumberEducationEntries()
         ProfileThemeAppearance.refreshFormSubtree(formStack)
@@ -454,22 +514,22 @@ final class MyProfilePageView: NSView {
         ])
 
         addFullWidthArrangedSubview(
-            labeledGroup(title: L("Profile Name *"), field: profileNameField, placeholder: L("Marketing Director Profile"))
+            labeledGroup(labelKey: "Profile Name *", field: profileNameField, placeholderKey: "Marketing Director Profile")
         )
-        addFullWidthArrangedSubview(sectionHeading(L("Personal Information")))
+        addFullWidthArrangedSubview(sectionHeading("Personal Information"))
 
-        let nameGroup = labeledGroup(title: L("Full Name *"), field: fullNameField, placeholder: L("John Doe"))
-        let emailGroup = labeledGroup(title: L("Email *"), field: emailField, placeholder: L("john@example.com"))
+        let nameGroup = labeledGroup(labelKey: "Full Name *", field: fullNameField, placeholderKey: "John Doe")
+        let emailGroup = labeledGroup(labelKey: "Email *", field: emailField, placeholderKey: "john@example.com")
         nameEmailRow = ProfileDualFieldRow(left: nameGroup, right: emailGroup, spacing: 12)
         addFullWidthArrangedSubview(nameEmailRow)
 
-        let phoneGroup = labeledGroup(title: L("Phone"), field: phoneField, placeholder: L("+1 (555) 123-4567"))
-        let jobGroup = labeledGroup(title: L("Job Title *"), field: jobTitleField, placeholder: L("Software Engineer"))
+        let phoneGroup = labeledGroup(labelKey: "Phone", field: phoneField, placeholderKey: "+1 (555) 123-4567")
+        let jobGroup = labeledGroup(labelKey: "Job Title *", field: jobTitleField, placeholderKey: "Software Engineer")
         phoneJobRow = ProfileDualFieldRow(left: phoneGroup, right: jobGroup, spacing: 12)
         addFullWidthArrangedSubview(phoneJobRow)
 
         addFullWidthArrangedSubview(
-            labeledGroup(title: L("Address"), field: addressField, placeholder: L("123 Main St, City, State, ZIP"))
+            labeledGroup(labelKey: "Address", field: addressField, placeholderKey: "123 Main St, City, State, ZIP")
         )
         addFullWidthArrangedSubview(careerSummaryBlock())
         addFullWidthArrangedSubview(horizontalSeparator())
@@ -479,8 +539,8 @@ final class MyProfilePageView: NSView {
         addFullWidthArrangedSubview(horizontalSeparator())
         addFullWidthArrangedSubview(
             multilineProfileBlock(
-                title: L("Certificates / Rewards"),
-                placeholder: L("List your certificates and awards..."),
+                labelKey: "Certificates / Rewards",
+                placeholderKey: "List your certificates and awards...",
                 field: certificatesField,
                 minHeight: 100
             )
@@ -488,8 +548,8 @@ final class MyProfilePageView: NSView {
         addFullWidthArrangedSubview(horizontalSeparator())
         addFullWidthArrangedSubview(
             multilineProfileBlock(
-                title: L("Interests"),
-                placeholder: L("List your interests and hobbies..."),
+                labelKey: "Interests",
+                placeholderKey: "List your interests and hobbies...",
                 field: interestsField,
                 minHeight: 100
             )
@@ -497,8 +557,8 @@ final class MyProfilePageView: NSView {
         addFullWidthArrangedSubview(horizontalSeparator())
         addFullWidthArrangedSubview(
             multilineProfileBlock(
-                title: L("Languages"),
-                placeholder: L("List languages you speak (e.g., English - Native, Spanish - Fluent)..."),
+                labelKey: "Languages",
+                placeholderKey: "List languages you speak (e.g., English - Native, Spanish - Fluent)...",
                 field: languagesField,
                 minHeight: 100
             )
@@ -518,6 +578,7 @@ final class MyProfilePageView: NSView {
 
     func prepareNewProfile() {
         editingProfileID = nil
+        applyLocalizedStrings()
         contextLabel.stringValue = L("New profile")
         applyForm(
             from: SavedProfile(
@@ -537,6 +598,7 @@ final class MyProfilePageView: NSView {
 
     func loadSavedProfile(_ profile: SavedProfile) {
         editingProfileID = profile.id
+        applyLocalizedStrings()
         contextLabel.stringValue = L("Edit profile")
         applyForm(from: profile)
     }
@@ -658,8 +720,9 @@ final class MyProfilePageView: NSView {
         view.widthAnchor.constraint(equalTo: formStack.widthAnchor).isActive = true
     }
 
-    private func sectionHeading(_ text: String) -> NSView {
-        let label = NSTextField(labelWithString: text)
+    private func sectionHeading(_ key: String) -> NSView {
+        let label = NSTextField(labelWithString: L(key))
+        ProfileFormLocalization.tagSection(label, key: key)
         label.font = .systemFont(ofSize: 15, weight: .semibold)
         label.textColor = ProfilePagePalette.primaryText
         label.baseWritingDirection = .leftToRight
@@ -685,15 +748,16 @@ final class MyProfilePageView: NSView {
         return row
     }
 
-    private func labeledGroup(title: String, field: NSTextField, placeholder: String) -> NSView {
-        let label = NSTextField(labelWithString: title)
+    private func labeledGroup(labelKey: String, field: NSTextField, placeholderKey: String) -> NSView {
+        let label = NSTextField(labelWithString: L(labelKey))
+        ProfileFormLocalization.tagLabel(label, key: labelKey)
         label.font = .systemFont(ofSize: 12, weight: .medium)
         label.textColor = ProfilePagePalette.secondaryText
         label.translatesAutoresizingMaskIntoConstraints = false
         label.setContentHuggingPriority(.defaultLow, for: .horizontal)
         ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
 
-        styleSingleLineField(field, placeholder: placeholder)
+        styleSingleLineField(field, placeholderKey: placeholderKey)
         let wrap = roundedFieldChrome(containing: field, minHeight: 40)
 
         let stack = NSStackView(views: [label, wrap])
@@ -715,7 +779,8 @@ final class MyProfilePageView: NSView {
         return stack
     }
 
-    private func styleSingleLineField(_ field: NSTextField, placeholder: String) {
+    private func styleSingleLineField(_ field: NSTextField, placeholderKey: String) {
+        ProfileFormLocalization.tagPlaceholder(field, key: placeholderKey)
         field.translatesAutoresizingMaskIntoConstraints = false
         field.isBordered = false
         field.drawsBackground = false
@@ -724,15 +789,7 @@ final class MyProfilePageView: NSView {
         field.textColor = ProfilePagePalette.primaryText
         field.setContentHuggingPriority(.defaultLow, for: .horizontal)
         field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
-        let paragraph = ProfileLayoutEnforcement.leftAlignedParagraphStyle()
-        field.placeholderAttributedString = NSAttributedString(
-            string: placeholder,
-            attributes: [
-                .foregroundColor: ProfilePagePalette.secondaryText,
-                .font: NSFont.systemFont(ofSize: 14, weight: .regular),
-                .paragraphStyle: paragraph
-            ]
-        )
+        ProfileFormLocalization.applyPlaceholder(L(placeholderKey), to: field)
         field.cell?.usesSingleLineMode = true
         field.cell?.wraps = false
         field.cell?.isScrollable = true
@@ -763,6 +820,7 @@ final class MyProfilePageView: NSView {
 
     private func careerSummaryBlock() -> NSView {
         let label = NSTextField(labelWithString: L("Career Summary"))
+        ProfileFormLocalization.tagLabel(label, key: "Career Summary")
         label.font = .systemFont(ofSize: 12, weight: .medium)
         label.textColor = ProfilePagePalette.secondaryText
         label.translatesAutoresizingMaskIntoConstraints = false
@@ -783,13 +841,13 @@ final class MyProfilePageView: NSView {
         careerField.stringValue = ""
         careerField.setContentHuggingPriority(.defaultLow, for: .horizontal)
         careerField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
-        careerField.placeholderAttributedString = NSAttributedString(
-            string: L("Brief overview of your professional background and key achievements..."),
-            attributes: [
-                .foregroundColor: ProfilePagePalette.secondaryText,
-                .font: NSFont.systemFont(ofSize: 14, weight: .regular),
-                .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
-            ]
+        ProfileFormLocalization.tagPlaceholder(
+            careerField,
+            key: "Brief overview of your professional background and key achievements..."
+        )
+        ProfileFormLocalization.applyPlaceholder(
+            L("Brief overview of your professional background and key achievements..."),
+            to: careerField
         )
         ProfileLayoutEnforcement.applyLeftAlignedTextField(careerField)
 
@@ -830,13 +888,15 @@ final class MyProfilePageView: NSView {
         return stack
     }
 
-    private func multilineProfileBlock(title: String, placeholder: String, field: NSTextField, minHeight: CGFloat) -> NSView {
-        let label = NSTextField(labelWithString: title)
+    private func multilineProfileBlock(labelKey: String, placeholderKey: String, field: NSTextField, minHeight: CGFloat) -> NSView {
+        let label = NSTextField(labelWithString: L(labelKey))
+        ProfileFormLocalization.tagLabel(label, key: labelKey)
         label.font = .systemFont(ofSize: 12, weight: .medium)
         label.textColor = ProfilePagePalette.secondaryText
         label.translatesAutoresizingMaskIntoConstraints = false
         ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
 
+        ProfileFormLocalization.tagPlaceholder(field, key: placeholderKey)
         field.translatesAutoresizingMaskIntoConstraints = false
         field.isEditable = true
         field.isSelectable = true
@@ -852,14 +912,7 @@ final class MyProfilePageView: NSView {
         field.stringValue = ""
         field.setContentHuggingPriority(.defaultLow, for: .horizontal)
         field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
-        field.placeholderAttributedString = NSAttributedString(
-            string: placeholder,
-            attributes: [
-                .foregroundColor: ProfilePagePalette.secondaryText,
-                .font: NSFont.systemFont(ofSize: 14, weight: .regular),
-                .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
-            ]
-        )
+        ProfileFormLocalization.applyPlaceholder(L(placeholderKey), to: field)
         ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
 
         let wrap = NSView()
@@ -901,12 +954,13 @@ final class MyProfilePageView: NSView {
 
     private func referralBlock() -> NSView {
         let label = NSTextField(labelWithString: L("Referral (Optional)"))
+        ProfileFormLocalization.tagLabel(label, key: "Referral (Optional)")
         label.font = .systemFont(ofSize: 12, weight: .medium)
         label.textColor = ProfilePagePalette.secondaryText
         label.translatesAutoresizingMaskIntoConstraints = false
         ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
 
-        styleSingleLineField(referralField, placeholder: L("Referred by (Company/Person Name)"))
+        styleSingleLineField(referralField, placeholderKey: "Referred by (Company/Person Name)")
         let wrap = roundedFieldChrome(containing: referralField, minHeight: 40)
 
         let helper = NSTextField(wrappingLabelWithString: L("If someone referred you for this job, enter their name or company here"))
@@ -947,6 +1001,7 @@ final class MyProfilePageView: NSView {
 
     private func workExperienceSection() -> NSView {
         let title = NSTextField(labelWithString: L("Work Experience"))
+        ProfileFormLocalization.tagSection(title, key: "Work Experience")
         title.font = .systemFont(ofSize: 15, weight: .semibold)
         title.textColor = ProfilePagePalette.primaryText
         title.translatesAutoresizingMaskIntoConstraints = false
@@ -954,6 +1009,7 @@ final class MyProfilePageView: NSView {
         ProfileLayoutEnforcement.applyLeftAlignedTextField(title)
 
         let addButton = NSButton(title: L("+ Add Another"), target: self, action: #selector(didTapAddWorkExperience))
+        ProfileFormLocalization.tagButton(addButton, key: "+ Add Another")
         addButton.translatesAutoresizingMaskIntoConstraints = false
         addButton.bezelStyle = .rounded
         addButton.isBordered = true
@@ -994,6 +1050,7 @@ final class MyProfilePageView: NSView {
 
     private func educationSection() -> NSView {
         let title = NSTextField(labelWithString: L("Education"))
+        ProfileFormLocalization.tagSection(title, key: "Education")
         title.font = .systemFont(ofSize: 15, weight: .semibold)
         title.textColor = ProfilePagePalette.primaryText
         title.translatesAutoresizingMaskIntoConstraints = false
@@ -1001,6 +1058,7 @@ final class MyProfilePageView: NSView {
         ProfileLayoutEnforcement.applyLeftAlignedTextField(title)
 
         let addButton = NSButton(title: L("+ Add Another"), target: self, action: #selector(didTapAddEducation))
+        ProfileFormLocalization.tagButton(addButton, key: "+ Add Another")
         addButton.translatesAutoresizingMaskIntoConstraints = false
         addButton.bezelStyle = .rounded
         addButton.isBordered = true
@@ -1259,9 +1317,11 @@ private final class WorkExperienceEntryView: NSView {
         if #available(macOS 11.0, *) {
             deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: L("Remove experience"))
             deleteButton.imagePosition = .imageOnly
+            ProfileFormLocalization.tagButton(deleteButton, key: "Remove experience")
         } else {
             deleteButton.title = L("Remove")
             deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
+            ProfileFormLocalization.tagButton(deleteButton, key: "Remove")
         }
 
         let headerSpacer = NSView()
@@ -1276,15 +1336,27 @@ private final class WorkExperienceEntryView: NSView {
         ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
         headerRow.translatesAutoresizingMaskIntoConstraints = false
 
-        let jobGroup = Self.labeledFieldStack(title: L("Job Title *"), field: jobTitleField, placeholder: L("e.g., Software Engineer"))
-        let companyGroup = Self.labeledFieldStack(title: L("Company Name *"), field: companyField, placeholder: L("e.g., Google"))
+        let jobGroup = Self.labeledFieldStack(
+            labelKey: "Job Title *",
+            field: jobTitleField,
+            placeholderKey: "e.g., Software Engineer"
+        )
+        let companyGroup = Self.labeledFieldStack(
+            labelKey: "Company Name *",
+            field: companyField,
+            placeholderKey: "e.g., Google"
+        )
         jobCompanyRow = ProfileDualFieldRow(left: jobGroup, right: companyGroup, spacing: 12)
 
-        let durationGroup = Self.labeledFieldStack(title: L("Duration *"), field: durationField, placeholder: L("e.g., Jan 2020 - Present"))
+        let durationGroup = Self.labeledFieldStack(
+            labelKey: "Duration *",
+            field: durationField,
+            placeholderKey: "e.g., Jan 2020 - Present"
+        )
         let descriptionGroup = Self.multilineLabeledStack(
-            title: L("Description"),
+            labelKey: "Description",
             field: descriptionField,
-            placeholder: L("Describe your responsibilities and achievements..."),
+            placeholderKey: "Describe your responsibilities and achievements...",
             minHeight: 120
         )
 
@@ -1334,14 +1406,15 @@ private final class WorkExperienceEntryView: NSView {
         ProfileThemeAppearance.refreshFormSubtree(self)
     }
 
-    fileprivate static func labeledFieldStack(title: String, field: NSTextField, placeholder: String) -> NSView {
-        let label = NSTextField(labelWithString: title)
+    fileprivate static func labeledFieldStack(labelKey: String, field: NSTextField, placeholderKey: String) -> NSView {
+        let label = NSTextField(labelWithString: L(labelKey))
+        ProfileFormLocalization.tagLabel(label, key: labelKey)
         label.font = .systemFont(ofSize: 12, weight: .medium)
         label.textColor = ProfilePagePalette.secondaryText
         label.translatesAutoresizingMaskIntoConstraints = false
         ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
 
-        styleSingleLineField(field, placeholder: placeholder)
+        styleSingleLineField(field, placeholderKey: placeholderKey)
         let wrap = roundedChrome(around: field, minHeight: 40)
 
         let stack = NSStackView(views: [label, wrap])
@@ -1362,13 +1435,15 @@ private final class WorkExperienceEntryView: NSView {
         return stack
     }
 
-    private static func multilineLabeledStack(title: String, field: NSTextField, placeholder: String, minHeight: CGFloat) -> NSView {
-        let label = NSTextField(labelWithString: title)
+    private static func multilineLabeledStack(labelKey: String, field: NSTextField, placeholderKey: String, minHeight: CGFloat) -> NSView {
+        let label = NSTextField(labelWithString: L(labelKey))
+        ProfileFormLocalization.tagLabel(label, key: labelKey)
         label.font = .systemFont(ofSize: 12, weight: .medium)
         label.textColor = ProfilePagePalette.secondaryText
         label.translatesAutoresizingMaskIntoConstraints = false
         ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
 
+        ProfileFormLocalization.tagPlaceholder(field, key: placeholderKey)
         field.translatesAutoresizingMaskIntoConstraints = false
         field.isEditable = true
         field.isSelectable = true
@@ -1381,14 +1456,7 @@ private final class WorkExperienceEntryView: NSView {
         field.cell?.wraps = true
         field.cell?.isScrollable = false
         field.cell?.usesSingleLineMode = false
-        field.placeholderAttributedString = NSAttributedString(
-            string: placeholder,
-            attributes: [
-                .foregroundColor: ProfilePagePalette.secondaryText,
-                .font: NSFont.systemFont(ofSize: 14, weight: .regular),
-                .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
-            ]
-        )
+        ProfileFormLocalization.applyPlaceholder(L(placeholderKey), to: field)
         ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
 
         let wrap = NSView()
@@ -1428,21 +1496,15 @@ private final class WorkExperienceEntryView: NSView {
         return stack
     }
 
-    private static func styleSingleLineField(_ field: NSTextField, placeholder: String) {
+    private static func styleSingleLineField(_ field: NSTextField, placeholderKey: String) {
+        ProfileFormLocalization.tagPlaceholder(field, key: placeholderKey)
         field.translatesAutoresizingMaskIntoConstraints = false
         field.isBordered = false
         field.drawsBackground = false
         field.focusRingType = .none
         field.font = .systemFont(ofSize: 14, weight: .regular)
         field.textColor = ProfilePagePalette.primaryText
-        field.placeholderAttributedString = NSAttributedString(
-            string: placeholder,
-            attributes: [
-                .foregroundColor: ProfilePagePalette.secondaryText,
-                .font: NSFont.systemFont(ofSize: 14, weight: .regular),
-                .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
-            ]
-        )
+        ProfileFormLocalization.applyPlaceholder(L(placeholderKey), to: field)
         field.cell?.usesSingleLineMode = true
         field.cell?.wraps = false
         field.cell?.isScrollable = true
@@ -1549,9 +1611,11 @@ private final class EducationEntryView: NSView {
         if #available(macOS 11.0, *) {
             deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: L("Remove education"))
             deleteButton.imagePosition = .imageOnly
+            ProfileFormLocalization.tagButton(deleteButton, key: "Remove education")
         } else {
             deleteButton.title = L("Remove")
             deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
+            ProfileFormLocalization.tagButton(deleteButton, key: "Remove")
         }
 
         let headerSpacer = NSView()
@@ -1567,21 +1631,21 @@ private final class EducationEntryView: NSView {
         headerRow.translatesAutoresizingMaskIntoConstraints = false
 
         let degreeGroup = WorkExperienceEntryView.labeledFieldStack(
-            title: L("Degree / program *"),
+            labelKey: "Degree / program *",
             field: degreeField,
-            placeholder: L("e.g., BSc Computer Science")
+            placeholderKey: "e.g., BSc Computer Science"
         )
         let institutionGroup = WorkExperienceEntryView.labeledFieldStack(
-            title: L("Institution *"),
+            labelKey: "Institution *",
             field: institutionField,
-            placeholder: L("e.g., MIT")
+            placeholderKey: "e.g., MIT"
         )
         degreeInstitutionRow = ProfileDualFieldRow(left: degreeGroup, right: institutionGroup, spacing: 12)
 
         let yearGroup = WorkExperienceEntryView.labeledFieldStack(
-            title: L("Year *"),
+            labelKey: "Year *",
             field: yearField,
-            placeholder: L("e.g., 2020")
+            placeholderKey: "e.g., 2020"
         )
 
         let inner = NSStackView(views: [headerRow, degreeInstitutionRow, yearGroup])

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

@@ -0,0 +1,353 @@
+/* Localizable.strings (Français) */
+
+// MARK: - Général
+"OK" = "OK";
+"Cancel" = "Annuler";
+"Delete" = "Supprimer";
+"Remove" = "Retirer";
+"Dismiss" = "Ignorer";
+
+// MARK: - Écran de lancement
+"AI-POWERED" = "ALIMENTÉ PAR L'IA";
+"Find your perfect job with the power of AI." = "Trouvez l'emploi idéal grâce à la puissance de l'IA.";
+"Starting up…" = "Démarrage…";
+"Loading progress" = "Chargement en cours";
+
+// MARK: - Statut de lancement
+"Checking your Pro subscription…" = "Vérification de votre abonnement Pro…";
+"Loading premium plans from the App Store…" = "Chargement des offres premium depuis l'App Store…";
+"Preparing your job search workspace…" = "Préparation de votre espace de recherche d'emploi…";
+"Almost ready…" = "Presque prêt…";
+
+// MARK: - Barre latérale
+"Home" = "Accueil";
+"Saved Jobs" = "Emplois enregistrés";
+"CV Maker" = "Créateur de CV";
+"Profile" = "Profil";
+"Settings" = "Paramètres";
+"Premium" = "Premium";
+"Indeed" = "Indeed";
+"Open Indeed to search and apply for jobs" = "Ouvrez Indeed pour rechercher et postuler à des emplois";
+
+// MARK: - Tableau de bord / Accueil
+"Welcome" = "Bienvenue";
+"Send" = "Envoyer";
+"Clear chat" = "Effacer le chat";
+"Remove all messages and start a new conversation" = "Supprimer tous les messages et démarrer une nouvelle conversation";
+"Ask for roles, skills, salary, or job descriptions..." = "Demandez des rôles, compétences, salaires ou descriptions de poste…";
+"Ask AI" = "Interroger l'IA";
+"1 reply left" = "1 réponse restante";
+"Apply" = "Postuler";
+"Save" = "Enregistrer";
+"Saved" = "Enregistré";
+"Remove from saved" = "Retirer des enregistrements";
+"Show more jobs" = "Afficher plus d'emplois";
+"This area is not available in the preview build. Use Home to search jobs." = "Cette zone n'est pas disponible dans la version préliminaire. Utilisez Accueil pour rechercher des emplois.";
+"Save jobs from Home to see them here." = "Enregistrez des emplois depuis Accueil pour les voir ici.";
+"No saved jobs yet. Search on Home, then tap Save on a listing." = "Aucun emploi enregistré pour l'instant. Effectuez une recherche sur Accueil, puis appuyez sur Enregistrer sur une annonce.";
+"Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary." = "Dites-moi quel rôle vous voulez et je vous fournirai des descriptions de poste, les compétences clés et un résumé rapide de la correspondance.";
+"1 saved position" = "1 poste enregistré";
+"Delete this profile?" = "Supprimer ce profil ?";
+"Find roles similar to: " = "Trouver des rôles similaires à : ";
+"Find jobs at company: " = "Trouver des emplois dans l'entreprise : ";
+"Find jobs that require skill: " = "Trouver des emplois qui exigent la compétence : ";
+"match" = "correspondance";
+"matches" = "correspondances";
+
+// MARK: - Raccourcis des fonctionnalités
+"Role" = "Rôle";
+"Explore similar or better job roles" = "Explorez des rôles similaires ou meilleurs";
+"Company" = "Entreprise";
+"Find opportunities at other companies" = "Trouvez des opportunités dans d'autres entreprises";
+"Skill" = "Compétence";
+"Match jobs that fit your skills" = "Trouvez des emplois qui correspondent à vos compétences";
+
+// MARK: - Pro / Abonnement
+"Upgrade to Pro" = "Passer à Pro";
+"You're on Pro" = "Vous êtes sur Pro";
+"Unlimited AI matches, smart alerts, and interview prep—all in one place." = "Correspondances IA illimitées, alertes intelligentes et préparation aux entretiens — tout au même endroit.";
+"Manage billing, renewals, and plans in Premium." = "Gérez la facturation, les renouvellements et les forfaits dans Premium.";
+"Try Pro" = "Essayer Pro";
+"Manage Subscription" = "Gérer l'abonnement";
+"Premium Plans" = "Forfaits Premium";
+"Unlock unlimited access to premium tools and boost your productivity." = "Débloquez un accès illimité aux outils premium et boostez votre productivité.";
+"Continue with free plan" = "Continuer avec le forfait gratuit";
+"Restore Purchase" = "Restaurer l'achat";
+"You're subscribed" = "Vous êtes abonné";
+"Thank you — Pro features are now available." = "Merci — les fonctionnalités Pro sont maintenant disponibles.";
+"Pro" = "Pro";
+"Purchases restored" = "Achats restaurés";
+"Your subscription is active." = "Votre abonnement est actif.";
+"No subscription found" = "Aucun abonnement trouvé";
+"There was nothing to restore for this Apple ID." = "Il n'y avait rien à restaurer pour cet identifiant Apple.";
+"Something went wrong" = "Une erreur s'est produite";
+"That subscription isn’t available from the App Store right now." = "Cet abonnement n'est pas disponible sur l'App Store pour le moment.";
+"Unlimited AI job search on Home" = "Recherche d'emploi IA illimitée sur Accueil";
+"Save jobs & open listings in-app" = "Enregistrez des emplois et ouvrez les annonces dans l'application";
+"CV Maker, profiles & PDF export" = "Créateur de CV, profils et export PDF";
+"Role, company & skill shortcuts" = "Raccourcis de rôle, entreprise et compétence";
+
+// MARK: - Offres du paywall
+"Weekly" = "Hebdomadaire";
+"Flexible and commitment-free" = "Flexible et sans engagement";
+"Monthly" = "Mensuel";
+"Balanced for regular productivity" = "Équilibré pour une productivité régulière";
+"Yearly" = "Annuel";
+"Best value for long-term users" = "Meilleur rapport qualité-prix pour les utilisateurs à long terme";
+"/ week" = "/ semaine";
+"/ month" = "/ mois";
+"/ year" = "/ an";
+"3 days free trial" = "3 jours d'essai gratuit";
+"Perfect for short-term job hunts" = "Parfait pour les recherches d'emploi à court terme";
+"Cancel anytime" = "Annulation à tout moment";
+"Best for regular job seekers" = "Idéal pour les chercheurs d'emploi réguliers";
+"Priority support" = "Assistance prioritaire";
+"Lowest effective monthly cost" = "Coût mensuel effectif le plus bas";
+"Ideal for long-term use" = "Idéal pour une utilisation à long terme";
+
+// MARK: - Confiance du paywall
+"Secure Payments" = "Paiements sécurisés";
+"Your payment is 100% secure." = "Votre paiement est 100 % sécurisé.";
+"Cancel Anytime" = "Annulez à tout moment";
+"No commitment, cancel anytime." = "Sans engagement, annulez à tout moment.";
+"24/7 Support" = "Assistance 24/7";
+"We're here to help you anytime." = "Nous sommes là pour vous aider à tout moment.";
+"Privacy First" = "Confidentialité d'abord";
+"Your data is safe with us." = "Vos données sont en sécurité avec nous.";
+
+// MARK: - Paramètres
+"Appearance" = "Apparence";
+"Theme" = "Thème";
+"Language" = "Langue";
+"Share App" = "Partager l'application";
+"More Apps" = "Plus d'applications";
+"About" = "À propos";
+"Website" = "Site Web";
+"Support" = "Assistance";
+"Terms of Use" = "Conditions d'utilisation";
+"Privacy Policy" = "Politique de confidentialité";
+"System" = "Système";
+"Light" = "Clair";
+"Dark" = "Sombre";
+
+// MARK: - Profils
+"Profiles" = "Profils";
+"Add new profile" = "Ajouter un profil";
+"Create and manage CV profiles. Each profile stores your details on this Mac." = "Créez et gérez des profils de CV. Chaque profil stocke vos informations sur ce Mac.";
+"No profiles yet. Tap “Add new profile” to create your first one." = "Aucun profil pour l'instant. Appuyez sur « Ajouter un profil » pour créer votre premier profil.";
+"Build CV" = "Créer un CV";
+"Edit" = "Modifier";
+"Untitled profile" = "Profil sans titre";
+"No contact details yet" = "Aucune coordonnée pour l'instant";
+"← Profiles" = "← Profils";
+
+// MARK: - Éditeur de profil
+"Save Profile  →" = "Enregistrer le profil →";
+"← All profiles" = "← Tous les profils";
+"New profile" = "Nouveau profil";
+"Edit profile" = "Modifier le profil";
+"Profile Name *" = "Nom du profil *";
+"Marketing Director Profile" = "Profil de Directeur Marketing";
+"Personal Information" = "Informations personnelles";
+"Full Name *" = "Nom complet *";
+"John Doe" = "Jean Dupont";
+"Email *" = "E-mail *";
+"john@example.com" = "jean.dupont@exemple.com";
+"Phone" = "Téléphone";
+"+1 (555) 123-4567" = "+33 6 12 34 56 78";
+"Job Title *" = "Titre du poste *";
+"Software Engineer" = "Ingénieur logiciel";
+"Address" = "Adresse";
+"123 Main St, City, State, ZIP" = "123 Rue Principale, Ville, Code Postal";
+"Certificates / Rewards" = "Certificats / Récompenses";
+"List your certificates and awards..." = "Listez vos certificats et récompenses…";
+"Interests" = "Centres d'intérêt";
+"List your interests and hobbies..." = "Listez vos centres d'intérêt et loisirs…";
+"Languages" = "Langues";
+"List languages you speak (e.g., English - Native, Spanish - Fluent)..." = "Listez les langues que vous parlez (ex : Français - Natif, Anglais - Courant)…";
+"Career Summary" = "Résumé de carrière";
+"Brief overview of your professional background and key achievements..." = "Aperçu bref de votre parcours professionnel et de vos principales réalisations…";
+"Referral (Optional)" = "Recommandation (optionnel)";
+"Referred by (Company/Person Name)" = "Recommandé par (Nom de l'entreprise/de la personne)";
+"If someone referred you for this job, enter their name or company here" = "Si quelqu'un vous a recommandé pour ce poste, entrez son nom ou son entreprise ici";
+"Work Experience" = "Expérience professionnelle";
+"Education" = "Formation";
+"+ Add Another" = "+ Ajouter un autre";
+"Complete required fields" = "Remplissez les champs obligatoires";
+"Remove experience" = "Supprimer l'expérience";
+"Remove education" = "Supprimer la formation";
+"Company Name *" = "Nom de l'entreprise *";
+"Duration *" = "Durée *";
+"Description" = "Description";
+"e.g., Software Engineer" = "ex : Ingénieur logiciel";
+"e.g., Google" = "ex : Google";
+"e.g., Jan 2020 - Present" = "ex : janv. 2020 - Présent";
+"Describe your responsibilities and achievements..." = "Décrivez vos responsabilités et réalisations…";
+"Degree / program *" = "Diplôme / programme *";
+"Institution *" = "Établissement *";
+"Year *" = "Année *";
+"e.g., BSc Computer Science" = "ex : Licence en Informatique";
+"e.g., MIT" = "ex : Sorbonne Université";
+"e.g., 2020" = "ex : 2020";
+"Profile name" = "Nom du profil";
+"Full Name" = "Nom complet";
+"Email" = "E-mail";
+"Job Title" = "Titre du poste";
+
+// MARK: - Créateur de CV
+"Templates" = "Modèles";
+"Polished layouts with live previews — pick a style that fits your story." = "Des mises en page soignées avec aperçus en direct — choisissez un style qui correspond à votre histoire.";
+"Use Template & Select Profile  →" = "Utiliser le modèle et sélectionner le profil →";
+"All" = "Tous";
+"No templates yet for this category." = "Aucun modèle pour cette catégorie pour l'instant.";
+"Pick a template" = "Choisissez un modèle";
+"Select a template first, then choose a profile to continue." = "Sélectionnez d'abord un modèle, puis choisissez un profil pour continuer.";
+"Fetching AI-curated templates…" = "Récupération des modèles organisés par l'IA…";
+"Couldn’t load AI templates — showing the built-in gallery." = "Impossible de charger les modèles IA — affichage de la galerie intégrée.";
+"Design-Based" = "Basé sur le design";
+"Profession-Based" = "Basé sur la profession";
+"Professional" = "Professionnel";
+"Modern" = "Moderne";
+"Creative" = "Créatif";
+"Minimal" = "Minimaliste";
+"Executive" = "Cadre dirigeant";
+"ATS layout" = "Mise en page ATS";
+"Sidebar left" = "Barre latérale gauche";
+"Sidebar right" = "Barre latérale droite";
+
+// MARK: - Aperçu du CV
+"CV preview" = "Aperçu du CV";
+"Export PDF…" = "Exporter en PDF…";
+"Layout matches the CV Maker thumbnail for this template. Export a PDF that matches what you see here (fonts, columns, colours, and rules)." = "La mise en page correspond à la miniature du Créateur de CV pour ce modèle. Exportez un PDF qui correspond à ce que vous voyez ici (polices, colonnes, couleurs et règles).";
+"The résumé could not be rendered to PDF (empty output). Try scrolling the preview so it lays out, then export again." = "Le CV n'a pas pu être converti en PDF (sortie vide). Essayez de faire défiler l'aperçu pour qu'il se mette en page, puis exportez à nouveau.";
+"Couldn’t save PDF" = "Impossible d'enregistrer le PDF";
+"Your name" = "Votre nom";
+"Professional headline" = "Titre professionnel";
+"Experience" = "Expérience";
+"Highlights" = "Points forts";
+"Summary" = "Résumé";
+"Contact" = "Contact";
+"Skills" = "Compétences";
+"Tools" = "Outils";
+"Languages & more" = "Langues et plus";
+"Certificates" = "Certificats";
+"Referrals" = "Recommandations";
+"Professional Summary" = "Résumé professionnel";
+"Selected Experience" = "Expérience sélectionnée";
+"Core Competencies" = "Compétences clés";
+"Impact" = "Impact";
+"Add contact in your profile" = "Ajoutez un contact dans votre profil";
+"Add contact details in your profile" = "Ajoutez des coordonnées dans votre profil";
+"Add a career summary or interests in your profile to populate this column." = "Ajoutez un résumé de carrière ou des centres d'intérêt dans votre profil pour remplir cette colonne.";
+"CV" = "CV";
+"Open to relocation" = "Prêt à déménager";
+"STRENGTHS" = "FORCES";
+"PORTFOLIO SNAPSHOT" = "APERÇU DU PORTFOLIO";
+"Close" = "Fermer";
+"/ day" = "/ jour";
+"/ %d days" = "/ %d jours";
+"/ %d weeks" = "/ %d semaines";
+"/ %d months" = "/ %d mois";
+"/ %d years" = "/ %d ans";
+
+// MARK: - Noms des modèles de CV
+"Paper White" = "Blanc Pur";
+"Swiss" = "Suisse";
+"Mono" = "Mono";
+"Airy" = "Aérien";
+"Tabular" = "Tabulaire";
+"Facet" = "Facette";
+"Corporate" = "Corporate";
+"Atlas" = "Atlas";
+"Ledger" = "Registre";
+"Harbor" = "Havre";
+"Clear Path" = "Chemin Clair";
+"Pinstripe" = "Rayures Fines";
+"Briefing" = "Briefing";
+"Quorum" = "Quorum";
+"Docket" = "Rôle";
+"Conduit" = "Conduit";
+"Principal" = "Principal";
+"Charter" = "Charte";
+"Vertex" = "Sommet";
+"Linea" = "Ligne";
+"Prism" = "Prisme";
+"Circuit" = "Circuit";
+"North" = "Nord";
+"Axis" = "Axe";
+"Marigold" = "Œillet d'Inde";
+"Ember" = "Braise";
+"Lattice" = "Treillis";
+"Bloom" = "Floraison";
+"Studio" = "Studio";
+"Kite" = "Cerf-volant";
+"Regent" = "Régent";
+"Monarch" = "Monarque";
+"Sterling" = "Sterling";
+"Summit" = "Sommet";
+"Estate" = "Domaine";
+"Chairman" = "Président";
+"Blue Ocean" = "Océan Bleu";
+
+// MARK: - Contenu de l'aperçu de démonstration du CV
+"Sarah Johnson" = "Sarah Johnson";
+"Senior Product Manager" = "Chef de produit senior";
+"Group PM, Consumer Growth & Activation" = "Responsable de groupe produit, Croissance et Activation des consommateurs";
+"Google · Mountain View, CA · 2019 – Present" = "Google · Mountain View, Californie · 2019 – Présent";
+"Stanford University" = "Université de Stanford";
+"M.S. Management Science & Engineering" = "Master en sciences du management et ingénierie";
+"2014 – 2016" = "2014 – 2016";
+"Mountain View, CA" = "Mountain View, Californie";
+"Product leader shipping roadmap, discovery, and analytics for high-scale consumer experiences." = "Responsable produit, pilotant la feuille de route, la découverte et les analyses pour des expériences consommateurs à grande échelle.";
+"Defined multi-year platform strategy with exec stakeholders and quarterly OKRs." = "Définition d'une stratégie de plateforme pluriannuelle avec les parties prenantes exécutives et les OKR trimestriels.";
+"Partnered with engineering and design to launch experiments improving activation by 12%." = "Partenariat avec l'ingénierie et le design pour lancer des expériences améliorant l'activation de 12 %.";
+"Stood up quarterly business reviews with finance and GTM, aligning spend to north-star metrics." = "Mise en place de revues commerciales trimestrielles avec les finances et le GTM, alignant les dépenses sur les métriques clés.";
+"Presented roadmap shifts to the leadership team and translated trade-offs into clear investment asks." = "Présentation des changements de feuille de route à l'équipe de direction et traduction des compromis en demandes d'investissement claires.";
+"Figma · SQL · Amplitude · Jira · BigQuery" = "Figma · SQL · Amplitude · Jira · BigQuery";
+"Product Strategy" = "Stratégie produit";
+"A/B Testing" = "Tests A/B";
+"Roadmapping" = "Feuille de route";
+"CONTACT" = "CONTACT";
+"SKILLS" = "COMPÉTENCES";
+"PROFILE" = "PROFIL";
+"EXPERIENCE" = "EXPÉRIENCE";
+"EDUCATION" = "FORMATION";
+"SUMMARY" = "RÉSUMÉ";
+"PROFESSIONAL SUMMARY" = "RÉSUMÉ PROFESSIONNEL";
+"SELECTED EXPERIENCE" = "EXPÉRIENCE SÉLECTIONNÉE";
+"CORE COMPETENCIES" = "COMPÉTENCES CLÉS";
+"TOOLS" = "OUTILS";
+"IMPACT" = "IMPACT";
+
+// MARK: - Navigateur d'emplois
+"Return to the previous screen" = "Revenir à l'écran précédent";
+
+// MARK: - Erreurs
+"We couldn't reach the server. Check your internet connection and try again." = "Nous ne pouvons pas joindre le serveur. Vérifiez votre connexion Internet et réessayez.";
+"The search was cancelled. Try again when you're ready." = "La recherche a été annulée. Réessayez quand vous êtes prêt.";
+"Something went wrong while searching. Please try again in a moment." = "Une erreur s'est produite lors de la recherche. Veuillez réessayer dans un instant.";
+"Job search is unavailable." = "La recherche d'emploi n'est pas disponible.";
+
+// MARK: - Alertes
+"This profile will be removed from this Mac." = "Ce profil sera supprimé de ce Mac.";
+
+// MARK: - Chaînes de format
+"Loading %@" = "Chargement de %@";
+"Loading %@. %@" = "Chargement de %@. %@";
+"Starting %@…" = "Démarrage de %@…";
+"%d replies left" = "%d réponses restantes";
+"%d saved positions" = "%d postes enregistrés";
+"“%@” will be removed from this Mac." = "« %@ » sera supprimé de ce Mac.";
+"%@ isn’t available yet" = "%@ n'est pas encore disponible";
+"I couldn't find new matches for “%@”. Try a different angle or a more specific keyword." = "Je n'ai pas trouvé de nouvelles correspondances pour « %@ ». Essayez un autre angle ou un mot-clé plus spécifique.";
+"No jobs found for “%@”. Try another title, skill, company, or location." = "Aucun emploi trouvé pour « %@ ». Essayez un autre titre, compétence, entreprise ou lieu.";
+"Here are %d more %@ for “%@”." = "Voici %d %@ supplémentaires pour « %@ ».";
+"Found %d %@ for “%@”. Tap Apply to open the listing or Save to revisit later." = "%d %@ trouvé(s) pour « %@ ». Appuyez sur Postuler pour ouvrir l'annonce ou sur Enregistrer pour y revenir plus tard.";
+"Get %@" = "Obtenir %@";
+"You chose the “%@” template. Tap Build CV on a profile to preview your résumé with that layout." = "Vous avez choisi le modèle « %@ ». Appuyez sur Créer un CV sur un profil pour prévisualiser votre CV avec cette mise en page.";
+"Experience %d" = "Expérience %d";
+"Education %d" = "Formation %d";
+"Please fill in: %@." = "Veuillez remplir : %@.";
+
+// MARK: - Multiligne
+"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)" = "Ajoutez vos identifiants Mac App Store dans les paramètres de build de la cible :\n• AppStoreAppID — identifiant numérique de l'application depuis App Store Connect\n• AppStoreDeveloperID — identifiant numérique du développeur (pour votre page d'autres applications)";