From 205c09137aff35d35911beb9f7afc26c5bf24008 Mon Sep 17 00:00:00 2001 From: Haim Barad Date: Thu, 4 Jun 2026 19:26:12 +0300 Subject: [PATCH 1/4] feat(audit): add frontend i18n key-reference audit script + report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/audit-frontend-keys.mjs — an AST-based (TypeScript compiler API) auditor that extracts every t() key the core_api dashboard frontend references and diffs them against the en-US source-of-truth JSON and the bundled fallback. Emits machine- and human-readable reports under docs/audits/. Un-ignores docs/audits/ so reports are tracked deliverables. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 +- .../audits/2026-06-04-frontend-key-audit.json | 2596 +++++++++++++++++ docs/audits/2026-06-04-frontend-key-audit.md | 142 + scripts/audit-frontend-keys.mjs | 582 ++++ 4 files changed, 3323 insertions(+), 1 deletion(-) create mode 100644 docs/audits/2026-06-04-frontend-key-audit.json create mode 100644 docs/audits/2026-06-04-frontend-key-audit.md create mode 100644 scripts/audit-frontend-keys.mjs diff --git a/.gitignore b/.gitignore index 575a765..058e1a6 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,9 @@ CLAUDE.md **/CLAUDE.md # Internal planning docs — kept locally, not in version control -docs/ +docs/* +# ...except audit reports, which are committed deliverables +!docs/audits/ _bmad* diff --git a/docs/audits/2026-06-04-frontend-key-audit.json b/docs/audits/2026-06-04-frontend-key-audit.json new file mode 100644 index 0000000..be046da --- /dev/null +++ b/docs/audits/2026-06-04-frontend-key-audit.json @@ -0,0 +1,2596 @@ +{ + "summary": { + "generatedAt": "2026-06-04T16:23:02.310Z", + "inputs": { + "frontendSrc": "/tmp/wt-core_api-i18n/frontend/src", + "localeDir": "/tmp/wt-locplat-i18n/packages/i18n/locales/en-US", + "fallback": "/tmp/wt-core_api-i18n/frontend/src/lib/i18n-utils.ts", + "tsModule": "/tmp/wt-core_api-i18n/frontend/node_modules/typescript" + }, + "filesScanned": 716, + "totalReferenceOccurrences": 2124, + "uniqueReferencedKeys": 1858, + "localeNamespaces": { + "clover": 35, + "common": 2768, + "emails": 409, + "eposnow": 18, + "errors": 284, + "giftCards": 536, + "marketing": 32, + "notifications": 103, + "shopify": 23, + "validation": 141, + "wordpress": 618 + }, + "bundleNamespaces": { + "common": 2766, + "marketing": 32 + }, + "counts": { + "m1_missingFromJson": 14, + "m2_referenced_jsonNotBundle": 0, + "m2_referenced_bundleNotJson": 14, + "m2_full_jsonNotBundle": 2187, + "m2_full_bundleNotJson": 18, + "m3_missingFromBoth": 0, + "dynamic_manualReview": 32 + } + }, + "m1": { + "common": [ + { + "key": "app.admin.aiAdvisor.initialSummaryMessage", + "locations": [ + { + "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", + "line": 70, + "shape": "client" + } + ], + "inBundle": true + }, + { + "key": "app.admin.aiAdvisor.failedToFetchData", + "locations": [ + { + "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", + "line": 103, + "shape": "client" + } + ], + "inBundle": true + }, + { + "key": "app.admin.aiAdvisor.pageTitle", + "locations": [ + { + "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", + "line": 189, + "shape": "client" + } + ], + "inBundle": true + }, + { + "key": "app.admin.aiAdvisor.liveData", + "locations": [ + { + "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", + "line": 237, + "shape": "client" + } + ], + "inBundle": true + }, + { + "key": "app.admin.aiAdvisor.applied", + "locations": [ + { + "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", + "line": 247, + "shape": "client" + } + ], + "inBundle": true + }, + { + "key": "app.admin.aiAdvisor.fromSource", + "locations": [ + { + "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", + "line": 248, + "shape": "client" + } + ], + "inBundle": true + }, + { + "key": "app.admin.aiAdvisor.aiStatus", + "locations": [ + { + "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", + "line": 259, + "shape": "client" + } + ], + "inBundle": true + }, + { + "key": "app.admin.aiAdvisor.aiConnected", + "locations": [ + { + "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", + "line": 262, + "shape": "client" + } + ], + "inBundle": true + }, + { + "key": "app.admin.aiAdvisor.aiDisconnected", + "locations": [ + { + "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", + "line": 263, + "shape": "client" + } + ], + "inBundle": true + }, + { + "key": "app.admin.aiAdvisor.refreshData", + "locations": [ + { + "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", + "line": 268, + "shape": "client" + } + ], + "inBundle": true + }, + { + "key": "app.admin.aiAdvisor.noDataTitle", + "locations": [ + { + "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", + "line": 299, + "shape": "client" + } + ], + "inBundle": true + }, + { + "key": "app.admin.aiAdvisor.noDataDescription", + "locations": [ + { + "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", + "line": 300, + "shape": "client" + } + ], + "inBundle": true + }, + { + "key": "app.admin.aiAdvisor.pocNotice", + "locations": [ + { + "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", + "line": 308, + "shape": "client" + } + ], + "inBundle": true + }, + { + "key": "nav.noProgram", + "locations": [ + { + "file": "src/components/layouts/Navbar.tsx", + "line": 236, + "shape": "client" + } + ], + "inBundle": true + } + ] + }, + "m2": { + "referenced": { + "jsonNotBundle": [], + "bundleNotJson": [ + "common:nav.noProgram", + "common:app.admin.aiAdvisor.aiConnected", + "common:app.admin.aiAdvisor.aiDisconnected", + "common:app.admin.aiAdvisor.aiStatus", + "common:app.admin.aiAdvisor.applied", + "common:app.admin.aiAdvisor.failedToFetchData", + "common:app.admin.aiAdvisor.fromSource", + "common:app.admin.aiAdvisor.initialSummaryMessage", + "common:app.admin.aiAdvisor.liveData", + "common:app.admin.aiAdvisor.noDataDescription", + "common:app.admin.aiAdvisor.noDataTitle", + "common:app.admin.aiAdvisor.pageTitle", + "common:app.admin.aiAdvisor.pocNotice", + "common:app.admin.aiAdvisor.refreshData" + ] + }, + "full": { + "jsonNotBundleCount": 2187, + "bundleNotJsonCount": 18, + "jsonNotBundle": [ + "clover:clover.pos.title", + "clover:clover.pos.enrollPrompt", + "clover:clover.pos.pointsBalance", + "clover:clover.pos.pointsEarned", + "clover:clover.pos.rewardAvailable", + "clover:clover.pos.redeemButton", + "clover:clover.pos.cancelButton", + "clover:clover.loyalty.tier", + "clover:clover.loyalty.memberSince", + "clover:clover.loyalty.lifetimePoints", + "clover:clover.ui.confirm", + "clover:clover.ui.ok", + "clover:clover.ui.cancel", + "clover:clover.ui.search", + "clover:clover.ui.searchHint", + "clover:clover.ui.customerDetails", + "clover:clover.ui.noCustomerName", + "clover:clover.ui.noOfferName", + "clover:clover.offers.confirmUseOffer", + "clover:clover.offers.confirmUseOtherOffer", + "clover:clover.offers.costTemplate", + "clover:clover.offers.redeemButton", + "clover:clover.offers.doNotRedeemButton", + "clover:clover.offers.useButton", + "clover:clover.offers.doNotUseButton", + "clover:clover.offers.activeLabel", + "clover:clover.errors.connectionFailed", + "clover:clover.errors.enrollFailed", + "clover:clover.errors.transactionFailed", + "clover:clover.errors.unableToLoadCustomer", + "clover:clover.errors.unableToUseOffer", + "clover:clover.errors.unexpectedError", + "clover:clover.success.enrolled", + "clover:clover.success.pointsAdded", + "clover:clover.success.redeemed", + "common:actions.loadMore", + "common:tab.barcodeType", + "common:templates.account.overview.passTemplates", + "common:templates.account.overview.passes", + "common:templates.account.overview.passTypeIds", + "common:templates.account.overview.passTypeIdsHelper", + "common:templates.account.overview.appKeys", + "common:templates.account.overview.empty", + "common:wordpress.auth.activateCta", + "common:wordpress.auth.subtitle", + "common:wordpress.auth.activating", + "common:wordpress.auth.success", + "common:wordpress.auth.settingUp", + "common:wordpress.auth.manualToggle", + "common:wordpress.auth.manualLabel", + "common:wordpress.auth.manualPlaceholder", + "common:wordpress.auth.manualCta", + "common:wordpress.auth.tryAgain", + "common:common.yes", + "common:common.loadingData", + "emails:welcome.subject", + "emails:welcome.body", + "emails:welcome.greeting", + "emails:welcome.pointsInfo", + "emails:welcome.cta", + "emails:welcome.footer", + "emails:rewardEarned.subject", + "emails:rewardEarned.body", + "emails:pointsEarned.subject", + "emails:pointsEarned.body", + "emails:pointsEarned.greeting", + "emails:pointsEarned.transactionDetails", + "emails:pointsEarned.amount", + "emails:pointsEarned.date", + "emails:pointsEarned.newBalance", + "emails:pointsEarned.cta", + "emails:rewardExpiring.subject", + "emails:rewardExpiring.body", + "emails:rewardExpiring.greeting", + "emails:rewardExpiring.cta", + "emails:rewardExpiring.footer", + "emails:campaign.subject", + "emails:campaign.body", + "emails:birthday.subject", + "emails:birthday.body", + "emails:passwordReset.subject", + "emails:passwordReset.body", + "emails:passwordReset.greeting", + "emails:passwordReset.cta", + "emails:passwordReset.expiryNote", + "emails:passwordReset.ignoreNote", + "emails:rewardRedeemed.subject", + "emails:rewardRedeemed.greeting", + "emails:rewardRedeemed.body", + "emails:rewardRedeemed.rewardDetails", + "emails:rewardRedeemed.rewardName", + "emails:rewardRedeemed.pointsUsed", + "emails:rewardRedeemed.remainingBalance", + "emails:rewardRedeemed.instructions", + "emails:rewardRedeemed.expiryNote", + "emails:rewardRedeemed.cta", + "emails:tierUpgrade.subject", + "emails:tierUpgrade.greeting", + "emails:tierUpgrade.body", + "emails:tierUpgrade.benefits", + "emails:tierUpgrade.cta", + "emails:tierUpgrade.footer", + "emails:birthdayReward.subject", + "emails:birthdayReward.greeting", + "emails:birthdayReward.body", + "emails:birthdayReward.rewardInfo", + "emails:birthdayReward.cta", + "emails:birthdayReward.expiryNote", + "emails:inactivityReminder.subject", + "emails:inactivityReminder.greeting", + "emails:inactivityReminder.body", + "emails:inactivityReminder.pointsReminder", + "emails:inactivityReminder.cta", + "emails:inactivityReminder.footer", + "emails:pointsExpiring.subject", + "emails:pointsExpiring.greeting", + "emails:pointsExpiring.currentBalance", + "emails:pointsExpiring.cta", + "emails:pointsExpiring.footer", + "emails:emailVerification.subject", + "emails:emailVerification.greeting", + "emails:emailVerification.body", + "emails:emailVerification.cta", + "emails:emailVerification.expiryNote", + "emails:footer.unsubscribe", + "emails:footer.privacyPolicy", + "emails:footer.termsOfService", + "emails:footer.contactUs", + "emails:footer.copyright", + "emails:footer.poweredBy", + "emails:welcome_new_user.subject", + "emails:welcome_new_user.greeting", + "emails:welcome_new_user.welcome_message", + "emails:welcome_new_user.acknowledgment", + "emails:welcome_new_user.setup_call_heading", + "emails:welcome_new_user.setup_call_message", + "emails:welcome_new_user.setup_call_button", + "emails:welcome_new_user.dashboard_access", + "emails:welcome_new_user.dashboard_button", + "emails:welcome_new_user.alternative_access", + "emails:welcome_new_user.login_credentials_heading", + "emails:welcome_new_user.username_label", + "emails:welcome_new_user.password_label", + "emails:welcome_new_user.password_recommendation", + "emails:welcome_new_user.quick_start_heading", + "emails:welcome_new_user.quick_start_step_1_instruction", + "emails:welcome_new_user.quick_start_step_1_details", + "emails:welcome_new_user.quick_start_step_2_instruction", + "emails:welcome_new_user.quick_start_step_2_details", + "emails:welcome_new_user.quick_start_step_3_instruction", + "emails:welcome_new_user.quick_start_step_3_details", + "emails:welcome_new_user.launch_ready_heading", + "emails:welcome_new_user.launch_tip_1", + "emails:welcome_new_user.launch_tip_2", + "emails:welcome_new_user.youtube_channel_heading", + "emails:welcome_new_user.youtube_link", + "emails:welcome_new_user.quick_links_heading", + "emails:welcome_new_user.quick_links_create_reward", + "emails:welcome_new_user.quick_links_customize_pass", + "emails:welcome_new_user.quick_links_join_program", + "emails:welcome_new_user.closing_message", + "emails:welcome_new_user.closing_signoff", + "emails:welcome_new_user.founder_signature", + "emails:welcome_new_user.website_link", + "emails:welcome_new_user.founder_email", + "emails:welcome_new_user.support_email", + "emails:welcome_new_user_verify.subject", + "emails:welcome_new_user_verify.greeting", + "emails:welcome_new_user_verify.welcome_message", + "emails:welcome_new_user_verify.acknowledgment", + "emails:welcome_new_user_verify.setup_call_heading", + "emails:welcome_new_user_verify.setup_call_message", + "emails:welcome_new_user_verify.setup_call_button", + "emails:welcome_new_user_verify.dashboard_access", + "emails:welcome_new_user_verify.verify_button", + "emails:welcome_new_user_verify.alternative_access", + "emails:password_reset_wordpress.subject", + "emails:password_reset_wordpress.greeting", + "emails:password_reset_wordpress.welcome_message", + "emails:password_reset_wordpress.instructions", + "emails:password_reset_wordpress.reset_button", + "emails:password_reset_wordpress.alternative_access", + "emails:password_reset_wordpress.expiration_notice", + "emails:password_reset_wordpress.dashboard_access", + "emails:password_reset_wordpress.next_steps_heading", + "emails:password_reset_wordpress.help_heading", + "emails:password_reset_wordpress.help_contact", + "emails:password_reset_wordpress.website", + "emails:password_reset_wordpress.footer_copyright", + "emails:password_reset.subject", + "emails:password_reset.greeting", + "emails:password_reset.body", + "emails:password_reset.reset_button", + "emails:password_reset.alternative_access", + "emails:password_reset.no_action_needed", + "emails:password_reset.help_heading", + "emails:password_reset.help_contact", + "emails:password_reset.website", + "emails:password_reset.footer_copyright", + "emails:email_verification.subject", + "emails:email_verification.greeting", + "emails:email_verification.instruction", + "emails:email_verification.expiration_notice", + "emails:email_verification.help_heading", + "emails:email_verification.help_contact", + "emails:email_verification.closing_message", + "emails:email_verification.signoff", + "emails:email_verification.website", + "emails:email_verification.footer_copyright", + "emails:voucher_received.subject", + "emails:voucher_received.greeting", + "emails:voucher_received.voucher_heading", + "emails:voucher_received.voucher_intro", + "emails:voucher_received.expiration_label", + "emails:voucher_received.redemption_steps_heading", + "emails:voucher_received.redemption_step_1", + "emails:voucher_received.redemption_step_2", + "emails:voucher_received.redemption_step_3", + "emails:voucher_received.voucher_restriction", + "emails:voucher_received.redemption_button", + "emails:voucher_received.help_contact", + "emails:voucher_received.closing_message", + "emails:voucher_received.signoff", + "emails:welcome_voucher.subject", + "emails:welcome_voucher.greeting", + "emails:welcome_voucher.welcome_message", + "emails:welcome_voucher.expiration_label", + "emails:welcome_voucher.voucher_intro", + "emails:welcome_voucher.redemption_steps_heading", + "emails:welcome_voucher.redemption_step_1", + "emails:welcome_voucher.redemption_step_2", + "emails:welcome_voucher.redemption_step_3", + "emails:welcome_voucher.voucher_restriction", + "emails:welcome_voucher.redemption_button", + "emails:welcome_voucher.help_contact", + "emails:welcome_voucher.closing_message", + "emails:welcome_voucher.signoff", + "emails:birthday_voucher.subject", + "emails:birthday_voucher.greeting", + "emails:birthday_voucher.birthday_wish", + "emails:birthday_voucher.birthday_message", + "emails:birthday_voucher.gift_intro", + "emails:birthday_voucher.expiration_label", + "emails:birthday_voucher.gift_message", + "emails:birthday_voucher.redemption_steps_heading", + "emails:birthday_voucher.redemption_step_1", + "emails:birthday_voucher.redemption_step_2", + "emails:birthday_voucher.redemption_step_3", + "emails:birthday_voucher.voucher_restriction", + "emails:birthday_voucher.redemption_button", + "emails:birthday_voucher.help_contact", + "emails:birthday_voucher.birthday_wishes", + "emails:birthday_voucher.signoff", + "emails:digital_pass_ready.subject", + "emails:digital_pass_ready.greeting", + "emails:digital_pass_ready.good_news", + "emails:digital_pass_ready.benefits_intro", + "emails:digital_pass_ready.steps_heading", + "emails:digital_pass_ready.download_step", + "emails:digital_pass_ready.install_step", + "emails:digital_pass_ready.download_button", + "emails:digital_pass_ready.help_message", + "emails:digital_pass_ready.thank_you", + "emails:digital_pass_ready.signoff", + "emails:digital_pass_ready.footer_copyright", + "emails:transaction_summary.subject", + "emails:transaction_summary.message_1", + "emails:transaction_summary.message_2", + "emails:transaction_summary.download_link", + "emails:transaction_summary.footer_copyright", + "emails:admin_daily_report.date_range", + "emails:admin_daily_report.report_link", + "emails:admin_daily_report.footer_copyright", + "emails:wordpress_email_failure_alert.alert_heading", + "emails:wordpress_email_failure_alert.alert_summary", + "emails:wordpress_email_failure_alert.account_details_heading", + "emails:wordpress_email_failure_alert.account_id_label", + "emails:wordpress_email_failure_alert.business_name_label", + "emails:wordpress_email_failure_alert.merchant_email_label", + "emails:wordpress_email_failure_alert.timestamp_label", + "emails:wordpress_email_failure_alert.error_details_heading", + "emails:wordpress_email_failure_alert.recommended_actions_heading", + "emails:wordpress_email_failure_alert.action_view_account", + "emails:wordpress_email_failure_alert.action_resend_email", + "emails:wordpress_email_failure_alert.action_contact_merchant", + "emails:wordpress_email_failure_alert.action_check_email_config", + "emails:wordpress_email_failure_alert.common_causes_heading", + "emails:wordpress_email_failure_alert.common_cause_1", + "emails:wordpress_email_failure_alert.common_cause_2", + "emails:wordpress_email_failure_alert.common_cause_3", + "emails:wordpress_email_failure_alert.common_cause_4", + "emails:wordpress_email_failure_alert.footer_message", + "emails:wordpress_email_failure_alert.footer_copyright", + "emails:shopify_install_customer.subject", + "emails:shopify_install_customer.greeting", + "emails:shopify_install_customer.welcome_message", + "emails:shopify_install_customer.intro_message", + "emails:shopify_install_customer.goal_message", + "emails:shopify_install_customer.setup_message", + "emails:shopify_install_customer.video_call_message", + "emails:shopify_install_customer.questions_heading", + "emails:shopify_install_customer.question_1", + "emails:shopify_install_customer.question_2", + "emails:shopify_install_customer.question_3", + "emails:shopify_install_customer.question_4", + "emails:shopify_install_customer.looking_forward", + "emails:shopify_install_customer.closing_message", + "emails:shopify_install_customer.contact_signature", + "emails:shopify_install_customer.follow_us_heading", + "emails:shopify_install_customer.website_link", + "emails:eposnow_install_customer.subject", + "emails:eposnow_install_customer.greeting", + "emails:eposnow_install_customer.welcome_message", + "emails:eposnow_install_customer.intro_message", + "emails:eposnow_install_customer.goal_message", + "emails:eposnow_install_customer.setup_message", + "emails:eposnow_install_customer.video_call_message", + "emails:eposnow_install_customer.questions_heading", + "emails:eposnow_install_customer.question_1", + "emails:eposnow_install_customer.question_2", + "emails:eposnow_install_customer.question_3", + "emails:eposnow_install_customer.question_4", + "emails:eposnow_install_customer.looking_forward", + "emails:eposnow_install_customer.closing_message", + "emails:eposnow_install_customer.contact_signature", + "emails:eposnow_install_customer.follow_us_heading", + "emails:eposnow_install_customer.website_link", + "emails:shopify_data_request_customer.subject", + "emails:shopify_data_request_customer.message_1", + "emails:shopify_data_request_customer.message_2", + "emails:shopify_data_request_customer.shop_id", + "emails:shopify_data_request_customer.shop_domain", + "emails:shopify_data_request_customer.customer_id", + "emails:shopify_data_request_customer.customer_email", + "emails:shopify_data_request_customer.customer_phone", + "emails:shopify_data_request_customer.requested_orders", + "emails:shopify_data_request_customer.gdpr_link", + "emails:eposnow_data_request_customer.subject", + "emails:eposnow_data_request_customer.message_1", + "emails:eposnow_data_request_customer.message_2", + "emails:eposnow_data_request_customer.shop_id", + "emails:eposnow_data_request_customer.shop_domain", + "emails:eposnow_data_request_customer.customer_id", + "emails:eposnow_data_request_customer.customer_email", + "emails:eposnow_data_request_customer.customer_phone", + "emails:eposnow_data_request_customer.requested_orders", + "emails:eposnow_data_request_customer.gdpr_link", + "emails:giftCardPurchased.subject", + "emails:giftCardPurchased.greeting", + "emails:giftCardPurchased.body", + "emails:giftCardPurchased.cardNumber", + "emails:giftCardPurchased.securityCode", + "emails:giftCardPurchased.balance", + "emails:giftCardPurchased.expires", + "emails:giftCardPurchased.personalMessage", + "emails:giftCardPurchased.cta", + "emails:giftCardPurchased.footer", + "emails:giftCardExpiringSoon.subject", + "emails:giftCardExpiringSoon.greeting", + "emails:giftCardExpiringSoon.body", + "emails:giftCardExpiringSoon.balance", + "emails:giftCardExpiringSoon.cta", + "emails:giftCardExpiringSoon.footer", + "emails:giftCardSoldReceipt.subject", + "emails:giftCardSoldReceipt.greeting", + "emails:giftCardSoldReceipt.body", + "emails:giftCardSoldReceipt.cardNumber", + "emails:giftCardSoldReceipt.amount", + "emails:giftCardSoldReceipt.cardType", + "emails:giftCardSoldReceipt.recipient", + "emails:giftCardSoldReceipt.timestamp", + "emails:giftCardSoldReceipt.footer", + "emails:giftCardReloadReceipt.subject", + "emails:giftCardReloadReceipt.greeting", + "emails:giftCardReloadReceipt.body", + "emails:giftCardReloadReceipt.newBalance", + "emails:giftCardReloadReceipt.cta", + "emails:giftCardReloadReceipt.footer", + "emails:merchant_monthly_summary.subject", + "emails:merchant_monthly_summary.greeting", + "emails:merchant_monthly_summary.body", + "emails:merchant_monthly_summary.newMembers", + "emails:merchant_monthly_summary.pointsIssued", + "emails:merchant_monthly_summary.rewardsRedeemed", + "emails:merchant_monthly_summary.totalTransactions", + "emails:merchant_monthly_summary.cta", + "emails:merchant_monthly_summary.footer", + "emails:integration_sync_failure.subject", + "emails:integration_sync_failure.greeting", + "emails:integration_sync_failure.body", + "emails:integration_sync_failure.errorMessage", + "emails:integration_sync_failure.lastSuccessfulSync", + "emails:integration_sync_failure.troubleshooting", + "emails:integration_sync_failure.cta", + "emails:integration_sync_failure.footer", + "emails:low_gift_card_balance.subject", + "emails:low_gift_card_balance.greeting", + "emails:low_gift_card_balance.body", + "emails:low_gift_card_balance.currentBalance", + "emails:low_gift_card_balance.threshold", + "emails:low_gift_card_balance.recommendation", + "emails:low_gift_card_balance.cta", + "emails:low_gift_card_balance.footer", + "emails:tier_downgrade.subject", + "emails:tier_downgrade.greeting", + "emails:tier_downgrade.body", + "emails:tier_downgrade.currentTier", + "emails:tier_downgrade.newTier", + "emails:tier_downgrade.reason", + "emails:tier_downgrade.howToMaintain", + "emails:tier_downgrade.cta", + "emails:tier_downgrade.footer", + "emails:referral_earned.subject", + "emails:referral_earned.greeting", + "emails:referral_earned.body", + "emails:referral_earned.bonusPoints", + "emails:referral_earned.newBalance", + "emails:referral_earned.cta", + "emails:referral_earned.footer", + "emails:account_suspended.subject", + "emails:account_suspended.greeting", + "emails:account_suspended.body", + "emails:account_suspended.reason", + "emails:account_suspended.contactSupport", + "emails:account_suspended.supportEmail", + "emails:account_suspended.canAppeal", + "emails:account_suspended.footer", + "emails:payment_failed.subject", + "emails:payment_failed.greeting", + "emails:payment_failed.body", + "emails:payment_failed.failureReason", + "emails:payment_failed.retryDate", + "emails:payment_failed.updatePayment", + "emails:payment_failed.cta", + "emails:payment_failed.footer", + "emails:subscription_renewing.subject", + "emails:subscription_renewing.greeting", + "emails:subscription_renewing.body", + "emails:subscription_renewing.plan", + "emails:subscription_renewing.amount", + "emails:subscription_renewing.paymentMethod", + "emails:subscription_renewing.cancelOption", + "emails:subscription_renewing.cta", + "emails:subscription_renewing.footer", + "emails:merchant_welcome.subject", + "emails:merchant_welcome.greeting", + "emails:merchant_welcome.body", + "emails:merchant_welcome.nextSteps", + "emails:merchant_welcome.step1", + "emails:merchant_welcome.step2", + "emails:merchant_welcome.step3", + "emails:merchant_welcome.step4", + "emails:merchant_welcome.cta", + "emails:merchant_welcome.support", + "emails:merchant_welcome.footer", + "emails:api_rate_limit_warning.subject", + "emails:api_rate_limit_warning.greeting", + "emails:api_rate_limit_warning.body", + "emails:api_rate_limit_warning.currentUsage", + "emails:api_rate_limit_warning.resetTime", + "emails:api_rate_limit_warning.recommendation", + "emails:api_rate_limit_warning.upgradeOption", + "emails:api_rate_limit_warning.cta", + "emails:api_rate_limit_warning.footer", + "eposnow:eposnow.pos.title", + "eposnow:eposnow.pos.subtitle", + "eposnow:eposnow.pos.connectButton", + "eposnow:eposnow.pos.disconnectButton", + "eposnow:eposnow.pos.connectedStatus", + "eposnow:eposnow.pos.disconnectedStatus", + "eposnow:eposnow.loyalty.enrollPrompt", + "eposnow:eposnow.loyalty.pointsBalance", + "eposnow:eposnow.loyalty.pointsEarned", + "eposnow:eposnow.loyalty.rewardAvailable", + "eposnow:eposnow.loyalty.redeemButton", + "eposnow:eposnow.loyalty.cancelButton", + "eposnow:eposnow.errors.connectionFailed", + "eposnow:eposnow.errors.syncFailed", + "eposnow:eposnow.errors.transactionFailed", + "eposnow:eposnow.success.connected", + "eposnow:eposnow.success.enrolled", + "eposnow:eposnow.success.redeemed", + "errors:unauthorized", + "errors:forbidden", + "errors:notFound", + "errors:rateLimit", + "errors:serverError", + "errors:networkError", + "errors:validation.required", + "errors:validation.email", + "errors:validation.phone", + "errors:validation.minLength", + "errors:validation.maxLength", + "errors:validation.invalidFormat", + "errors:validation.invalidEmail", + "errors:validation.invalidPhone", + "errors:validation.invalidDate", + "errors:validation.passwordMismatch", + "errors:validation.weakPassword", + "errors:validation.invalid_language", + "errors:validation.invalid_request", + "errors:validation.business_domain_invalid", + "errors:validation.business_domain_format_invalid", + "errors:validation.business_domain_too_long", + "errors:validation.business_domain_restricted", + "errors:validation.business_domain_invalid_ip", + "errors:validation.business_domain_ssl_error", + "errors:validation.business_domain_timeout", + "errors:validation.business_domain_invalid_redirect", + "errors:validation.request_too_large", + "errors:validation.invalid_session", + "errors:validation.session_not_found", + "errors:validation.password_empty", + "errors:validation.guid_empty", + "errors:validation.guid_invalid_url", + "errors:validation.wordpress_url_invalid", + "errors:validation.wordpress_url_format_invalid", + "errors:validation.redirect_chain_exceeded", + "errors:validation.empty_email", + "errors:validation.max_length_too_short", + "errors:auth.invalidCredentials", + "errors:auth.sessionExpired", + "errors:auth.tokenInvalid", + "errors:auth.accountLocked", + "errors:auth.accountDisabled", + "errors:auth.emailNotVerified", + "errors:auth.passwordResetRequired", + "errors:auth.twoFactorRequired", + "errors:auth.twoFactorInvalid", + "errors:auth.notVerified", + "errors:auth.alreadyLoggedIn", + "errors:auth.loginRequired", + "errors:auth.not_authenticated", + "errors:auth.invalid_token", + "errors:auth.token_missing", + "errors:auth.token_expired", + "errors:auth.unauthorized", + "errors:auth.hmac_validation_failed", + "errors:auth.invalid_signature", + "errors:auth.signature_missing", + "errors:auth.invalid_api_key", + "errors:auth.authentication_failed", + "errors:auth.invalid_auth_header", + "errors:auth.token_missing_header", + "errors:auth.wordpress_oauth_failed", + "errors:integration.square.connectionFailed", + "errors:integration.square.syncFailed", + "errors:integration.shopify.connectionFailed", + "errors:integration.shopify.syncFailed", + "errors:integration.eposnow.connectionFailed", + "errors:integration.eposnow.syncFailed", + "errors:loyalty.insufficientPoints", + "errors:loyalty.rewardExpired", + "errors:loyalty.rewardNotAvailable", + "errors:loyalty.memberNotFound", + "errors:loyalty.alreadyEnrolled", + "errors:http.400", + "errors:http.401", + "errors:http.403", + "errors:http.404", + "errors:http.409", + "errors:http.422", + "errors:http.429", + "errors:http.500", + "errors:http.502", + "errors:http.503", + "errors:member.notFound", + "errors:member.alreadyExists", + "errors:member.invalidEmail", + "errors:member.invalidPhone", + "errors:member.insufficientPoints", + "errors:member.cardNotFound", + "errors:member.cardAlreadyLinked", + "errors:member.enrollmentFailed", + "errors:member.rewardExpired", + "errors:member.rewardAlreadyRedeemed", + "errors:member.tierDowngraded", + "errors:reward.notFound", + "errors:reward.notAvailable", + "errors:reward.expired", + "errors:reward.alreadyRedeemed", + "errors:reward.limitReached", + "errors:reward.tierRequired", + "errors:reward.minimumPointsNotMet", + "errors:merchant.notFound", + "errors:merchant.subscriptionExpired", + "errors:merchant.subscriptionRequired", + "errors:merchant.limitReached", + "errors:merchant.integrationError", + "errors:transaction.failed", + "errors:transaction.alreadyProcessed", + "errors:transaction.invalidAmount", + "errors:transaction.refundNotAllowed", + "errors:transaction.partialRefundNotAllowed", + "errors:network.connectionFailed", + "errors:network.timeout", + "errors:network.offline", + "errors:generic.unknown", + "errors:generic.tryAgain", + "errors:generic.contactSupport", + "errors:resources.program_not_found", + "errors:resources.customer_not_found", + "errors:resources.member_not_found", + "errors:resources.pass_not_found", + "errors:resources.template_not_found", + "errors:resources.scanner_not_found", + "errors:resources.webhook_not_found", + "errors:resources.offer_not_found", + "errors:resources.voucher_not_found", + "errors:resources.shop_not_found", + "errors:resources.shop_not_active", + "errors:resources.shop_program_not_found", + "errors:resources.eposnow_shop_not_found", + "errors:resources.wordpress_account_not_found", + "errors:resources.backup_not_found", + "errors:resources.restore_task_not_found", + "errors:resources.registration_inactive", + "errors:operations.service_maintenance", + "errors:operations.invalid_request_agent", + "errors:operations.invalid_ttl", + "errors:operations.offer_not_active", + "errors:operations.generate_claim_id_failed", + "errors:operations.duplicated_customers", + "errors:operations.order_data_not_found", + "errors:operations.generate_code_error", + "errors:operations.failed_to_renew_certificate", + "errors:operations.certificate_not_found", + "errors:operations.unsupported_pass_style", + "errors:operations.invalid_attempt_rate_limit", + "errors:operations.installation_rate_limit", + "errors:operations.failed_to_register_site", + "errors:operations.missing_webhook_secret", + "errors:operations.configuration_error", + "errors:operations.user_creation_failed", + "errors:operations.user_race_condition", + "errors:operations.email_delivery_failed", + "errors:operations.cannot_submit_task", + "errors:operations.blinker_not_installed", + "errors:operations.database_connection_lost", + "errors:operations.invalid_intermediate_key", + "errors:operations.invalid_message", + "errors:operations.failed_to_fetch_public_keys", + "errors:operations.invalid_merchant_operation", + "errors:server.internal_error", + "errors:server.unknown_exception", + "errors:aiResponseFailed", + "errors:unexpectedError", + "errors:noTextResponse", + "errors:mistralApiKey", + "errors:programIdRequired", + "errors:messageRequired", + "errors:healthCheckFailed", + "errors:noTransactionsFound", + "errors:errorOccurred", + "errors:pleaseUpdateValidConfig", + "errors:pleaseAddProductsFirst", + "errors:databaseOutOfDate", + "errors:anErrorHasOccurred", + "errors:giftCard.notFound", + "errors:giftCard.alreadyActivated", + "errors:giftCard.notActive", + "errors:giftCard.voided", + "errors:giftCard.expired", + "errors:giftCard.insufficientBalance", + "errors:giftCard.invalidSecurityCode", + "errors:giftCard.invalidAmount", + "errors:giftCard.amountExceedsBalance", + "errors:giftCard.maxValueExceeded", + "errors:giftCard.programNotFound", + "errors:giftCard.consolidationFailed", + "errors:giftCard.transferFailed", + "errors:giftCard.reloadFailed", + "errors:giftCard.activationFailed", + "errors:consumer.programNotAvailable", + "errors:consumer.enrollmentClosed", + "errors:consumer.alreadyEnrolled", + "errors:consumer.ineligibleForProgram", + "errors:consumer.accountSuspended", + "errors:consumer.pendingApproval", + "errors:consumer.maxMembersReached", + "errors:consumer.referralCodeInvalid", + "errors:consumer.referralCodeAlreadyUsed", + "errors:consumer.selfReferralNotAllowed", + "errors:consumer.enrollmentFailed", + "errors:consumer.profileUpdateFailed", + "errors:consumer.passInstallFailed", + "errors:consumer.locationRequired", + "errors:consumer.locationPermissionDenied", + "errors:consumer.qrCodeScanFailed", + "errors:consumer.invalidQRCode", + "errors:consumer.expiredQRCode", + "errors:checkout.rewardNotApplicable", + "errors:checkout.rewardExpired", + "errors:checkout.rewardAlreadyRedeemed", + "errors:checkout.insufficientPoints", + "errors:checkout.rewardNotAvailable", + "errors:checkout.minimumOrderNotMet", + "errors:checkout.maximumOrderExceeded", + "errors:checkout.rewardCategoryMismatch", + "errors:checkout.rewardStackingNotAllowed", + "errors:checkout.rewardRequiresTier", + "errors:checkout.pointsCalculationFailed", + "errors:checkout.rewardApplicationFailed", + "errors:enrollment.emailAlreadyExists", + "errors:enrollment.phoneAlreadyExists", + "errors:enrollment.invalidEmailFormat", + "errors:enrollment.invalidPhoneFormat", + "errors:enrollment.passwordTooWeak", + "errors:enrollment.passwordsDoNotMatch", + "errors:enrollment.termsNotAccepted", + "errors:enrollment.gdprConsentRequired", + "errors:enrollment.verificationFailed", + "errors:enrollment.verificationCodeExpired", + "errors:enrollment.verificationCodeInvalid", + "errors:enrollment.tooManyVerificationAttempts", + "errors:enrollment.memberIdRequired", + "errors:enrollment.birthdayRequired", + "errors:enrollment.underAgeRequired", + "errors:enrollment.parentalConsentRequired", + "errors:qrCode.scanFailed", + "errors:qrCode.invalidFormat", + "errors:qrCode.codeExpired", + "errors:qrCode.codeAlreadyUsed", + "errors:qrCode.codeNotRecognized", + "errors:qrCode.cameraPermissionRequired", + "errors:qrCode.cameraNotAvailable", + "errors:qrCode.unsupportedCodeType", + "errors:qrCode.checkInFailed", + "errors:qrCode.redeemFailed", + "errors:qrCode.generateFailed", + "errors:mobile.pushNotificationDisabled", + "errors:mobile.locationServicesDisabled", + "errors:mobile.cameraPermissionDenied", + "errors:mobile.storagePermissionDenied", + "errors:mobile.biometricNotAvailable", + "errors:mobile.biometricEnrollmentRequired", + "errors:mobile.biometricFailed", + "errors:mobile.appUpdateRequired", + "errors:mobile.unsupportedOSVersion", + "errors:mobile.networkConnectionLost", + "errors:mobile.serverUnreachable", + "errors:mobile.maintenanceMode", + "errors:mobile.featureNotAvailable", + "errors:subscription.planExpired", + "errors:subscription.paymentPastDue", + "errors:subscription.subscriptionCancelled", + "errors:subscription.subscriptionPaused", + "errors:subscription.planLimitReached", + "errors:subscription.featureRequiresUpgrade", + "errors:subscription.billingCycleChanged", + "errors:subscription.proratedCharge", + "errors:subscription.invoiceFailed", + "errors:subscription.paymentMethodExpired", + "errors:subscription.paymentMethodDeclined", + "errors:subscription.subscriptionRenewalFailed", + "errors:subscription.downgradeNotAllowed", + "errors:subscription.upgradeInProgress", + "errors:subscription.cancelationPending", + "errors:invalidSession", + "errors:wordpress.sessionExpired", + "errors:wordpress.activationFailed", + "errors:wordpress.tokenExpired", + "errors:wordpress.rateLimited", + "errors:wordpress.networkError", + "errors:wordpress.invalidKey", + "errors:wordpress.genericError", + "giftCards:nav.dashboard", + "giftCards:nav.balanceCheck", + "giftCards:nav.transactionHistory", + "giftCards:nav.consolidateCards", + "giftCards:nav.transferCards", + "giftCards:nav.notifications", + "giftCards:nav.settings", + "giftCards:nav.sellGiftCard", + "giftCards:nav.redeemGiftCards", + "giftCards:nav.manageGiftCards", + "giftCards:nav.reports", + "giftCards:nav.cardList", + "giftCards:nav.consolidate", + "giftCards:nav.transferOwnership", + "giftCards:nav.reloadCard", + "giftCards:layout.giftCards", + "giftCards:layout.trustLevel", + "giftCards:layout.signOut", + "giftCards:layout.signOutAriaLabel", + "giftCards:layout.manageSecurely", + "giftCards:layout.levelAccess", + "giftCards:layout.openMenu", + "giftCards:layout.businessManager", + "giftCards:layout.giftCardPurchase", + "giftCards:layout.giftCardPurchaseSubtitle", + "giftCards:layout.merchantAdmin", + "giftCards:layout.merchantAdminSubtitle", + "giftCards:layout.platformAdmin", + "giftCards:layout.eposNow", + "giftCards:layout.analyticsTitle", + "giftCards:auth.verifyingSession", + "giftCards:auth.portal", + "giftCards:auth.portalSubtitle", + "giftCards:auth.signIn", + "giftCards:auth.signInDesc", + "giftCards:auth.createAccount", + "giftCards:auth.createAccountDesc", + "giftCards:auth.createYourAccount", + "giftCards:auth.chooseRegisterMethod", + "giftCards:auth.chooseSignInMethod", + "giftCards:auth.useEmail", + "giftCards:auth.useEmailDesc", + "giftCards:auth.usePhone", + "giftCards:auth.usePhoneDesc", + "giftCards:auth.demoHint", + "giftCards:auth.back", + "giftCards:auth.verifyAccount", + "giftCards:auth.enterContactPrompt", + "giftCards:auth.sendCode", + "giftCards:auth.enterCodePrompt", + "giftCards:auth.codePlaceholder", + "giftCards:auth.demoCode", + "giftCards:auth.resendCountdown", + "giftCards:auth.resendCode", + "giftCards:auth.registerNote", + "giftCards:auth.signInNote", + "giftCards:auth.accountCreated", + "giftCards:auth.welcome", + "giftCards:auth.redirecting", + "giftCards:auth.securityNote", + "giftCards:auth.errorEmptyContact", + "giftCards:auth.errorInvalidEmail", + "giftCards:auth.codeSent", + "giftCards:auth.errorSendCode", + "giftCards:auth.errorEmptyCode", + "giftCards:auth.errorInvalidCode", + "giftCards:auth.errorVerification", + "giftCards:auth.email", + "giftCards:auth.phone", + "giftCards:dashboard.welcomeNamed", + "giftCards:dashboard.welcomeAnon", + "giftCards:dashboard.getStarted", + "giftCards:dashboard.activeCardsCount_one", + "giftCards:dashboard.activeCardsCount_other", + "giftCards:dashboard.verificationInProgress", + "giftCards:dashboard.verificationLimitedNote", + "giftCards:dashboard.consolidationVerificationNote", + "giftCards:dashboard.totalBalance", + "giftCards:dashboard.activeCount", + "giftCards:dashboard.activeCards", + "giftCards:dashboard.ofTotal", + "giftCards:dashboard.totalSpent", + "giftCards:dashboard.allTime", + "giftCards:dashboard.expiringSoon", + "giftCards:dashboard.within30Days", + "giftCards:dashboard.allCardsValid", + "giftCards:dashboard.noCardsTitle", + "giftCards:dashboard.noCardsDesc", + "giftCards:dashboard.registerCard", + "giftCards:dashboard.merchant", + "giftCards:dashboard.cardCount_one", + "giftCards:dashboard.cardCount_other", + "giftCards:dashboard.quickActions", + "giftCards:dashboard.checkBalance", + "giftCards:dashboard.instantBalance", + "giftCards:dashboard.qrScanner", + "giftCards:dashboard.scanQr", + "giftCards:dashboard.consolidateCards", + "giftCards:dashboard.mergeCards", + "giftCards:dashboard.expirationAlerts", + "giftCards:dashboard.daysRemaining", + "giftCards:dashboard.yourLimits", + "giftCards:dashboard.dailySpendLimit", + "giftCards:dashboard.unlimited", + "giftCards:dashboard.canConsolidate", + "giftCards:dashboard.canTransfer", + "giftCards:dashboard.yes", + "giftCards:dashboard.no", + "giftCards:dashboard.cardDetails", + "giftCards:dashboard.recentTransactions", + "giftCards:dashboard.close", + "giftCards:balance.pageTitle", + "giftCards:balance.pageSubtitle", + "giftCards:balance.intro", + "giftCards:balance.cardNumber", + "giftCards:balance.cardNumberPlaceholder", + "giftCards:balance.securityCode", + "giftCards:balance.securityCodePlaceholder", + "giftCards:balance.securityCodeHint", + "giftCards:balance.checkBalance", + "giftCards:balance.resultTitle", + "giftCards:balance.linkedToAccount", + "giftCards:balance.notLinkedToAccount", + "giftCards:balance.status", + "giftCards:balance.statusActive", + "giftCards:balance.statusUnavailable", + "giftCards:balance.expires", + "giftCards:balance.cardDetails", + "giftCards:balance.demoHint", + "giftCards:balance.addToWallet", + "giftCards:history.yourCards", + "giftCards:history.cardCount", + "giftCards:history.totalBalance", + "giftCards:history.yourGiftCards", + "giftCards:history.clickToView", + "giftCards:history.transactionHistory", + "giftCards:history.allTransactions", + "giftCards:history.noTransactions", + "giftCards:history.showing50", + "giftCards:history.cardDetails", + "giftCards:history.cardTransactions", + "giftCards:history.close", + "giftCards:history.columnCard", + "giftCards:history.columnBalance", + "giftCards:history.columnStatus", + "giftCards:consolidate.rulesTitle", + "giftCards:consolidate.ruleSameMerchant", + "giftCards:consolidate.ruleYourCards", + "giftCards:consolidate.ruleAllSameMerchant", + "giftCards:consolidate.ruleSecurityCode", + "giftCards:consolidate.ruleVoided", + "giftCards:consolidate.noCardsTitle", + "giftCards:consolidate.noCardsDesc", + "giftCards:consolidate.needMoreTitle", + "giftCards:consolidate.needMoreDesc", + "giftCards:consolidate.cardsFromMerchants", + "giftCards:consolidate.selectMerchant", + "giftCards:consolidate.chooseMerchant", + "giftCards:consolidate.eligibleCards_one", + "giftCards:consolidate.eligibleCards_other", + "giftCards:consolidate.need2Cards", + "giftCards:consolidate.selectCards", + "giftCards:consolidate.selectCardsSubtitle", + "giftCards:consolidate.expires", + "giftCards:consolidate.selected", + "giftCards:consolidate.errorMinCards", + "giftCards:consolidate.errorSameMerchant", + "giftCards:consolidate.errorSameCustomer", + "giftCards:consolidate.errorMaxValue", + "giftCards:consolidate.errorNoPermission", + "giftCards:consolidate.errorMaxCards", + "giftCards:consolidate.securityTitle", + "giftCards:consolidate.securityDesc", + "giftCards:consolidate.confirmTitle", + "giftCards:consolidate.cardsToMerge", + "giftCards:consolidate.totalValue", + "giftCards:consolidate.merchant", + "giftCards:consolidate.cardsToVoid", + "giftCards:consolidate.securityCode", + "giftCards:consolidate.securityCodePlaceholder", + "giftCards:consolidate.securityCodeHint", + "giftCards:consolidate.back", + "giftCards:consolidate.confirm", + "giftCards:consolidate.successTitle", + "giftCards:consolidate.successDesc", + "giftCards:consolidate.consolidateMore", + "giftCards:consolidate.cardsSelected_one", + "giftCards:consolidate.cardsSelected_other", + "giftCards:consolidate.continue", + "giftCards:consolidate.searchAddCards", + "giftCards:consolidate.searchDesc", + "giftCards:consolidate.searchPlaceholder", + "giftCards:consolidate.searchButton", + "giftCards:consolidate.selectedCards", + "giftCards:consolidate.cardAlreadyAdded", + "giftCards:consolidate.maxCardsReached", + "giftCards:consolidate.preview", + "giftCards:consolidate.cardsToMergeLabel", + "giftCards:consolidate.customerName", + "giftCards:consolidate.customerNamePlaceholder", + "giftCards:consolidate.customerEmail", + "giftCards:consolidate.customerEmailPlaceholder", + "giftCards:consolidate.consolidateButton", + "giftCards:consolidate.successCashier", + "giftCards:consolidate.newConsolidation", + "giftCards:consolidate.errorMin2Cards", + "giftCards:consolidate.errorMaxValueFixed", + "giftCards:consolidate.consolidated", + "giftCards:consolidate.consolidationFailed", + "giftCards:transfer.unavailableTitle", + "giftCards:transfer.unavailableDesc", + "giftCards:transfer.currentTrustLevel", + "giftCards:transfer.understandingTitle", + "giftCards:transfer.understandingDesc", + "giftCards:transfer.tip1", + "giftCards:transfer.tip2", + "giftCards:transfer.tip3", + "giftCards:transfer.tip4", + "giftCards:transfer.selectSource", + "giftCards:transfer.selectSourceDesc", + "giftCards:transfer.noActiveCards", + "giftCards:transfer.transferDetails", + "giftCards:transfer.transferringFrom", + "giftCards:transfer.securityCode", + "giftCards:transfer.securityCodePlaceholder", + "giftCards:transfer.securityCodeHint", + "giftCards:transfer.destinationCard", + "giftCards:transfer.destinationPlaceholder", + "giftCards:transfer.amount", + "giftCards:transfer.amountPlaceholder", + "giftCards:transfer.maximumAmount", + "giftCards:transfer.transferButton", + "giftCards:transfer.finalNote", + "giftCards:transfer.errorNoSource", + "giftCards:transfer.errorNoCode", + "giftCards:transfer.errorNoDestination", + "giftCards:transfer.errorInvalidAmount", + "giftCards:transfer.errorInsufficientBalance", + "giftCards:transfer.successTransfer", + "giftCards:transfer.ownershipTitle", + "giftCards:transfer.ownershipDesc", + "giftCards:transfer.searchCard", + "giftCards:transfer.searchPlaceholder", + "giftCards:transfer.noResults", + "giftCards:transfer.selectedCard", + "giftCards:transfer.change", + "giftCards:transfer.newOwnerDetails", + "giftCards:transfer.newOwnerName", + "giftCards:transfer.newOwnerNamePlaceholder", + "giftCards:transfer.newOwnerEmail", + "giftCards:transfer.newOwnerEmailPlaceholder", + "giftCards:transfer.transferOwnership", + "giftCards:transfer.ownershipNote", + "giftCards:transfer.errorNoCard", + "giftCards:transfer.errorMissingOwner", + "giftCards:transfer.ownershipSuccess", + "giftCards:transfer.transferFailed", + "giftCards:notifications.title", + "giftCards:notifications.newCount", + "giftCards:notifications.markAllRead", + "giftCards:notifications.cardCount", + "giftCards:notifications.expiringSoon", + "giftCards:notifications.noNotifications", + "giftCards:notifications.noNotificationsDesc", + "giftCards:notifications.urgent", + "giftCards:notifications.daysLeft", + "giftCards:notifications.actionNeeded", + "giftCards:notifications.recently", + "giftCards:notifications.tip", + "giftCards:notifications.justNow", + "giftCards:notifications.expiringTitle", + "giftCards:notifications.expiringDesc", + "giftCards:notifications.expiredTitle", + "giftCards:notifications.expiredDesc", + "giftCards:notifications.depletedTitle", + "giftCards:notifications.depletedDesc", + "giftCards:notifications.consolidateTitle", + "giftCards:notifications.consolidateDesc", + "giftCards:notifications.balanceUpdatedTitle", + "giftCards:notifications.balanceUpdatedDesc", + "giftCards:notifications.welcomeTitle", + "giftCards:notifications.welcomeDesc", + "giftCards:notifications.preferencesTitle", + "giftCards:notifications.preferencesDesc", + "giftCards:notifications.settings", + "giftCards:notifications.viewDetails", + "giftCards:settings.trustLevel", + "giftCards:settings.accountSettings", + "giftCards:settings.fullName", + "giftCards:settings.namePlaceholder", + "giftCards:settings.email", + "giftCards:settings.emailPlaceholder", + "giftCards:settings.verified", + "giftCards:settings.notVerified", + "giftCards:settings.phone", + "giftCards:settings.phonePlaceholder", + "giftCards:settings.saveChanges", + "giftCards:settings.notificationPreferences", + "giftCards:settings.balanceUpdates", + "giftCards:settings.balanceUpdatesDesc", + "giftCards:settings.expirationReminders", + "giftCards:settings.expirationRemindersDesc", + "giftCards:settings.transactionAlerts", + "giftCards:settings.transactionAlertsDesc", + "giftCards:settings.promotionalOffers", + "giftCards:settings.promotionalOffersDesc", + "giftCards:settings.security", + "giftCards:settings.otpAuth", + "giftCards:settings.verifiedVia", + "giftCards:settings.sessionInfo", + "giftCards:settings.sessionDesc", + "giftCards:settings.accountActions", + "giftCards:settings.logOut", + "giftCards:settings.support", + "giftCards:settings.successSave", + "giftCards:settings.errorSave", + "giftCards:settings.loggedOut", + "giftCards:cashierDashboard.quickActions", + "giftCards:cashierDashboard.sellGiftCard", + "giftCards:cashierDashboard.sellDesc", + "giftCards:cashierDashboard.redeemGiftCard", + "giftCards:cashierDashboard.redeemDesc", + "giftCards:cashierDashboard.checkBalance", + "giftCards:cashierDashboard.checkBalanceDesc", + "giftCards:cashierDashboard.reloadCard", + "giftCards:cashierDashboard.reloadDesc", + "giftCards:cashierDashboard.manageCards", + "giftCards:cashierDashboard.manageDesc", + "giftCards:cashierDashboard.reports", + "giftCards:cashierDashboard.reportsDesc", + "giftCards:sell.cardType", + "giftCards:sell.digital", + "giftCards:sell.physical", + "giftCards:sell.topUpSection", + "giftCards:sell.topUpLabel", + "giftCards:sell.topUpPlaceholder", + "giftCards:sell.scan", + "giftCards:sell.topUpHint", + "giftCards:sell.amount", + "giftCards:sell.usePresets", + "giftCards:sell.recipient", + "giftCards:sell.recipientDesc", + "giftCards:sell.recipientName", + "giftCards:sell.recipientNamePlaceholder", + "giftCards:sell.delivery", + "giftCards:sell.email", + "giftCards:sell.sms", + "giftCards:sell.recipientEmail", + "giftCards:sell.recipientEmailPlaceholder", + "giftCards:sell.recipientPhone", + "giftCards:sell.recipientPhonePlaceholder", + "giftCards:sell.message", + "giftCards:sell.messagePlaceholder", + "giftCards:sell.sender", + "giftCards:sell.senderName", + "giftCards:sell.senderNamePlaceholder", + "giftCards:sell.createButton", + "giftCards:sell.topUpButton", + "giftCards:sell.preview", + "giftCards:sell.previewStatus", + "giftCards:sell.previewStatusActive", + "giftCards:sell.previewDelivery", + "giftCards:sell.previewExpires", + "giftCards:sell.previewMessage", + "giftCards:sell.errorNoAmount", + "giftCards:sell.errorNoSender", + "giftCards:sell.successTopUp", + "giftCards:sell.successCreate", + "giftCards:sell.errorFailed", + "giftCards:sell.resendCodeButton", + "giftCards:sell.resendCodeTitle", + "giftCards:sell.resendCodeDesc", + "giftCards:sell.resendSuccess", + "giftCards:sell.successModalTitle", + "giftCards:sell.successModalDesc", + "giftCards:sell.notificationSentEmail", + "giftCards:sell.notificationSentSms", + "giftCards:sell.addToWallet", + "giftCards:sell.closeModal", + "giftCards:sell.topUpFind", + "giftCards:sell.topUpErrorEmpty", + "giftCards:sell.topUpNotFound", + "giftCards:sell.topUpInactiveCard", + "giftCards:sell.topUpSearchFailed", + "giftCards:sell.topUpFindFirst", + "giftCards:sell.topUpNewBalance", + "giftCards:sell.topUpChangeCard", + "giftCards:redeem.findCard", + "giftCards:redeem.findCardDesc", + "giftCards:redeem.searchPlaceholder", + "giftCards:redeem.search", + "giftCards:redeem.redeemAmount", + "giftCards:redeem.amountPlaceholder", + "giftCards:redeem.fullBalance", + "giftCards:redeem.currentBalance", + "giftCards:redeem.redeemAmountLabel", + "giftCards:redeem.remainingBalance", + "giftCards:redeem.confirm", + "giftCards:redeem.errorCardStatus", + "giftCards:redeem.errorNotFound", + "giftCards:redeem.errorSearch", + "giftCards:redeem.errorInvalidAmount", + "giftCards:redeem.success", + "giftCards:redeem.errorFailed", + "giftCards:redeem.scanQr", + "giftCards:manage.cardDetails", + "giftCards:manage.transactionHistory", + "giftCards:manage.close", + "giftCards:reports.cardsSold", + "giftCards:reports.activeCount", + "giftCards:reports.totalSales", + "giftCards:reports.outstandingBalance", + "giftCards:reports.recentSales", + "giftCards:reports.cardNumber", + "giftCards:reports.customer", + "giftCards:reports.amount", + "giftCards:reports.status", + "giftCards:reports.type", + "giftCards:reports.anonymous", + "giftCards:reports.physical", + "giftCards:reports.digital", + "giftCards:reports.totalCards", + "giftCards:reports.totalRedeemed", + "giftCards:reports.outstanding", + "giftCards:reports.cardsByStatus", + "giftCards:reports.transactionVolume", + "giftCards:reports.cardsSoldPerMonth", + "giftCards:cashierSettings.posConfig", + "giftCards:cashierSettings.merchantId", + "giftCards:cashierSettings.cashierId", + "giftCards:cashierSettings.programId", + "giftCards:cashierSettings.terminalId", + "giftCards:cashierSettings.terminalPlaceholder", + "giftCards:cashierSettings.apiConfig", + "giftCards:cashierSettings.apiMode", + "giftCards:cashierSettings.apiModeValue", + "giftCards:cashierSettings.apiModeHint", + "giftCards:cashierSettings.apiEndpoint", + "giftCards:cashierSettings.apiEndpointValue", + "giftCards:cashierSettings.receiptSettings", + "giftCards:cashierSettings.storeName", + "giftCards:cashierSettings.storeNameValue", + "giftCards:cashierSettings.storeAddress", + "giftCards:cashierSettings.storeAddressValue", + "giftCards:cashierSettings.saveSettings", + "giftCards:cashierSettings.programSettings", + "giftCards:cashierSettings.programSettingsHint", + "giftCards:cashierSettings.programIdPlaceholder", + "giftCards:cashierSettings.programIdHint", + "giftCards:cashierSettings.programIdRequired", + "giftCards:cashierSettings.saveProgramId", + "giftCards:cashierSettings.saving", + "giftCards:cashierSettings.saveSuccess", + "giftCards:cashierSettings.saveFailed", + "giftCards:cashierSettings.merchantIdRequired", + "giftCards:cashierSettings.merchantSettings", + "giftCards:merchantDashboard.totalActiveCards", + "giftCards:merchantDashboard.totalCardsLabel", + "giftCards:merchantDashboard.outstandingBalance", + "giftCards:merchantDashboard.outstandingDesc", + "giftCards:merchantDashboard.cardsSoldMonth", + "giftCards:merchantDashboard.cardsSoldValue", + "giftCards:merchantDashboard.recentGiftCards", + "giftCards:merchantDashboard.viewAll", + "giftCards:merchantCards.filterAll", + "giftCards:merchantCards.filterActive", + "giftCards:merchantCards.filterPending", + "giftCards:merchantCards.filterDepleted", + "giftCards:merchantCards.filterExpired", + "giftCards:merchantCards.filterVoided", + "giftCards:merchantCards.columnCard", + "giftCards:merchantCards.columnCustomer", + "giftCards:merchantCards.columnBalance", + "giftCards:merchantCards.columnInitial", + "giftCards:merchantCards.columnStatus", + "giftCards:merchantCards.columnCreated", + "giftCards:merchantCards.anonymous", + "giftCards:merchantCards.selectCard", + "giftCards:merchantCards.transactionHistory", + "giftCards:merchantConsolidate.title", + "giftCards:merchantConsolidate.description", + "giftCards:merchantConsolidate.noEligible", + "giftCards:merchantConsolidate.preview", + "giftCards:merchantConsolidate.cardsSelected_one", + "giftCards:merchantConsolidate.cardsSelected_other", + "giftCards:merchantConsolidate.totalValue", + "giftCards:merchantConsolidate.consolidateButton", + "giftCards:merchantConsolidate.successTitle", + "giftCards:merchantConsolidate.errorMin2", + "giftCards:merchantConsolidate.successMsg", + "giftCards:merchantConsolidate.failed", + "giftCards:merchantReports.totalCards", + "giftCards:merchantReports.totalSales", + "giftCards:merchantReports.totalRedeemed", + "giftCards:merchantReports.outstanding", + "giftCards:merchantReports.cardsByStatus", + "giftCards:merchantReports.active", + "giftCards:merchantReports.pending", + "giftCards:merchantReports.depleted", + "giftCards:merchantReports.expired", + "giftCards:merchantReports.voided", + "giftCards:merchantReports.ofActive", + "giftCards:merchantReports.transactionVolume", + "giftCards:merchantReports.cardsSoldPerMonth", + "giftCards:network.totalSales", + "giftCards:network.salesGrowth", + "giftCards:network.activeMerchants", + "giftCards:network.merchantGrowth", + "giftCards:network.giftCardsSold", + "giftCards:network.cardGrowth", + "giftCards:network.platformTransactions", + "giftCards:network.txGrowth", + "giftCards:network.adoptionTitle", + "giftCards:network.adoptionDesc", + "giftCards:network.healthTitle", + "giftCards:network.healthDesc", + "giftCards:network.txSuccessRate", + "giftCards:network.fraudAlerts", + "giftCards:network.supportTickets", + "giftCards:network.revenueTitle", + "giftCards:network.breakage", + "giftCards:network.breakageDesc", + "giftCards:network.txFeeRevenue", + "giftCards:network.txFeeDesc", + "giftCards:network.ltvUplift", + "giftCards:network.ltvDesc", + "giftCards:network.ltvValue", + "giftCards:network.ltvLabel", + "giftCards:network.standard", + "giftCards:network.giftCardUser", + "giftCards:status.active", + "giftCards:status.pending", + "giftCards:status.depleted", + "giftCards:status.expired", + "giftCards:status.voided", + "giftCards:status.suspended", + "notifications:pointsEarned.title", + "notifications:rewardUnlocked.title", + "notifications:rewardUnlocked.body", + "notifications:rewardExpiring.title", + "notifications:pointsExpiring.title", + "notifications:pointsExpiring.body", + "notifications:welcome.title", + "notifications:birthday.title", + "notifications:campaign.title", + "notifications:sms.welcome", + "notifications:sms.pointsEarned", + "notifications:sms.rewardReady", + "notifications:sms.rewardExpiring", + "notifications:sms.birthdayReward", + "notifications:sms.tierUpgrade", + "notifications:sms.pointsExpiring", + "notifications:sms.inactivityReminder", + "notifications:sms.promoAlert", + "notifications:sms.verificationCode", + "notifications:push.welcome.title", + "notifications:push.welcome.body", + "notifications:push.pointsEarned.title", + "notifications:push.pointsEarned.body", + "notifications:push.rewardReady.title", + "notifications:push.rewardReady.body", + "notifications:push.rewardExpiring.title", + "notifications:push.rewardExpiring.body", + "notifications:push.birthdayReward.title", + "notifications:push.birthdayReward.body", + "notifications:push.tierUpgrade.title", + "notifications:push.tierUpgrade.body", + "notifications:push.pointsExpiring.title", + "notifications:push.pointsExpiring.body", + "notifications:push.nearbyStore.title", + "notifications:push.nearbyStore.body", + "notifications:push.newReward.title", + "notifications:push.newReward.body", + "notifications:push.promoAlert.title", + "notifications:push.promoAlert.body", + "notifications:push.stampEarned.title", + "notifications:push.stampEarned.body", + "notifications:push.stampCardComplete.title", + "notifications:push.stampCardComplete.body", + "notifications:inApp.welcome", + "notifications:inApp.pointsEarned", + "notifications:inApp.rewardRedeemed", + "notifications:inApp.tierUpgrade", + "notifications:inApp.newRewardUnlocked", + "notifications:inApp.goalProgress", + "notifications:savedSuccessfully", + "notifications:customerDeleted", + "notifications:analyzing", + "notifications:hangTight", + "notifications:welcomeDashboard", + "notifications:rewardingExperience", + "notifications:beginJourney", + "notifications:simplicity", + "notifications:somethingWentWrong", + "notifications:loadingData", + "notifications:pleaseWait", + "notifications:submitOrSave", + "notifications:noCancel", + "notifications:yesConfirmDelete", + "notifications:warning", + "notifications:abort", + "notifications:askAboutLoyalty", + "notifications:retry", + "notifications:welcomeAdvisor", + "notifications:analyzedData", + "notifications:performanceAnalysis", + "notifications:campaignRecommendations", + "notifications:memberInsights", + "notifications:productAnalysis", + "notifications:wouldYouLike", + "notifications:totalTransactions", + "notifications:memberParticipation", + "notifications:totalRevenue", + "notifications:dateRangeTx", + "notifications:oopsError", + "notifications:updatedSuccessfully", + "notifications:messageHasBeenSent", + "notifications:loadingPleaseWait", + "notifications:giftCard.sms.cardSold", + "notifications:giftCard.sms.cardRedeemed", + "notifications:giftCard.sms.balanceCheck", + "notifications:giftCard.sms.cardExpiringSoon", + "notifications:giftCard.sms.cardActivated", + "notifications:giftCard.sms.cardTopUp", + "notifications:giftCard.sms.cardConsolidated", + "notifications:giftCard.push.cardSold.title", + "notifications:giftCard.push.cardSold.body", + "notifications:giftCard.push.cardRedeemed.title", + "notifications:giftCard.push.cardRedeemed.body", + "notifications:giftCard.push.balanceCheck.title", + "notifications:giftCard.push.balanceCheck.body", + "notifications:giftCard.push.cardExpiringSoon.title", + "notifications:giftCard.push.cardExpiringSoon.body", + "notifications:giftCard.push.cardActivated.title", + "notifications:giftCard.push.cardActivated.body", + "notifications:giftCard.push.cardTopUp.title", + "notifications:giftCard.push.cardTopUp.body", + "notifications:giftCard.push.cardConsolidated.title", + "notifications:giftCard.push.cardConsolidated.body", + "shopify:shopify.loyalty.joinButton", + "shopify:shopify.loyalty.viewRewardsButton", + "shopify:shopify.loyalty.pointsBalance", + "shopify:shopify.loyalty.pointsEarned", + "shopify:shopify.loyalty.rewardAvailable", + "shopify:shopify.loyalty.redeemButton", + "shopify:shopify.storefront.widgetTitle", + "shopify:shopify.storefront.signInPrompt", + "shopify:shopify.storefront.enrollPrompt", + "shopify:shopify.admin.title", + "shopify:shopify.admin.settingsTitle", + "shopify:shopify.admin.saveButton", + "shopify:shopify.admin.cancelButton", + "shopify:shopify.errors.loadFailed", + "shopify:shopify.errors.enrollFailed", + "shopify:shopify.errors.redeemFailed", + "shopify:shopify.widget.pointsBalance", + "shopify:shopify.widget.points", + "shopify:shopify.widget.availableOffers", + "shopify:shopify.widget.claim", + "shopify:shopify.widget.error", + "shopify:shopify.widget.confirmClaimNewOffer", + "shopify:shopify.widget.welcomeTo", + "validation:required", + "validation:minLength", + "validation:maxLength", + "validation:invalidEmail", + "validation:invalidPhone", + "validation:invalidUrl", + "validation:passwordMismatch", + "validation:invalidNumber", + "validation:minValue", + "validation:maxValue", + "validation:invalidDate", + "validation:futureDate", + "validation:pastDate", + "validation:fields.name", + "validation:fields.email", + "validation:fields.phone", + "validation:fields.address", + "validation:fields.city", + "validation:fields.state", + "validation:fields.zip", + "validation:fields.country", + "validation:fields.password", + "validation:fields.confirmPassword", + "validation:fields.points", + "validation:fields.rewardName", + "validation:fields.description", + "validation:fields.message", + "validation:required_field", + "validation:required_email", + "validation:required_password", + "validation:required_name", + "validation:required_phone", + "validation:required_amount", + "validation:required_date", + "validation:required_selection", + "validation:format.email", + "validation:format.phone", + "validation:format.url", + "validation:format.date", + "validation:format.time", + "validation:format.number", + "validation:format.integer", + "validation:format.decimal", + "validation:format.postalCode", + "validation:format.creditCard", + "validation:format.cvv", + "validation:format.expiryDate", + "validation:length.min", + "validation:length.max", + "validation:length.exact", + "validation:length.between", + "validation:range.min", + "validation:range.max", + "validation:range.between", + "validation:password.tooShort", + "validation:password.tooWeak", + "validation:password.requiresUppercase", + "validation:password.requiresLowercase", + "validation:password.requiresNumber", + "validation:password.requiresSpecial", + "validation:password.noMatch", + "validation:password.sameAsCurrent", + "validation:file.required", + "validation:file.tooLarge", + "validation:file.invalidType", + "validation:file.tooMany", + "validation:date.invalid", + "validation:date.past", + "validation:date.future", + "validation:date.before", + "validation:date.after", + "validation:date.range", + "validation:custom.unique", + "validation:custom.exists", + "validation:custom.mismatch", + "validation:custom.invalid", + "validation:custom.unavailable", + "validation:points.insufficient", + "validation:points.minimum", + "validation:points.maximum", + "validation:member.emailTaken", + "validation:member.phoneTaken", + "validation:member.invalidReferralCode", + "validation:common.required", + "validation:common.empty", + "validation:common.invalid_format", + "validation:common.too_long", + "validation:common.too_short", + "validation:common.invalid_url", + "validation:common.invalid_phone", + "validation:account.email_required", + "validation:account.email_invalid", + "validation:account.password_required", + "validation:account.password_too_short", + "validation:account.password_empty", + "validation:account.name_required", + "validation:account.domain_required", + "validation:account.domain_invalid", + "validation:account.guid_required", + "validation:account.guid_invalid", + "validation:program.program_id_required", + "validation:program.program_not_found", + "validation:program.program_inactive", + "validation:customer.customer_id_required", + "validation:customer.customer_not_found", + "validation:customer.customer_email_required", + "validation:offer.offer_id_required", + "validation:offer.offer_not_found", + "validation:offer.offer_not_active", + "validation:offer.missing_offer_or_reward", + "validation:webhook.missing_signature", + "validation:webhook.invalid_signature", + "validation:webhook.shopify_webhook_invalid", + "validation:webhook.wordpress_webhook_invalid", + "validation:webhook.body_too_large", + "validation:integration.shopify_domain_required", + "validation:integration.shopify_domain_invalid", + "validation:integration.shopify_hmac_invalid", + "validation:integration.clover_market_code_required", + "validation:integration.wordpress_site_url_required", + "validation:integration.wordpress_site_url_invalid", + "validation:integration.eposnow_shop_id_required", + "validation:security.invalid_session", + "validation:security.session_expired", + "validation:security.invalid_password", + "validation:security.rate_limit_exceeded", + "validation:security.forbidden", + "validation:file_upload.file_too_large", + "validation:file_upload.invalid_file_type", + "validation:file_upload.file_required", + "validation:api.invalid_version", + "validation:api.endpoint_not_found", + "validation:api.method_not_allowed", + "validation:api.unsupported_media_type", + "validation:api.request_timeout", + "validation:api.too_many_requests", + "validation:email", + "validation:phone", + "validation:min_length", + "validation:shortPromotional", + "validation:legalNameBusiness", + "wordpress:wordpress.admin.dashboard.title", + "wordpress:wordpress.admin.dashboard.account", + "wordpress:wordpress.admin.dashboard.notConnected", + "wordpress:wordpress.admin.dashboard.program", + "wordpress:wordpress.admin.dashboard.noProgramSelected", + "wordpress:wordpress.admin.dashboard.openDashboard", + "wordpress:wordpress.admin.dashboard.connectStore", + "wordpress:wordpress.admin.dashboard.atAGlanceStatusOfYour", + "wordpress:wordpress.admin.dashboard.programSummaryMetrics", + "wordpress:wordpress.admin.dashboard.connectionStatus", + "wordpress:wordpress.admin.dashboard.connection", + "wordpress:wordpress.admin.dashboard.programWp", + "wordpress:wordpress.admin.dashboard.pointsIssuedInTheLast30", + "wordpress:wordpress.admin.dashboard.pointsIssued30d", + "wordpress:wordpress.admin.dashboard.valueNotYetAvailable", + "wordpress:wordpress.admin.dashboard.viewInPortal", + "wordpress:wordpress.admin.dashboard.totalMembersEnrolled", + "wordpress:wordpress.admin.dashboard.members", + "wordpress:wordpress.admin.dashboard.latestActivity", + "wordpress:wordpress.admin.dashboard.latestTransaction", + "wordpress:wordpress.admin.dashboard.noActivityYet", + "wordpress:wordpress.admin.connection.title", + "wordpress:wordpress.admin.connection.apiKey", + "wordpress:wordpress.admin.connection.configured", + "wordpress:wordpress.admin.connection.notConfigured", + "wordpress:wordpress.admin.connection.connectionStatus", + "wordpress:wordpress.admin.connection.connected", + "wordpress:wordpress.admin.connection.connectionError", + "wordpress:wordpress.admin.connection.unknown", + "wordpress:wordpress.admin.connection.lastChecked", + "wordpress:wordpress.admin.connection.registrationId", + "wordpress:wordpress.admin.connection.testConnection", + "wordpress:wordpress.admin.connection.activationPrompt", + "wordpress:wordpress.admin.connection.apiKeyNotConfigured", + "wordpress:wordpress.admin.connection.httpError", + "wordpress:wordpress.admin.connection.connectionSuccessful", + "wordpress:wordpress.admin.connection.unauthorized", + "wordpress:wordpress.admin.connection.youDoNotHavePermissionTo", + "wordpress:wordpress.admin.connection.programId", + "wordpress:wordpress.admin.connection.php1sOrNewerIsRequired", + "wordpress:wordpress.admin.connection.wordpress1sOrNewerIsRequired", + "wordpress:wordpress.admin.connection.woocommerceIsRequiredButIsNot", + "wordpress:wordpress.admin.connection.woocommerce1sOrNewerIsRequired", + "wordpress:wordpress.admin.connection.httpFromApi", + "wordpress:wordpress.admin.widget.title", + "wordpress:wordpress.admin.widget.shortcode", + "wordpress:wordpress.admin.widget.shortcodeDescription", + "wordpress:wordpress.admin.widget.copy", + "wordpress:wordpress.admin.widget.copied", + "wordpress:wordpress.admin.widget.gutenbergNote", + "wordpress:wordpress.admin.widget.wcTab", + "wordpress:wordpress.admin.widget.enableWcTab", + "wordpress:wordpress.admin.widget.save", + "wordpress:wordpress.admin.widget.settingsSaved", + "wordpress:wordpress.admin.widget.preview", + "wordpress:wordpress.admin.widget.previewPlaceholder", + "wordpress:wordpress.admin.widget.couldNotSaveSettingsToLoyaltydog", + "wordpress:wordpress.admin.widget.joinWidget", + "wordpress:wordpress.admin.widget.shownToGuestVisitorsAndCustomers", + "wordpress:wordpress.admin.widget.signupUrl", + "wordpress:wordpress.admin.widget.theShareJoinUrlFromYour", + "wordpress:wordpress.admin.widget.qrCode", + "wordpress:wordpress.admin.widget.showQrCodeAlongsideJoinText", + "wordpress:wordpress.admin.widget.customersCanScanWithTheirPhone", + "wordpress:wordpress.admin.widget.checkoutRedemption", + "wordpress:wordpress.admin.widget.allowCustomersToAutomaticallyRedeemLoyalty", + "wordpress:wordpress.admin.widget.enableRedemption", + "wordpress:wordpress.admin.widget.applyLoyaltyPointsAsACart", + "wordpress:wordpress.admin.widget.whenEnabledAllAvailablePointsAre", + "wordpress:wordpress.admin.widget.pointsValue", + "wordpress:wordpress.admin.widget.currencyPerPoint", + "wordpress:wordpress.admin.widget.eG001Means100", + "wordpress:wordpress.admin.widget.emailNotifications", + "wordpress:wordpress.admin.widget.controlWhichTransactionalEmailsCustomersReceive", + "wordpress:wordpress.admin.widget.tierChangeEmails", + "wordpress:wordpress.admin.widget.sendANotificationWhenACustomer", + "wordpress:wordpress.admin.widget.customersCanAlsoOptOutIndividually", + "wordpress:wordpress.admin.systemInfo.title", + "wordpress:wordpress.admin.systemInfo.component", + "wordpress:wordpress.admin.systemInfo.value", + "wordpress:wordpress.admin.systemInfo.phpVersion", + "wordpress:wordpress.admin.systemInfo.wordpressVersion", + "wordpress:wordpress.admin.systemInfo.woocommerceVersion", + "wordpress:wordpress.admin.systemInfo.pluginVersion", + "wordpress:wordpress.admin.systemInfo.pendingActions", + "wordpress:wordpress.admin.systemInfo.notActive", + "wordpress:wordpress.admin.systemInfo.systemInformation", + "wordpress:wordpress.admin.systemInfo.compatibilityStatus", + "wordpress:wordpress.admin.systemInfo.yourPlatformMeetsAllRequirements", + "wordpress:wordpress.admin.systemInfo.somePlatformRequirementsAreNotMet", + "wordpress:wordpress.admin.systemInfo.wordpress", + "wordpress:wordpress.admin.systemInfo.version", + "wordpress:wordpress.admin.systemInfo.language", + "wordpress:wordpress.admin.systemInfo.timezone", + "wordpress:wordpress.admin.systemInfo.multisite", + "wordpress:wordpress.admin.systemInfo.yes", + "wordpress:wordpress.admin.systemInfo.no", + "wordpress:wordpress.admin.systemInfo.woocommerce", + "wordpress:wordpress.admin.systemInfo.status", + "wordpress:wordpress.admin.systemInfo.active", + "wordpress:wordpress.admin.systemInfo.currency", + "wordpress:wordpress.admin.systemInfo.restApi", + "wordpress:wordpress.admin.systemInfo.enabled", + "wordpress:wordpress.admin.systemInfo.disabled", + "wordpress:wordpress.admin.systemInfo.php", + "wordpress:wordpress.admin.systemInfo.sapi", + "wordpress:wordpress.admin.systemInfo.maxInputVars", + "wordpress:wordpress.admin.systemInfo.postMaxSize", + "wordpress:wordpress.admin.systemInfo.uploadMaxSize", + "wordpress:wordpress.admin.systemInfo.phpExtensions", + "wordpress:wordpress.admin.systemInfo.required", + "wordpress:wordpress.admin.systemInfo.loaded", + "wordpress:wordpress.admin.systemInfo.missing", + "wordpress:wordpress.admin.systemInfo.optional", + "wordpress:wordpress.admin.systemInfo.serverResources", + "wordpress:wordpress.admin.systemInfo.memoryLimit", + "wordpress:wordpress.admin.systemInfo.currentMemoryUsage", + "wordpress:wordpress.admin.systemInfo.maxExecutionTime", + "wordpress:wordpress.admin.systemInfo.serverSoftware", + "wordpress:wordpress.admin.systemInfo.wpDebug", + "wordpress:wordpress.admin.credentials.securityCheckFailed", + "wordpress:wordpress.admin.credentials.invalidCredentialKey", + "wordpress:wordpress.admin.credentials.credentialValueIsRequired", + "wordpress:wordpress.admin.credentials.invalidCredentialFormat", + "wordpress:wordpress.admin.credentials.failedToStoreCredentialPleaseTry", + "wordpress:wordpress.admin.credentials.credentialUpdatedSuccessfully", + "wordpress:wordpress.admin.credentials.failedToDeleteCredentialPleaseTry", + "wordpress:wordpress.admin.credentials.credentialDeletedSuccessfully", + "wordpress:wordpress.admin.credentials.noCredentialsToExport", + "wordpress:wordpress.admin.credentials.noFileProvided", + "wordpress:wordpress.admin.credentials.fileUploadFailed", + "wordpress:wordpress.admin.credentials.invalidFileTypePleaseUploadA", + "wordpress:wordpress.admin.credentials.invalidImportFileFormat", + "wordpress:wordpress.admin.credentials.noCredentialsWereImported", + "wordpress:wordpress.admin.credentials.credentialsImportedSuccessfully", + "wordpress:wordpress.admin.settings.loyaltydogCredentials", + "wordpress:wordpress.admin.settings.manageYourApiKeysAndIntegration", + "wordpress:wordpress.admin.settings.status", + "wordpress:wordpress.admin.settings.stripe", + "wordpress:wordpress.admin.settings.coreApi", + "wordpress:wordpress.admin.settings.auditLog", + "wordpress:wordpress.admin.settings.backup", + "wordpress:wordpress.admin.settings.stripeSecretKey", + "wordpress:wordpress.admin.settings.stripeWebhookSecret", + "wordpress:wordpress.admin.settings.stripePublicKey", + "wordpress:wordpress.admin.settings.stripePriceId", + "wordpress:wordpress.admin.settings.credentialStatus", + "wordpress:wordpress.admin.settings.credential", + "wordpress:wordpress.admin.settings.type", + "wordpress:wordpress.admin.settings.lastUpdated", + "wordpress:wordpress.admin.settings.actions", + "wordpress:wordpress.admin.settings.notConfigured", + "wordpress:wordpress.admin.settings.encrypted", + "wordpress:wordpress.admin.settings.plainText", + "wordpress:wordpress.admin.settings.edit", + "wordpress:wordpress.admin.settings.delete", + "wordpress:wordpress.admin.settings.configure", + "wordpress:wordpress.admin.settings.stripeConfiguration", + "wordpress:wordpress.admin.settings.secretKey", + "wordpress:wordpress.admin.settings.findInStripeDashboardDevelopers", + "wordpress:wordpress.admin.settings.configured", + "wordpress:wordpress.admin.settings.update", + "wordpress:wordpress.admin.settings.clear", + "wordpress:wordpress.admin.settings.publishableKey", + "wordpress:wordpress.admin.settings.webhookSigningSecret", + "wordpress:wordpress.admin.settings.findInStripeDashboardDevelopers2", + "wordpress:wordpress.admin.settings.monthlyPriceId", + "wordpress:wordpress.admin.settings.stripePriceIdFor29Month", + "wordpress:wordpress.admin.settings.loyaltydogApiCredentials", + "wordpress:wordpress.admin.settings.oauthStyleCredentialsGeneratedDuringAccount", + "wordpress:wordpress.admin.settings.notConnected", + "wordpress:wordpress.admin.settings.clickTheButtonBelowToConnect", + "wordpress:wordpress.admin.settings.connectToLoyaltydog", + "wordpress:wordpress.admin.settings.registrationStatus", + "wordpress:wordpress.admin.settings.apiKeyIsSecurelyEncryptedIn", + "wordpress:wordpress.admin.settings.webhookSecret", + "wordpress:wordpress.admin.settings.webhookSecretIsSecurelyEncryptedIn", + "wordpress:wordpress.admin.settings.rotateWebhookSecret", + "wordpress:wordpress.admin.settings.generateANewWebhookSecretFor", + "wordpress:wordpress.admin.settings.howToReconfigure", + "wordpress:wordpress.admin.settings.toResetOrChangeYourLoyaltydog", + "wordpress:wordpress.admin.settings.detailedAuditLogOfAllCredential", + "wordpress:wordpress.admin.settings.checkYourWordpressDebugLogFile", + "wordpress:wordpress.admin.settings.recentOperations", + "wordpress:wordpress.admin.settings.allCredentialUpdatesAreLoggedAutomatically", + "wordpress:wordpress.admin.settings.allCredentialDeletionsAreLoggedAutomatically", + "wordpress:wordpress.admin.settings.allCredentialAccessIsLoggedRead", + "wordpress:wordpress.admin.settings.logsIncludeUserTimestampAndAction", + "wordpress:wordpress.admin.settings.credentialValuesAreNeverLoggedSanitized", + "wordpress:wordpress.admin.settings.backupRestore", + "wordpress:wordpress.admin.settings.exportCredentials", + "wordpress:wordpress.admin.settings.exportYourEncryptedCredentialsAsA", + "wordpress:wordpress.admin.settings.exportCredentialsEncrypted", + "wordpress:wordpress.admin.settings.importCredentials", + "wordpress:wordpress.admin.settings.restoreCredentialsFromAPreviouslyExported", + "wordpress:wordpress.admin.settings.warning", + "wordpress:wordpress.admin.settings.importedCredentialsWillOverwriteExistingCredentials", + "wordpress:wordpress.admin.encryptionKeyNotice.yourAes256EncryptionKeyCurrently", + "wordpress:wordpress.admin.encryptionKeyNotice.thisNoticeDisappearsOnceTheConstant", + "wordpress:wordpress.admin.notices.loyaltydogV2MigrationCompletedAutomatically", + "wordpress:wordpress.admin.notices.yourLoyaltyDataWasMigratedWithout", + "wordpress:wordpress.admin.notices.contactSupportLoyaltyDog", + "wordpress:wordpress.admin.notices.loyaltydogUpgradedToV203", + "wordpress:wordpress.admin.notices.yourExistingProgramSettingsAndMember", + "wordpress:wordpress.admin.notices.reviewConnection", + "wordpress:wordpress.admin.notices.dismiss", + "wordpress:wordpress.admin.notices.loyaltydogV1V2MigrationFailed", + "wordpress:wordpress.admin.notices.error", + "wordpress:wordpress.admin.notices.recoveryGuide", + "wordpress:wordpress.admin.notices.thePluginWillRetryAutomaticallyOn", + "wordpress:wordpress.admin.subscriptionLapse.yourLoyaltydogWordpressAddOnPayment", + "wordpress:wordpress.admin.subscriptionLapse.loyaltyOperationsContinueForNowStripe", + "wordpress:wordpress.admin.subscriptionLapse.updatePaymentMethod", + "wordpress:wordpress.admin.subscriptionLapse.remindMeTomorrow", + "wordpress:wordpress.admin.subscriptionLapse.yourLoyaltydogWordpressAddOnSubscription", + "wordpress:wordpress.admin.subscriptionLapse.loyaltyOperationsArePausedExistingCustomer", + "wordpress:wordpress.admin.subscriptionLapse.resubscribe", + "wordpress:wordpress.admin.trialNotices.trialExpired", + "wordpress:wordpress.admin.trialNotices.welcomeToLoyaltydog", + "wordpress:wordpress.admin.trialNotices.signUpForAFree30", + "wordpress:wordpress.admin.trialNotices.startYourFreeTrial", + "wordpress:wordpress.admin.trialNotices.upgradeNowToKeepAwardingPoints", + "wordpress:wordpress.admin.trialNotices.upgradeNow", + "wordpress:wordpress.admin.trialNotices.remindMeLater", + "wordpress:wordpress.admin.trialNotices.yourLoyaltydogTrialHasEnded", + "wordpress:wordpress.admin.trialNotices.yourStorefrontContinuesToWorkBut", + "wordpress:wordpress.admin.trialNotices.trialDayRemaining_one", + "wordpress:wordpress.admin.trialNotices.trialDayRemaining_other", + "wordpress:wordpress.admin.trialNotices.yourLoyaltydogTrialEndsInDay_one", + "wordpress:wordpress.admin.trialNotices.yourLoyaltydogTrialEndsInDay_other", + "wordpress:wordpress.admin.activation.securityCheckFailed", + "wordpress:wordpress.admin.activation.insufficientPermissions", + "wordpress:wordpress.admin.activation.invalidActivationKey", + "wordpress:wordpress.admin.activation.unableToReachLoyaltydogRightNow", + "wordpress:wordpress.admin.activation.thisActivationLinkHasExpiredPlease", + "wordpress:wordpress.admin.activation.tooManyAttemptsPleaseWaitA", + "wordpress:wordpress.admin.activation.somethingWentWrongDuringActivationPlease", + "wordpress:wordpress.admin.activation.unableToStoreCredentialsSecurelyPlease", + "wordpress:wordpress.admin.activation.accountActivatedSuccessfully", + "wordpress:wordpress.admin.testConnection.invalidRequest", + "wordpress:wordpress.admin.testConnection.connectionTestFailed", + "wordpress:wordpress.admin.testConnection.yourWordpressPluginIsSuccessfullyConnected", + "wordpress:wordpress.admin.help.gettingStarted", + "wordpress:wordpress.admin.help.settings", + "wordpress:wordpress.admin.help.pointsRules", + "wordpress:wordpress.admin.help.shortcodes", + "wordpress:wordpress.admin.help.gettingStartedWithLoyaltydog", + "wordpress:wordpress.admin.help.loyaltydogAddsADigitalLoyaltyProgram", + "wordpress:wordpress.admin.help.enterYour1sprogramId2sAnd1sapi", + "wordpress:wordpress.admin.help.configureThePointsConversionRateE", + "wordpress:wordpress.admin.help.setUpOfferMessagesThatCustomers", + "wordpress:wordpress.admin.help.optionallyConfigureCategoryMultipliersCampaignsAnd", + "wordpress:wordpress.admin.help.customersWillSeeTheirPointsBalance", + "wordpress:wordpress.admin.help.settingsReference", + "wordpress:wordpress.admin.help.yourUniqueLoyaltydogProgramIdentifierFound", + "wordpress:wordpress.admin.help.secretKeyForApiAuthenticationNever", + "wordpress:wordpress.admin.help.earnPointsWhen", + "wordpress:wordpress.admin.help.controlsWhenPointsAreAwardedProcessing", + "wordpress:wordpress.admin.help.pointsConversionRate", + "wordpress:wordpress.admin.help.howSpendingConvertsToPoints1", + "wordpress:wordpress.admin.help.pointsRulesEngine", + "wordpress:wordpress.admin.help.categoryMultipliers", + "wordpress:wordpress.admin.help.awardBonusPointsForSpecificProduct", + "wordpress:wordpress.admin.help.thisGives2xPointsForElectronics", + "wordpress:wordpress.admin.help.minimumSpend", + "wordpress:wordpress.admin.help.setAMinimumOrderTotalRequired", + "wordpress:wordpress.admin.help.timeLimitedCampaigns", + "wordpress:wordpress.admin.help.runPromotionalPeriodsWithBonusPoint", + "wordpress:wordpress.admin.help.formatMultiplierstartdateenddatelabelLinesStartingWith", + "wordpress:wordpress.admin.help.productLevelOverrides", + "wordpress:wordpress.admin.help.setAFixedPointValueFor", + "wordpress:wordpress.admin.help.availableShortcodes", + "wordpress:wordpress.admin.help.displayTheLoggedInCustomerS", + "wordpress:wordpress.admin.help.displayTheCustomerSReferralLink", + "wordpress:wordpress.admin.help.resources", + "wordpress:wordpress.admin.help.supportCenter", + "wordpress:wordpress.admin.help.configureLoyaltydog", + "wordpress:wordpress.admin.help.setUpYourLoyaltyProgramBy", + "wordpress:wordpress.admin.help.hooksReference", + "wordpress:wordpress.admin.menu.manage", + "wordpress:wordpress.admin.menu.strongLoyaltydogStrongIsAlmostReady", + "wordpress:wordpress.admin.menu.strongLoyaltydogStrongRequiresCouponsTo", + "wordpress:wordpress.admin.menu.sendPushNotifications", + "wordpress:wordpress.admin.menu.message", + "wordpress:wordpress.admin.menu.pushNotificationsAreCompliantWithEu", + "wordpress:wordpress.admin.menu.send", + "wordpress:wordpress.admin.menu.enableTheLoggingOfApiRequest", + "wordpress:wordpress.admin.menu.1sviewLog2s", + "wordpress:wordpress.admin.menu.loyaltydogApi", + "wordpress:wordpress.admin.menu.enterWithYourProgramId", + "wordpress:wordpress.admin.menu.enterWithYourLoyaltydogApiKey", + "wordpress:wordpress.admin.menu.debugLog", + "wordpress:wordpress.admin.menu.thisShouldBeCheckedOnlyIf", + "wordpress:wordpress.admin.menu.pointsConversion", + "wordpress:wordpress.admin.menu.earnPointsWhenOrderStatusIs", + "wordpress:wordpress.admin.menu.behaviorHowPointsAreEarnedAnd", + "wordpress:wordpress.admin.menu.pendingPayment", + "wordpress:wordpress.admin.menu.processing", + "wordpress:wordpress.admin.menu.onHold", + "wordpress:wordpress.admin.menu.completed", + "wordpress:wordpress.admin.menu.earnPointsConversionRate", + "wordpress:wordpress.admin.menu.setTheNumberOfPointsAwarded", + "wordpress:wordpress.admin.menu.earnPointsRoundingMode", + "wordpress:wordpress.admin.menu.setHowPointsShouldBeRounded", + "wordpress:wordpress.admin.menu.roundToNearestInteger", + "wordpress:wordpress.admin.menu.alwaysRoundDown", + "wordpress:wordpress.admin.menu.alwaysRoundUp", + "wordpress:wordpress.admin.menu.offerMessages", + "wordpress:wordpress.admin.menu.adjustTheMessageByUsing1s", + "wordpress:wordpress.admin.menu.availableOfferMessage", + "wordpress:wordpress.admin.menu.addAnOptionalMessageWhenUser", + "wordpress:wordpress.admin.menu.strongActiveLoyaltyOfferStrongFriendlynametobeadded", + "wordpress:wordpress.admin.menu.redeemOfferMessage", + "wordpress:wordpress.admin.menu.displayedOnTheCartAndCheckout", + "wordpress:wordpress.admin.menu.strongLoyaltyRewardStrongFriendlynametobeadded", + "wordpress:wordpress.admin.menu.pointsEarnedForActions", + "wordpress:wordpress.admin.menu.customersCanAlsoEarnPointsFor", + "wordpress:wordpress.admin.menu.isRequired", + "wordpress:wordpress.admin.menu.points", + "wordpress:wordpress.admin.menu.isInvalid", + "wordpress:wordpress.admin.menu.messageCanNotBeBlank", + "wordpress:wordpress.admin.menu.messageHaveBeenSent", + "wordpress:wordpress.admin.wizard.loyaltydogSetup", + "wordpress:wordpress.admin.wizard.loyaltyRewardsForYourWoocommerceStore", + "wordpress:wordpress.admin.wizard.setupSteps", + "wordpress:wordpress.admin.wizard.connect", + "wordpress:wordpress.admin.wizard.ready", + "wordpress:wordpress.admin.wizard.connectYourAccount", + "wordpress:wordpress.admin.wizard.accountType", + "wordpress:wordpress.admin.wizard.iHaveALoyaltydogAccount", + "wordpress:wordpress.admin.wizard.connectAnExistingProgramIncludingPos", + "wordpress:wordpress.admin.wizard.iMNewCreateAn", + "wordpress:wordpress.admin.wizard.signUpAndStartANew", + "wordpress:wordpress.admin.wizard.connectionMethod", + "wordpress:wordpress.admin.wizard.iReceivedAnActivationKeyBy", + "wordpress:wordpress.admin.wizard.pasteTheOneTimeKeyFrom", + "wordpress:wordpress.admin.wizard.iHaveAProgramIdAnd", + "wordpress:wordpress.admin.wizard.forLegacyV1Customers", + "wordpress:wordpress.admin.wizard.activationKey", + "wordpress:wordpress.admin.wizard.pasteTheKeyFromYourEmail", + "wordpress:wordpress.admin.wizard.sentToYourInboxAfterSubscribing", + "wordpress:wordpress.admin.wizard.activate", + "wordpress:wordpress.admin.wizard.enterYourProgramIdAndApi", + "wordpress:wordpress.admin.wizard.eG507f1f77bcf86cd799439011", + "wordpress:wordpress.admin.wizard.foundInYourLoyaltydogDashboardUnder", + "wordpress:wordpress.admin.wizard.yourApiKey", + "wordpress:wordpress.admin.wizard.keepThisSecretNeverShareYour", + "wordpress:wordpress.admin.wizard.validateampContinue", + "wordpress:wordpress.admin.wizard.createANewLoyaltydogAccountYour", + "wordpress:wordpress.admin.wizard.createAccountAtDashLoyaltyDog", + "wordpress:wordpress.admin.wizard.afterCreatingYourAccountReturnHere", + "wordpress:wordpress.admin.wizard.configurePoints", + "wordpress:wordpress.admin.wizard.chooseAPresetOrSetUp", + "wordpress:wordpress.admin.wizard.pointsPreset", + "wordpress:wordpress.admin.wizard.simple", + "wordpress:wordpress.admin.wizard.easyToUnderstandForCustomers", + "wordpress:wordpress.admin.wizard.recommended", + "wordpress:wordpress.admin.wizard.generous", + "wordpress:wordpress.admin.wizard.greatForBuildingEngagementFast", + "wordpress:wordpress.admin.wizard.custom", + "wordpress:wordpress.admin.wizard.setYourOwnRate", + "wordpress:wordpress.admin.wizard.fullControlOverEarningRules", + "wordpress:wordpress.admin.wizard.earnRate", + "wordpress:wordpress.admin.wizard.pointsForEvery", + "wordpress:wordpress.admin.wizard.currencyAmount", + "wordpress:wordpress.admin.wizard.spent", + "wordpress:wordpress.admin.wizard.bonusPointsForSignup", + "wordpress:wordpress.admin.wizard.pointsAwardedWhenANewCustomer", + "wordpress:wordpress.admin.wizard.earnPointsWhenOrderStatusIs", + "wordpress:wordpress.admin.wizard.processing", + "wordpress:wordpress.admin.wizard.completed", + "wordpress:wordpress.admin.wizard.onHold", + "wordpress:wordpress.admin.wizard.back", + "wordpress:wordpress.admin.wizard.saveampFinish", + "wordpress:wordpress.admin.wizard.youReAllSet", + "wordpress:wordpress.admin.wizard.yourLoyaltyProgramIsNowActive", + "wordpress:wordpress.admin.wizard.checkingConnection", + "wordpress:wordpress.admin.wizard.whatSNext", + "wordpress:wordpress.admin.wizard.customizeYourPass", + "wordpress:wordpress.admin.wizard.designYourDigitalLoyaltyPassIn", + "wordpress:wordpress.admin.wizard.readTheDocs", + "wordpress:wordpress.admin.wizard.learnHowToConfigureRulesCampaigns", + "wordpress:wordpress.admin.wizard.testItYourself", + "wordpress:wordpress.admin.wizard.viewTheCustomerFacingLoyaltyPage", + "wordpress:wordpress.admin.wizard.goToSettings", + "wordpress:wordpress.admin.wizard.goToDashboard", + "wordpress:wordpress.admin.wizard.permissionDenied", + "wordpress:wordpress.admin.wizard.bothProgramIdAndApiKey", + "wordpress:wordpress.admin.wizard.invalidApiKeyPleaseCheckYour", + "wordpress:wordpress.admin.wizard.programNotFoundPleaseCheckYour", + "wordpress:wordpress.admin.wizard.connectionFailedHttpPleaseTryAgain", + "wordpress:wordpress.admin.wizard.connectionSuccessful", + "wordpress:wordpress.admin.wizard.credentialsAreRequired", + "wordpress:wordpress.admin.wizard.setupComplete", + "wordpress:wordpress.admin.wizard.apiUnreachableHttp", + "wordpress:wordpress.admin.wizard.activationKeyLooksMalformedPasteThe", + "wordpress:wordpress.admin.wizard.activating", + "wordpress:wordpress.admin.wizard.connectedContinuingToConfiguration", + "wordpress:wordpress.admin.wizard.activationFailedPleaseTryAgain", + "wordpress:wordpress.admin.wizard.networkErrorPleaseTryAgain", + "wordpress:wordpress.admin.wizard.pleaseEnterBothProgramIdAnd", + "wordpress:wordpress.admin.wizard.validating", + "wordpress:wordpress.admin.wizard.validateContinue", + "wordpress:wordpress.admin.wizard.program", + "wordpress:wordpress.admin.wizard.saving", + "wordpress:wordpress.admin.wizard.saveFinish", + "wordpress:wordpress.admin.wizard.connectionIssue", + "wordpress:wordpress.admin.program.loyaltyPrograms", + "wordpress:wordpress.admin.program.createNewProgram", + "wordpress:wordpress.admin.program.programName", + "wordpress:wordpress.admin.program.thisNameWillAppearToYour", + "wordpress:wordpress.admin.program.createProgram", + "wordpress:wordpress.admin.program.cancel", + "wordpress:wordpress.admin.program.failedToLoadPrograms", + "wordpress:wordpress.admin.program.failedToLoadProgramsPleaseTry", + "wordpress:wordpress.admin.program.noProgramsYet", + "wordpress:wordpress.admin.program.createYourFirstProgram", + "wordpress:wordpress.admin.program.created", + "wordpress:wordpress.admin.program.failedToCreateProgramPleaseTry", + "wordpress:wordpress.admin.diagnostics.pluginWonTActivate", + "wordpress:wordpress.admin.diagnostics.verifyMysqlVersionRequires57", + "wordpress:wordpress.admin.diagnostics.checkErrorLogsForSpecificErrors", + "wordpress:wordpress.admin.diagnostics.disableOtherPluginsAndTryAgain", + "wordpress:wordpress.admin.diagnostics.productsNotSyncing", + "wordpress:wordpress.admin.diagnostics.verifyWoocommerceIsInstalled", + "wordpress:wordpress.admin.diagnostics.checkSyncLogsForErrors", + "wordpress:wordpress.admin.diagnostics.manualSyncGoToSettingsSync", + "wordpress:wordpress.admin.diagnostics.checkDatabasePermissions", + "wordpress:wordpress.admin.diagnostics.webhookFailures", + "wordpress:wordpress.admin.diagnostics.verifyWebhookUrlIsAccessible", + "wordpress:wordpress.admin.diagnostics.checkSslCertificateValidity", + "wordpress:wordpress.admin.diagnostics.reviewWebhookEventLogs", + "wordpress:wordpress.admin.diagnostics.testWithManualWebhookDispatch", + "wordpress:wordpress.admin.diagnostics.ticketCreatedWeLlRespondWithin", + "wordpress:wordpress.admin.diagnostics.checkPhpVersionRequires82", + "wordpress:wordpress.admin.settingsSync.serverReturnedAnUnexpectedResponseShape", + "wordpress:wordpress.widget.joinCta", + "wordpress:wordpress.widget.loginPrompt", + "wordpress:wordpress.plugin.loyaltydog", + "wordpress:wordpress.plugin.httpsLoyaltydogIoWordpress", + "wordpress:wordpress.plugin.pointsTiersAndRewardRedemptionFor", + "wordpress:wordpress.plugin.loyaltydogInc", + "wordpress:wordpress.plugin.httpsLoyaltyDog", + "wordpress:wordpress.plugin.loyaltydogRequiresWordpress69Or", + "wordpress:wordpress.plugin.youAreCurrentlyRunningWordpress", + "wordpress:wordpress.plugin.youAreCurrentlyRunningPhp", + "wordpress:wordpress.plugin.loyaltydogRequiresWoocommerceToBeInstalled", + "wordpress:wordpress.plugin.loyaltydogRequiresWoocommerce70Or", + "wordpress:wordpress.plugin.youAreCurrentlyRunningWoocommerce", + "wordpress:wordpress.plugin.loyaltydogActivationFailedSystemRequirementsNot", + "wordpress:wordpress.plugin.loyaltydogRequiresPhp82OrNewer", + "wordpress:wordpress.misc.invalidProgram", + "wordpress:wordpress.misc.emailOrPhoneIsRequired", + "wordpress:wordpress.misc.customerNotFound", + "wordpress:wordpress.misc.invalidParameters", + "wordpress:wordpress.misc.memberNotFound", + "wordpress:wordpress.misc.memberEmailMissing", + "wordpress:wordpress.misc.oauthCredentialsNotConfigured", + "wordpress:wordpress.misc.registrationIdNotFound", + "wordpress:wordpress.misc.backendUrlNotConfigured", + "wordpress:wordpress.misc.failedToRotateSecretPleaseTry", + "wordpress:wordpress.misc.coreApiReturnedAnError", + "wordpress:wordpress.misc.invalidResponseFromCoreApi", + "wordpress:wordpress.misc.failedToStoreNewSecret", + "wordpress:wordpress.misc.webhookSecretRotatedSuccessfully", + "wordpress:wordpress.misc.loyaltydogCredentialsAreNotConfigured", + "wordpress:wordpress.misc.apiKeyIsRequired", + "wordpress:wordpress.misc.thatLooksLikeASessionJwt", + "wordpress:wordpress.misc.apiKeyContainsInvalidCharactersAllowed", + "wordpress:wordpress.misc.apiKeyLengthIsOutOf", + "wordpress:wordpress.misc.apiKeyMustStartWithLoyaltydogpk", + "wordpress:wordpress.misc.profileNotFound", + "wordpress:wordpress.misc.tooManyRequestsPleaseTryAgain", + "wordpress:wordpress.misc.pointsEarnedForAccountSignup", + "wordpress:wordpress.misc.enterTheAmountOfPointsEarned", + "wordpress:wordpress.misc.pointsEarnedForWritingAReview", + "wordpress:wordpress.misc.enterTheAmountOfPointsEarned2", + "wordpress:wordpress.misc.currentLoyaltyPointsBalance", + "wordpress:wordpress.misc.whetherTheCustomerIsEnrolledIn", + "wordpress:wordpress.misc.estimatedPointsTheCustomerWillEarn", + "wordpress:wordpress.misc.loyaltyReward", + "wordpress:wordpress.misc.redeem", + "wordpress:wordpress.misc.redeemedSuccessfully", + "wordpress:wordpress.misc.xPointsBonusActive", + "wordpress:wordpress.misc.minimumSpendForPoints", + "wordpress:wordpress.misc.minimumOrderTotalRequiredToEarn", + "wordpress:wordpress.misc.categoryPointMultipliers", + "wordpress:wordpress.misc.onePerLineCategoryslugMultiplierE", + "wordpress:wordpress.misc.timeLimitedCampaigns", + "wordpress:wordpress.misc.onePerLineMultiplierstartdateenddatelabelEG", + "wordpress:wordpress.misc.welcomeTo1s2s", + "wordpress:wordpress.misc.loading", + "wordpress:wordpress.misc.dashboard", + "wordpress:wordpress.misc.widget", + "wordpress:wordpress.misc.systemInfo", + "wordpress:wordpress.misc.allSetTakingYouToYour", + "wordpress:wordpress.misc.activationFailed", + "wordpress:wordpress.misc.loyaltyDiscount", + "wordpress:wordpress.misc.nameFree", + "wordpress:wordpress.misc.nameValueCurrency", + "wordpress:wordpress.misc.nameValueCurrency2", + "wordpress:wordpress.misc.unableToGenerateASecureSession", + "wordpress:wordpress.misc.activatingYourLoyaltydogAccountHold", + "wordpress:wordpress.misc.activationFailedSessionExpiredPlease", + "wordpress:wordpress.misc.tryAgain", + "wordpress:wordpress.misc.connectYourLoyaltyProgramToWoocommerce", + "wordpress:wordpress.misc.pasteYourActivationKeyHere", + "wordpress:wordpress.misc.activateLoyaltydog", + "wordpress:wordpress.misc.havingTroubleContactSupport", + "wordpress:wordpress.misc.accountLinkedSuccessfully", + "wordpress:wordpress.misc.yourLoyaltydogAccountIsNowConnected", + "wordpress:wordpress.misc.dashboardImplementationComingInFutureUpdates", + "wordpress:wordpress.misc.increasePointMultiplierForTopSelling", + "wordpress:wordpress.misc.createAWinBackCampaignFor", + "wordpress:wordpress.misc.reduceTierAdvancementRequirementsToIncrease", + "wordpress:wordpress.misc.highValueSpent100095Engagement", + "wordpress:wordpress.misc.regularSpent100100070Engagement", + "wordpress:wordpress.misc.occasionalSpent10030Engagement", + "wordpress:wordpress.misc.inactiveNoPurchasesIn60Days", + "wordpress:wordpress.misc.createWinBackCampaignForInactive", + "wordpress:wordpress.misc.criticalIssues", + "wordpress:wordpress.misc.warnings", + "wordpress:wordpress.misc.webhookSecretNotConfigured", + "wordpress:wordpress.misc.missingStripeSignatureHeader", + "wordpress:wordpress.misc.malformedStripeSignatureHeader", + "wordpress:wordpress.misc.stripeSignatureTimestampIsNotNumeric", + "wordpress:wordpress.misc.webhookSignatureTimestampOutsideToleranceWindow", + "wordpress:wordpress.misc.webhookSignatureVerificationFailed", + "wordpress:wordpress.misc.invalidWebhookPayload", + "wordpress:wordpress.misc.dependsOnTheLastVersionOf", + "wordpress:wordpress.misc.activeWoocommerce", + "wordpress:wordpress.misc.installWoocommerce", + "wordpress:wordpress.misc.youHave", + "wordpress:wordpress.misc.pointsEarn", + "wordpress:wordpress.misc.moreWithThisOrder", + "wordpress:wordpress.misc.loyaltyPoints", + "wordpress:wordpress.frontend.points.loyaltyPoints", + "wordpress:wordpress.frontend.points.youAreNotYetEnrolledIn", + "wordpress:wordpress.frontend.points.yourPointsBalance", + "wordpress:wordpress.frontend.points.points", + "wordpress:wordpress.frontend.points.tier", + "wordpress:wordpress.frontend.points.activeReward", + "wordpress:wordpress.frontend.points.addItemsToYourCartTo", + "wordpress:wordpress.frontend.points.viewCart", + "wordpress:wordpress.frontend.points.howToEarnPoints", + "wordpress:wordpress.frontend.points.earn1sPointsForEvery2s", + "wordpress:wordpress.frontend.points.pointsForCreatingAnAccount", + "wordpress:wordpress.frontend.points.pointsForWritingAProductReview", + "wordpress:wordpress.frontend.points.pointsForReferringAFriend", + "wordpress:wordpress.frontend.points.strongLoyaltyPointsStrongYouHave", + "wordpress:wordpress.frontend.points.viewDetails", + "wordpress:wordpress.frontend.points.points2", + "wordpress:wordpress.frontend.customerExport.youMustBeLoggedInTo", + "wordpress:wordpress.frontend.customerExport.securityCheckFailedPleaseReloadAnd", + "wordpress:wordpress.frontend.customerExport.youCanRequestOneLoyaltyData", + "wordpress:wordpress.frontend.customerExport.weCouldNotPrepareYourExport", + "wordpress:wordpress.frontend.account.loyaltyProgram", + "wordpress:wordpress.frontend.account.downloadMyLoyaltyData", + "wordpress:wordpress.frontend.account.notificationPreferences", + "wordpress:wordpress.frontend.account.yourNotificationPreferencesHaveBeenSaved", + "wordpress:wordpress.frontend.account.emailMeWhenMyLoyaltyTier", + "wordpress:wordpress.frontend.account.savePreferences", + "wordpress:wordpress.frontend.checkout.loadingLoyaltyInformation", + "wordpress:wordpress.frontend.checkout.identifyingCustomer", + "wordpress:wordpress.frontend.checkout.errorLoadingLoyaltyProgramPleaseTry", + "wordpress:wordpress.frontend.checkout.emailOrPhone", + "wordpress:wordpress.frontend.checkout.enterEmailOrPhone", + "wordpress:wordpress.frontend.checkout.findAccount", + "wordpress:wordpress.frontend.checkout.welcome", + "wordpress:wordpress.frontend.checkout.pointsBalance", + "wordpress:wordpress.frontend.checkout.availableRewards", + "wordpress:wordpress.frontend.checkout.selectAReward", + "wordpress:wordpress.frontend.checkout.applyReward", + "wordpress:wordpress.frontend.checkout.appliedLoyaltyDiscounts", + "wordpress:wordpress.frontend.redeem.loyaltydogReward", + "wordpress:wordpress.frontend.checkoutService.networkErrorWhileApplyingRewardPlease", + "wordpress:wordpress.frontend.checkoutService.youDoNotHaveEnoughPoints", + "wordpress:wordpress.frontend.checkoutService.rewardNotFoundOrNoLonger", + "wordpress:wordpress.frontend.checkoutService.failedToApplyReward", + "wordpress:wordpress.frontend.checkoutService.rewardApplied", + "wordpress:wordpress.frontend.blocks.displayTheLoyaltydogLoyaltyWidgetFor", + "wordpress:wordpress.frontend.blocks.loyalty", + "wordpress:wordpress.frontend.blocks.points", + "wordpress:wordpress.frontend.blocks.rewards", + "wordpress:wordpress.frontend.blocks.loyaltydog", + "wordpress:wordpress.emails.invalidEmailAddress", + "wordpress:wordpress.emails.invalidVerificationCodeFormat", + "wordpress:wordpress.emails.emailServiceNotConfigured", + "wordpress:wordpress.emails.loyaltydogAccountVerification", + "wordpress:wordpress.emails.failedToSendEmailViaSendgrid", + "wordpress:wordpress.emails.verifyYourEmail", + "wordpress:wordpress.emails.thankYouForSigningUpFor", + "wordpress:wordpress.emails.yourVerificationCode", + "wordpress:wordpress.emails.thisCodeWillExpireIn10", + "wordpress:wordpress.emails.securityNotice", + "wordpress:wordpress.emails.neverShareThisCodeWithAnyone", + "wordpress:wordpress.emails.ifYouDidNotSignUp", + "wordpress:wordpress.emails.allRightsReserved", + "wordpress:wordpress.emails.thisIsAnAutomatedEmailPlease", + "wordpress:wordpress.emails.passwordIsRequired", + "wordpress:wordpress.emails.welcomeToLoyaltydogYourAccountIs", + "wordpress:wordpress.emails.welcomeToLoyaltydog", + "wordpress:wordpress.emails.yourLoyaltydogAccountForHasBeen", + "wordpress:wordpress.emails.yourLoyaltydogAccountHasBeenCreated", + "wordpress:wordpress.emails.belowAreYourLoginCredentialsYou", + "wordpress:wordpress.emails.email", + "wordpress:wordpress.emails.password", + "wordpress:wordpress.emails.keepThisPasswordSafeAndDo", + "wordpress:wordpress.emails.ifYouDidNotCreateThis", + "wordpress:wordpress.privacy.loyaltydogLoyaltyProgram", + "wordpress:wordpress.privacy.firstName", + "wordpress:wordpress.privacy.lastName", + "wordpress:wordpress.privacy.phone", + "wordpress:wordpress.privacy.pointsBalance", + "wordpress:wordpress.privacy.tier", + "wordpress:wordpress.privacy.enrollmentStatus", + "wordpress:wordpress.privacy.enrolledAt", + "wordpress:wordpress.privacy.whenYouUseLoyaltydogYourEmail" + ], + "bundleNotJson": [ + "common:nav.noProgram", + "common:app.admin.aiAdvisor.aiConnected", + "common:app.admin.aiAdvisor.aiDisconnected", + "common:app.admin.aiAdvisor.aiStatus", + "common:app.admin.aiAdvisor.applied", + "common:app.admin.aiAdvisor.failedToFetchData", + "common:app.admin.aiAdvisor.fromSource", + "common:app.admin.aiAdvisor.initialSummaryMessage", + "common:app.admin.aiAdvisor.liveData", + "common:app.admin.aiAdvisor.noDataDescription", + "common:app.admin.aiAdvisor.noDataTitle", + "common:app.admin.aiAdvisor.pageTitle", + "common:app.admin.aiAdvisor.pocNotice", + "common:app.admin.aiAdvisor.refreshData", + "common:app.auth.register.dataProcessingAgreement", + "common:app.auth.register.privacyPolicy", + "common:app.auth.register.termsOfService", + "common:_comment" + ] + } + }, + "m3": [], + "dynamic": [ + { + "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", + "line": 227, + "raw": "labelKey" + }, + { + "file": "src/app/(cpanel)/(admin)/admin/reports/ReportsClientParts.tsx", + "line": 16, + "raw": "titleKey" + }, + { + "file": "src/app/(cpanel)/(admin)/admin/reports/ReportsClientParts.tsx", + "line": 24, + "raw": "titleKey" + }, + { + "file": "src/app/(cpanel)/(user)/account/verification/page.tsx", + "line": 44, + "raw": "errorKey" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/[programId]/analytics-client.tsx", + "line": 41, + "raw": "label" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/offers/offers-table.tsx", + "line": 53, + "raw": "`loyalty.offers.status.${offer.status.toLowerCase()}`" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/vouchers-table.tsx", + "line": 45, + "raw": "`loyalty.vouchers.status.${voucher.status.toLowerCase()}`" + }, + { + "file": "src/app/(marketing)/_components/ComingSoonRow.tsx", + "line": 21, + "raw": "i.descriptionKey" + }, + { + "file": "src/app/(marketing)/_components/CookieConsentBanner.tsx", + "line": 60, + "raw": "M.cookies.banner" + }, + { + "file": "src/app/(marketing)/_components/CookieConsentBanner.tsx", + "line": 63, + "raw": "M.cookies.essentialsOnly" + }, + { + "file": "src/app/(marketing)/_components/CookieConsentBanner.tsx", + "line": 66, + "raw": "M.cookies.acceptAll" + }, + { + "file": "src/app/(marketing)/_components/FaqSection.tsx", + "line": 21, + "raw": "item.q" + }, + { + "file": "src/app/(marketing)/_components/FaqSection.tsx", + "line": 22, + "raw": "item.a" + }, + { + "file": "src/app/(marketing)/_components/Hero.tsx", + "line": 15, + "raw": "M.hero.headline" + }, + { + "file": "src/app/(marketing)/_components/Hero.tsx", + "line": 16, + "raw": "M.hero.subhead" + }, + { + "file": "src/app/(marketing)/_components/Hero.tsx", + "line": 22, + "raw": "M.hero.ctaPrimary" + }, + { + "file": "src/app/(marketing)/_components/Hero.tsx", + "line": 24, + "raw": "M.hero.ctaDisclaimer" + }, + { + "file": "src/app/(marketing)/_components/Hero.tsx", + "line": 26, + "raw": "M.hero.ctaLogin" + }, + { + "file": "src/app/(marketing)/_components/IntegrationGrid.tsx", + "line": 19, + "raw": "i.descriptionKey" + }, + { + "file": "src/app/(marketing)/_components/IntegrationTile.tsx", + "line": 27, + "raw": "M.integrations.comingSoon" + }, + { + "file": "src/app/(marketing)/_components/MarketingFooter.tsx", + "line": 16, + "raw": "M.footer.privacy" + }, + { + "file": "src/app/(marketing)/_components/MarketingFooter.tsx", + "line": 17, + "raw": "M.footer.terms" + }, + { + "file": "src/app/(marketing)/_components/MarketingFooter.tsx", + "line": 18, + "raw": "M.footer.dpa" + }, + { + "file": "src/app/(marketing)/_components/MarketingFooter.tsx", + "line": 19, + "raw": "M.footer.cookies" + }, + { + "file": "src/app/(marketing)/_components/MarketingNav.tsx", + "line": 29, + "raw": "M.nav.login" + }, + { + "file": "src/app/(marketing)/_components/MarketingNav.tsx", + "line": 32, + "raw": "M.nav.trial" + }, + { + "file": "src/app/(marketing)/_components/Pricing.tsx", + "line": 18, + "raw": "M.pricing.amount" + }, + { + "file": "src/app/(marketing)/_components/Pricing.tsx", + "line": 19, + "raw": "M.pricing.period" + }, + { + "file": "src/app/(marketing)/_components/Pricing.tsx", + "line": 21, + "raw": "M.pricing.disclaimer" + }, + { + "file": "src/app/(marketing)/_components/Pricing.tsx", + "line": 22, + "raw": "M.pricing.channelNote" + }, + { + "file": "src/components/MetricSwitcher.tsx", + "line": 48, + "raw": "METRIC_OPTIONS.find((opt) => opt.value === selectedMetric)?.translationKey || \"components.metricSelectMetric\"" + }, + { + "file": "src/components/MetricSwitcher.tsx", + "line": 82, + "raw": "option.translationKey" + } + ], + "multiNsFiles": [] +} \ No newline at end of file diff --git a/docs/audits/2026-06-04-frontend-key-audit.md b/docs/audits/2026-06-04-frontend-key-audit.md new file mode 100644 index 0000000..6b9168f --- /dev/null +++ b/docs/audits/2026-06-04-frontend-key-audit.md @@ -0,0 +1,142 @@ +# Frontend i18n Key-Reference Audit + +_Generated: 2026-06-04T16:23:02.310Z_ + +Reproduce: `node scripts/audit-frontend-keys.mjs` (paths overridable via flags/env). + +## Summary + +| Metric | Value | +|---|---| +| Files scanned | 716 | +| Total `t()` reference occurrences | 2124 | +| Unique referenced keys (ns:key) | 1858 | +| **M1** referenced but MISSING from en-US JSON | **14** | +| **M3** referenced but MISSING from BOTH JSON & bundle | **0** | +| M2 referenced: in JSON, not in bundle | 0 | +| M2 referenced: in bundle, not in JSON | 14 | +| M2 full drift: in JSON, not in bundle | 2187 | +| M2 full drift: in bundle, not in JSON | 18 | +| Dynamic keys (manual review) | 32 | + +### Inputs + +- Frontend src: `/tmp/wt-core_api-i18n/frontend/src` +- en-US locale dir: `/tmp/wt-locplat-i18n/packages/i18n/locales/en-US` +- Bundled fallback: `/tmp/wt-core_api-i18n/frontend/src/lib/i18n-utils.ts` +- TypeScript module: `/tmp/wt-core_api-i18n/frontend/node_modules/typescript` + +### Locale JSON namespaces (leaf key counts) + +- `clover`: 35 +- `common`: 2768 +- `emails`: 409 +- `eposnow`: 18 +- `errors`: 284 +- `giftCards`: 536 +- `marketing`: 32 +- `notifications`: 103 +- `shopify`: 23 +- `validation`: 141 +- `wordpress`: 618 + +### Bundled fallback namespaces (leaf key counts) + +- `common`: 2766 +- `marketing`: 32 + +## M1 — Referenced in frontend, MISSING from en-US JSON + +These keys must be ADDED to the en-US source-of-truth (grouped by namespace). +`(bundled)` = also present in the frontend fallback bundle (so it renders today but isn't in source JSON). + +### `common` (14) + +- `app.admin.aiAdvisor.aiConnected` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:262 +- `app.admin.aiAdvisor.aiDisconnected` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:263 +- `app.admin.aiAdvisor.aiStatus` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:259 +- `app.admin.aiAdvisor.applied` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:247 +- `app.admin.aiAdvisor.failedToFetchData` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:103 +- `app.admin.aiAdvisor.fromSource` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:248 +- `app.admin.aiAdvisor.initialSummaryMessage` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:70 +- `app.admin.aiAdvisor.liveData` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:237 +- `app.admin.aiAdvisor.noDataDescription` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:300 +- `app.admin.aiAdvisor.noDataTitle` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:299 +- `app.admin.aiAdvisor.pageTitle` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:189 +- `app.admin.aiAdvisor.pocNotice` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:308 +- `app.admin.aiAdvisor.refreshData` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:268 +- `nav.noProgram` _(bundled)_ — src/components/layouts/Navbar.tsx:236 + +## M3 — Referenced but MISSING from BOTH JSON and bundle (likely code bug) + +Probable wrong path/casing in frontend code — these need a CODE FIX. + +_None._ + +## M2 — JSON / bundle drift + +### Referenced keys present in JSON but NOT in bundle (0) + +These render from the API but would be missing if the backend is unreachable (fallback gap). + +_None._ + +### Referenced keys present in bundle but NOT in JSON (14) + +- `common:app.admin.aiAdvisor.aiConnected` +- `common:app.admin.aiAdvisor.aiDisconnected` +- `common:app.admin.aiAdvisor.aiStatus` +- `common:app.admin.aiAdvisor.applied` +- `common:app.admin.aiAdvisor.failedToFetchData` +- `common:app.admin.aiAdvisor.fromSource` +- `common:app.admin.aiAdvisor.initialSummaryMessage` +- `common:app.admin.aiAdvisor.liveData` +- `common:app.admin.aiAdvisor.noDataDescription` +- `common:app.admin.aiAdvisor.noDataTitle` +- `common:app.admin.aiAdvisor.pageTitle` +- `common:app.admin.aiAdvisor.pocNotice` +- `common:app.admin.aiAdvisor.refreshData` +- `common:nav.noProgram` + +### Full drift (all keys, not just referenced) + +- In JSON but not bundle: **2187** +- In bundle but not JSON: **18** + +## Dynamic keys — manual review + +Keys built from template literals / variables; not statically resolvable. + +- src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:227 — `labelKey` +- src/app/(cpanel)/(admin)/admin/reports/ReportsClientParts.tsx:16 — `titleKey` +- src/app/(cpanel)/(admin)/admin/reports/ReportsClientParts.tsx:24 — `titleKey` +- src/app/(cpanel)/(user)/account/verification/page.tsx:44 — `errorKey` +- src/app/(cpanel)/(user)/loyalty/[programId]/analytics-client.tsx:41 — `label` +- src/app/(cpanel)/(user)/loyalty/programs/[programId]/offers/offers-table.tsx:53 — ``loyalty.offers.status.${offer.status.toLowerCase()}`` +- src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/vouchers-table.tsx:45 — ``loyalty.vouchers.status.${voucher.status.toLowerCase()}`` +- src/app/(marketing)/_components/ComingSoonRow.tsx:21 — `i.descriptionKey` +- src/app/(marketing)/_components/CookieConsentBanner.tsx:60 — `M.cookies.banner` +- src/app/(marketing)/_components/CookieConsentBanner.tsx:63 — `M.cookies.essentialsOnly` +- src/app/(marketing)/_components/CookieConsentBanner.tsx:66 — `M.cookies.acceptAll` +- src/app/(marketing)/_components/FaqSection.tsx:21 — `item.q` +- src/app/(marketing)/_components/FaqSection.tsx:22 — `item.a` +- src/app/(marketing)/_components/Hero.tsx:15 — `M.hero.headline` +- src/app/(marketing)/_components/Hero.tsx:16 — `M.hero.subhead` +- src/app/(marketing)/_components/Hero.tsx:22 — `M.hero.ctaPrimary` +- src/app/(marketing)/_components/Hero.tsx:24 — `M.hero.ctaDisclaimer` +- src/app/(marketing)/_components/Hero.tsx:26 — `M.hero.ctaLogin` +- src/app/(marketing)/_components/IntegrationGrid.tsx:19 — `i.descriptionKey` +- src/app/(marketing)/_components/IntegrationTile.tsx:27 — `M.integrations.comingSoon` +- src/app/(marketing)/_components/MarketingFooter.tsx:16 — `M.footer.privacy` +- src/app/(marketing)/_components/MarketingFooter.tsx:17 — `M.footer.terms` +- src/app/(marketing)/_components/MarketingFooter.tsx:18 — `M.footer.dpa` +- src/app/(marketing)/_components/MarketingFooter.tsx:19 — `M.footer.cookies` +- src/app/(marketing)/_components/MarketingNav.tsx:29 — `M.nav.login` +- src/app/(marketing)/_components/MarketingNav.tsx:32 — `M.nav.trial` +- src/app/(marketing)/_components/Pricing.tsx:18 — `M.pricing.amount` +- src/app/(marketing)/_components/Pricing.tsx:19 — `M.pricing.period` +- src/app/(marketing)/_components/Pricing.tsx:21 — `M.pricing.disclaimer` +- src/app/(marketing)/_components/Pricing.tsx:22 — `M.pricing.channelNote` +- src/components/MetricSwitcher.tsx:48 — `METRIC_OPTIONS.find((opt) => opt.value === selectedMetric)?.translationKey || "components.metricSelectMetric"` +- src/components/MetricSwitcher.tsx:82 — `option.translationKey` + diff --git a/scripts/audit-frontend-keys.mjs b/scripts/audit-frontend-keys.mjs new file mode 100644 index 0000000..77de556 --- /dev/null +++ b/scripts/audit-frontend-keys.mjs @@ -0,0 +1,582 @@ +#!/usr/bin/env node +/** + * audit-frontend-keys.mjs + * ------------------------------------------------------------------------------ + * Produces an authoritative, reproducible diff of every i18n `t()` key that a + * consumer frontend REFERENCES against: + * (a) the en-US source-of-truth locale JSON, and + * (b) the frontend's bundled fallback (`i18n-utils.ts`). + * + * It emits both a machine-readable `.json` and a human-readable `.md` report + * with four buckets: + * M1 — referenced in frontend, MISSING from en-US JSON (drives "add new keys") + * M2 — JSON/bundle drift (present in JSON but not bundle, and vice-versa), + * both limited to referenced keys and as a full-drift count + * M3 — referenced but MISSING from BOTH JSON and bundle (likely a code bug) + * Dynamic — keys built from template literals / variables (manual review) + * + * EXTRACTION STRATEGY + * ------------------- + * Keys are extracted via the TypeScript compiler API (AST), not regex, so we + * correctly handle nesting, comments, JSX, and multi-arg calls. + * + * `typescript` is RESOLVED FROM THE FRONTEND'S node_modules (see TS_MODULE + * below) — no devDependency is added to this repo. Override with --ts-module. + * + * Two `t()` call shapes are recognised (per the core_api frontend): + * - CLIENT `t("key.path"[, opts])` — from useTranslation("ns") + * namespace = nearest enclosing useTranslation("ns") (default common) + * - SERVER `t(localeExpr, "key.path"[, "ns"])` — from @/lib/i18n-server + * namespace = 3rd-arg string literal if present, else common + * + * Disambiguation heuristic (matches the spec): + * - arg0 is a string literal -> CLIENT, key = arg0 + * - arg0 is NOT a string literal AND + * arg1 IS a string literal -> SERVER, key = arg1, ns = arg2|common + * - key arg is a template literal / non-literal -> DYNAMIC (manual review) + * + * i18next `ns:key` prefix: if the resolved key string itself contains a + * namespace prefix (e.g. `t("common:foo.bar")` on an i18n instance), the + * prefix wins and overrides the useTranslation-derived namespace. Only + * prefixes matching a known namespace are stripped (so legitimate keys that + * happen to contain a colon are left intact). + * + * Namespace resolution for CLIENT calls: we scan each file for + * `useTranslation("ns")` declarations and bind them by variable scope where + * possible. In practice the frontend always destructures `const { t } = ...`, + * so we track the most recent `useTranslation` ns seen lexically before the + * call (per-file, source-order). Confidence note: this is heuristic; a file + * that mixes namespaces in sibling scopes could mis-attribute. We flag files + * with >1 distinct namespace so reviewers can spot-check. + * + * BUNDLE PARSING + * -------------- + * The bundled fallback object literal is extracted by AST-walking the + * `allTranslations` (or `enUSFallback`) object-literal node and reading its + * string-valued leaves. We do NOT transpile+eval (avoids executing arbitrary + * module code and resolving imports); AST walking of the literal is robust and + * side-effect free. + * + * USAGE + * node scripts/audit-frontend-keys.mjs \ + * [--frontend-src DIR] [--locale-dir DIR] [--fallback FILE] \ + * [--out-json FILE] [--out-md FILE] [--ts-module DIR] + * All overridable via env: FRONTEND_SRC, LOCALE_DIR, FALLBACK_FILE, + * OUT_JSON, OUT_MD, TS_MODULE. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; + +// --------------------------------------------------------------------------- +// Config / CLI +// --------------------------------------------------------------------------- +const DEFAULTS = { + frontendSrc: "/tmp/wt-core_api-i18n/frontend/src", + localeDir: "/tmp/wt-locplat-i18n/packages/i18n/locales/en-US", + fallback: "/tmp/wt-core_api-i18n/frontend/src/lib/i18n-utils.ts", + tsModule: "/tmp/wt-core_api-i18n/frontend/node_modules/typescript", + outJson: "/tmp/wt-locplat-i18n/docs/audits/2026-06-04-frontend-key-audit.json", + outMd: "/tmp/wt-locplat-i18n/docs/audits/2026-06-04-frontend-key-audit.md", +}; + +function parseArgs(argv) { + const map = { + "--frontend-src": "frontendSrc", + "--locale-dir": "localeDir", + "--fallback": "fallback", + "--ts-module": "tsModule", + "--out-json": "outJson", + "--out-md": "outMd", + }; + const cfg = { ...DEFAULTS }; + // env overrides + if (process.env.FRONTEND_SRC) cfg.frontendSrc = process.env.FRONTEND_SRC; + if (process.env.LOCALE_DIR) cfg.localeDir = process.env.LOCALE_DIR; + if (process.env.FALLBACK_FILE) cfg.fallback = process.env.FALLBACK_FILE; + if (process.env.TS_MODULE) cfg.tsModule = process.env.TS_MODULE; + if (process.env.OUT_JSON) cfg.outJson = process.env.OUT_JSON; + if (process.env.OUT_MD) cfg.outMd = process.env.OUT_MD; + for (let i = 2; i < argv.length; i += 2) { + const key = map[argv[i]]; + if (key) cfg[key] = argv[i + 1]; + } + return cfg; +} + +const cfg = parseArgs(process.argv); + +// Resolve the TypeScript compiler API from the frontend's node_modules. +const require = createRequire(import.meta.url); +let ts; +try { + ts = require(cfg.tsModule); +} catch (e) { + console.error(`Failed to load TypeScript from ${cfg.tsModule}: ${e.message}`); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Helpers: file discovery +// --------------------------------------------------------------------------- +const EXCLUDE_DIR = new Set(["node_modules", ".next", "__tests__"]); +function isExcludedFile(p) { + const base = path.basename(p); + if (/\.d\.ts$/.test(base)) return true; + if (/\.test\.(ts|tsx)$/.test(base)) return true; + return false; +} +function collectFiles(dir, out = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (EXCLUDE_DIR.has(entry.name)) continue; + collectFiles(full, out); + } else if (/\.(ts|tsx)$/.test(entry.name) && !isExcludedFile(full)) { + out.push(full); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Helpers: flatten nested object -> dotted leaf keys +// --------------------------------------------------------------------------- +function flatten(obj, prefix, out) { + for (const [k, v] of Object.entries(obj)) { + const dotted = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === "object" && !Array.isArray(v)) { + flatten(v, dotted, out); + } else { + out.add(dotted); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Load en-US JSON namespaces +// --------------------------------------------------------------------------- +function loadLocaleJson(dir) { + const perNs = {}; // ns -> Set + for (const f of fs.readdirSync(dir)) { + if (!f.endsWith(".json")) continue; + const ns = f.replace(/\.json$/, ""); + const data = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); + perNs[ns] = flatten(data, "", new Set()); + } + return perNs; +} + +// --------------------------------------------------------------------------- +// Parse bundled fallback: AST-walk allTranslations -> en-US subtree -> per-ns sets +// --------------------------------------------------------------------------- +function objectLiteralToPlain(node) { + // Convert a TS ObjectLiteralExpression AST node to a plain JS object, + // following identifier references to other top-level const declarations. + const out = {}; + for (const prop of node.properties) { + if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue; + const name = prop.name; + let key; + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + key = name.text; + } else { + continue; + } + let valNode = ts.isPropertyAssignment(prop) ? prop.initializer : prop.name; + out[key] = valueOf(valNode); + } + return out; +} + +let TOP_LEVEL_CONSTS = new Map(); // name -> initializer node (for resolving references) + +function valueOf(node) { + if (!node) return undefined; + // `expr satisfies T` / `expr as T` — unwrap + if (ts.isSatisfiesExpression?.(node) || ts.isAsExpression(node)) { + return valueOf(node.expression); + } + if (ts.isParenthesizedExpression(node)) return valueOf(node.expression); + if (ts.isObjectLiteralExpression(node)) return objectLiteralToPlain(node); + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isNumericLiteral(node)) return Number(node.text); + if (node.kind === ts.SyntaxKind.TrueKeyword) return true; + if (node.kind === ts.SyntaxKind.FalseKeyword) return false; + if (ts.isTemplateExpression(node)) return ""; + if (ts.isIdentifier(node)) { + const ref = TOP_LEVEL_CONSTS.get(node.text); + if (ref) return valueOf(ref); + return ""; + } + return ""; +} + +function loadBundle(file) { + const text = fs.readFileSync(file, "utf8"); + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + + // Index top-level const declarations so references (e.g. enUSFallback) resolve. + TOP_LEVEL_CONSTS = new Map(); + let allTranslationsNode; + let enUSFallbackNode; + for (const stmt of sf.statements) { + if (ts.isVariableStatement(stmt)) { + for (const decl of stmt.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && decl.initializer) { + TOP_LEVEL_CONSTS.set(decl.name.text, decl.initializer); + if (decl.name.text === "allTranslations") allTranslationsNode = decl.initializer; + if (decl.name.text === "enUSFallback") enUSFallbackNode = decl.initializer; + } + } + } + } + + // Prefer the en-US subtree from allTranslations; fall back to enUSFallback. + let enUsTree; + if (allTranslationsNode && ts.isObjectLiteralExpression(allTranslationsNode)) { + const all = objectLiteralToPlain(allTranslationsNode); + enUsTree = all["en-US"]; + } + if (!enUsTree && enUSFallbackNode) { + enUsTree = valueOf(enUSFallbackNode); + } + if (!enUsTree || typeof enUsTree !== "object") { + throw new Error("Could not extract en-US subtree from bundle"); + } + + const perNs = {}; + for (const [ns, sub] of Object.entries(enUsTree)) { + if (sub && typeof sub === "object") { + perNs[ns] = flatten(sub, "", new Set()); + } + } + return perNs; +} + +// --------------------------------------------------------------------------- +// Extract referenced keys from a source file via AST +// --------------------------------------------------------------------------- +function extractFromFile(file, knownNamespaces) { + const text = fs.readFileSync(file, "utf8"); + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + + // Resolve an i18next `ns:key` prefix if `ns` is a known namespace. + function resolveKey(rawKey, fallbackNs) { + const idx = rawKey.indexOf(":"); + if (idx > 0) { + const maybeNs = rawKey.slice(0, idx); + if (knownNamespaces.has(maybeNs)) { + return { ns: maybeNs, key: rawKey.slice(idx + 1) }; + } + } + return { ns: fallbackNs, key: rawKey }; + } + + const refs = []; // { ns, key, line } + const dynamics = []; // { line, raw } + const namespacesSeen = new Set(); + + // First pass: collect useTranslation("ns") positions (source order). + const nsByPos = []; // { pos, ns } + function collectNs(node) { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useTranslation") { + const a0 = node.arguments[0]; + let ns = "common"; + if (a0 && (ts.isStringLiteral(a0) || ts.isNoSubstitutionTemplateLiteral(a0))) ns = a0.text; + nsByPos.push({ pos: node.getStart(sf), ns }); + namespacesSeen.add(ns); + } + ts.forEachChild(node, collectNs); + } + collectNs(sf); + nsByPos.sort((a, b) => a.pos - b.pos); + + function nsForPos(pos) { + // nearest useTranslation lexically before this call (source order) + let ns = "common"; + for (const e of nsByPos) { + if (e.pos <= pos) ns = e.ns; + else break; + } + return ns; + } + + const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; + + function isStrLit(n) { + return n && (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)); + } + function isDynamicKeyNode(n) { + return n && (ts.isTemplateExpression(n) || ts.isBinaryExpression(n) || + ts.isIdentifier(n) || ts.isPropertyAccessExpression(n) || + ts.isElementAccessExpression(n) || ts.isConditionalExpression(n) || + ts.isCallExpression(n)); + } + + // Second pass: find t(...) calls. Match callee named `t` (identifier or + // `obj.t` member access — i18next instances are sometimes `i18n.t`). + function isTCallee(expr) { + if (ts.isIdentifier(expr)) return expr.text === "t"; + if (ts.isPropertyAccessExpression(expr)) return expr.name.text === "t"; + return false; + } + + function visit(node) { + if (ts.isCallExpression(node) && isTCallee(node.expression)) { + const args = node.arguments; + const a0 = args[0]; + const a1 = args[1]; + const a2 = args[2]; + const line = lineOf(node); + + if (isStrLit(a0)) { + // CLIENT: t("key"[, opts]) — ns from useTranslation, unless key has ns: prefix + const resolved = resolveKey(a0.text, nsForPos(node.getStart(sf))); + refs.push({ ns: resolved.ns, key: resolved.key, line, shape: "client" }); + } else if (a0 && !isStrLit(a0) && isStrLit(a1)) { + // SERVER: t(localeExpr, "key"[, "ns"]) — explicit ns arg, else ns: prefix, else common + const explicitNs = isStrLit(a2) ? a2.text : "common"; + const resolved = resolveKey(a1.text, explicitNs); + refs.push({ ns: resolved.ns, key: resolved.key, line, shape: "server" }); + } else if (a0) { + // Dynamic key (template literal / variable / expression) — manual review. + // Only record if it's plausibly a key arg (a0 dynamic for client, or + // a1 dynamic for server-shaped call). + if (isDynamicKeyNode(a0)) { + dynamics.push({ line, raw: a0.getText(sf).slice(0, 160), arg: 0 }); + } else if (a1 && isDynamicKeyNode(a1)) { + dynamics.push({ line, raw: a1.getText(sf).slice(0, 160), arg: 1 }); + } + } + } + ts.forEachChild(node, visit); + } + visit(sf); + + return { refs, dynamics, namespacesSeen }; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +function rel(p) { + // present file paths relative to frontend src for compactness in the report + return path.relative(path.dirname(cfg.frontendSrc), p); +} + +function main() { + const localeNs = loadLocaleJson(cfg.localeDir); // ns -> Set + const bundleNs = loadBundle(cfg.fallback); // ns -> Set + + const localeAll = new Set(); + for (const s of Object.values(localeNs)) for (const k of s) localeAll.add(k); + + // Known namespaces (for stripping i18next `ns:key` prefixes during extraction). + const knownNamespaces = new Set([...Object.keys(localeNs), ...Object.keys(bundleNs)]); + + const files = collectFiles(cfg.frontendSrc); + + // Aggregate references. Key identity = `${ns}:${key}`. + const refMap = new Map(); // id -> { ns, key, locations:[{file,line,shape}] } + const dynamicList = []; // { file, line, raw } + const multiNsFiles = []; + + for (const file of files) { + const { refs, dynamics, namespacesSeen } = extractFromFile(file, knownNamespaces); + if (namespacesSeen.size > 1) multiNsFiles.push({ file: rel(file), namespaces: [...namespacesSeen] }); + for (const r of refs) { + const id = `${r.ns}:${r.key}`; + if (!refMap.has(id)) refMap.set(id, { ns: r.ns, key: r.key, locations: [] }); + refMap.get(id).locations.push({ file: rel(file), line: r.line, shape: r.shape }); + } + for (const d of dynamics) dynamicList.push({ file: rel(file), line: d.line, raw: d.raw }); + } + + const totalRefOccurrences = [...refMap.values()].reduce((n, r) => n + r.locations.length, 0); + + // Classify each unique referenced key. + const m1 = {}; // ns -> [{key, locations}] (missing from JSON) + const m3 = []; // [{ns,key,locations}] (missing from JSON AND bundle) + for (const ref of refMap.values()) { + const inJsonNs = localeNs[ref.ns]?.has(ref.key) ?? false; + // A key may exist under its declared ns OR (defensively) anywhere in JSON. + const inJsonAnywhere = localeAll.has(ref.key); + const inJson = inJsonNs || inJsonAnywhere; + const inBundleNs = bundleNs[ref.ns]?.has(ref.key) ?? false; + const inBundleAnywhere = Object.values(bundleNs).some((s) => s.has(ref.key)); + const inBundle = inBundleNs || inBundleAnywhere; + + if (!inJson) { + (m1[ref.ns] ??= []).push({ key: ref.key, locations: ref.locations, inBundle }); + } + if (!inJson && !inBundle) { + m3.push({ ns: ref.ns, key: ref.key, locations: ref.locations }); + } + } + + // M2: drift between JSON and bundle. + // Referenced-only view + full view. + const referencedKeys = new Set([...refMap.values()].map((r) => `${r.ns}:${r.key}`)); + + const m2 = { + referenced: { jsonNotBundle: [], bundleNotJson: [] }, + full: { jsonNotBundleCount: 0, bundleNotJsonCount: 0, jsonNotBundle: [], bundleNotJson: [] }, + }; + const allNs = new Set([...Object.keys(localeNs), ...Object.keys(bundleNs)]); + for (const ns of allNs) { + const jset = localeNs[ns] ?? new Set(); + const bset = bundleNs[ns] ?? new Set(); + for (const k of jset) { + if (!bset.has(k)) { + m2.full.jsonNotBundle.push(`${ns}:${k}`); + if (referencedKeys.has(`${ns}:${k}`)) m2.referenced.jsonNotBundle.push(`${ns}:${k}`); + } + } + for (const k of bset) { + if (!jset.has(k)) { + m2.full.bundleNotJson.push(`${ns}:${k}`); + if (referencedKeys.has(`${ns}:${k}`)) m2.referenced.bundleNotJson.push(`${ns}:${k}`); + } + } + } + m2.full.jsonNotBundleCount = m2.full.jsonNotBundle.length; + m2.full.bundleNotJsonCount = m2.full.bundleNotJson.length; + + const m1Count = Object.values(m1).reduce((n, a) => n + a.length, 0); + + const summary = { + generatedAt: new Date().toISOString(), + inputs: { + frontendSrc: cfg.frontendSrc, + localeDir: cfg.localeDir, + fallback: cfg.fallback, + tsModule: cfg.tsModule, + }, + filesScanned: files.length, + totalReferenceOccurrences: totalRefOccurrences, + uniqueReferencedKeys: refMap.size, + localeNamespaces: Object.fromEntries(Object.entries(localeNs).map(([k, v]) => [k, v.size])), + bundleNamespaces: Object.fromEntries(Object.entries(bundleNs).map(([k, v]) => [k, v.size])), + counts: { + m1_missingFromJson: m1Count, + m2_referenced_jsonNotBundle: m2.referenced.jsonNotBundle.length, + m2_referenced_bundleNotJson: m2.referenced.bundleNotJson.length, + m2_full_jsonNotBundle: m2.full.jsonNotBundleCount, + m2_full_bundleNotJson: m2.full.bundleNotJsonCount, + m3_missingFromBoth: m3.length, + dynamic_manualReview: dynamicList.length, + }, + }; + + const report = { summary, m1, m2, m3, dynamic: dynamicList, multiNsFiles }; + + // ----- write JSON ----- + fs.mkdirSync(path.dirname(cfg.outJson), { recursive: true }); + fs.writeFileSync(cfg.outJson, JSON.stringify(report, null, 2)); + + // ----- write Markdown ----- + fs.writeFileSync(cfg.outMd, renderMarkdown(report)); + + // Console summary (kept terse). + console.log(JSON.stringify(summary, null, 2)); + return report; +} + +function locStr(locations) { + return locations.map((l) => `${l.file}:${l.line}`).join(", "); +} + +function renderMarkdown(report) { + const { summary, m1, m2, m3, dynamic, multiNsFiles } = report; + const c = summary.counts; + let md = ""; + md += `# Frontend i18n Key-Reference Audit\n\n`; + md += `_Generated: ${summary.generatedAt}_\n\n`; + md += `Reproduce: \`node scripts/audit-frontend-keys.mjs\` (paths overridable via flags/env).\n\n`; + + md += `## Summary\n\n`; + md += `| Metric | Value |\n|---|---|\n`; + md += `| Files scanned | ${summary.filesScanned} |\n`; + md += `| Total \`t()\` reference occurrences | ${summary.totalReferenceOccurrences} |\n`; + md += `| Unique referenced keys (ns:key) | ${summary.uniqueReferencedKeys} |\n`; + md += `| **M1** referenced but MISSING from en-US JSON | **${c.m1_missingFromJson}** |\n`; + md += `| **M3** referenced but MISSING from BOTH JSON & bundle | **${c.m3_missingFromBoth}** |\n`; + md += `| M2 referenced: in JSON, not in bundle | ${c.m2_referenced_jsonNotBundle} |\n`; + md += `| M2 referenced: in bundle, not in JSON | ${c.m2_referenced_bundleNotJson} |\n`; + md += `| M2 full drift: in JSON, not in bundle | ${c.m2_full_jsonNotBundle} |\n`; + md += `| M2 full drift: in bundle, not in JSON | ${c.m2_full_bundleNotJson} |\n`; + md += `| Dynamic keys (manual review) | ${c.dynamic_manualReview} |\n\n`; + + md += `### Inputs\n\n`; + md += `- Frontend src: \`${summary.inputs.frontendSrc}\`\n`; + md += `- en-US locale dir: \`${summary.inputs.localeDir}\`\n`; + md += `- Bundled fallback: \`${summary.inputs.fallback}\`\n`; + md += `- TypeScript module: \`${summary.inputs.tsModule}\`\n\n`; + + md += `### Locale JSON namespaces (leaf key counts)\n\n`; + for (const [ns, n] of Object.entries(summary.localeNamespaces)) md += `- \`${ns}\`: ${n}\n`; + md += `\n### Bundled fallback namespaces (leaf key counts)\n\n`; + for (const [ns, n] of Object.entries(summary.bundleNamespaces)) md += `- \`${ns}\`: ${n}\n`; + md += `\n`; + + // M1 + md += `## M1 — Referenced in frontend, MISSING from en-US JSON\n\n`; + md += `These keys must be ADDED to the en-US source-of-truth (grouped by namespace).\n`; + md += `\`(bundled)\` = also present in the frontend fallback bundle (so it renders today but isn't in source JSON).\n\n`; + const m1Namespaces = Object.keys(m1).sort(); + if (m1Namespaces.length === 0) md += `_None._\n\n`; + for (const ns of m1Namespaces) { + const items = m1[ns].sort((a, b) => a.key.localeCompare(b.key)); + md += `### \`${ns}\` (${items.length})\n\n`; + for (const it of items) { + md += `- \`${it.key}\`${it.inBundle ? " _(bundled)_" : ""} — ${locStr(it.locations)}\n`; + } + md += `\n`; + } + + // M3 + md += `## M3 — Referenced but MISSING from BOTH JSON and bundle (likely code bug)\n\n`; + md += `Probable wrong path/casing in frontend code — these need a CODE FIX.\n\n`; + if (m3.length === 0) md += `_None._\n\n`; + else { + const sorted = [...m3].sort((a, b) => `${a.ns}:${a.key}`.localeCompare(`${b.ns}:${b.key}`)); + for (const it of sorted) md += `- \`${it.ns}:${it.key}\` — ${locStr(it.locations)}\n`; + md += `\n`; + } + + // M2 + md += `## M2 — JSON / bundle drift\n\n`; + md += `### Referenced keys present in JSON but NOT in bundle (${m2.referenced.jsonNotBundle.length})\n\n`; + md += `These render from the API but would be missing if the backend is unreachable (fallback gap).\n\n`; + if (m2.referenced.jsonNotBundle.length === 0) md += `_None._\n\n`; + else { for (const k of m2.referenced.jsonNotBundle.sort()) md += `- \`${k}\`\n`; md += `\n`; } + md += `### Referenced keys present in bundle but NOT in JSON (${m2.referenced.bundleNotJson.length})\n\n`; + if (m2.referenced.bundleNotJson.length === 0) md += `_None._\n\n`; + else { for (const k of m2.referenced.bundleNotJson.sort()) md += `- \`${k}\`\n`; md += `\n`; } + md += `### Full drift (all keys, not just referenced)\n\n`; + md += `- In JSON but not bundle: **${m2.full.jsonNotBundleCount}**\n`; + md += `- In bundle but not JSON: **${m2.full.bundleNotJsonCount}**\n\n`; + + // Dynamic + md += `## Dynamic keys — manual review\n\n`; + md += `Keys built from template literals / variables; not statically resolvable.\n\n`; + if (dynamic.length === 0) md += `_None._\n\n`; + else { + for (const d of dynamic) md += `- ${d.file}:${d.line} — \`${d.raw.replace(/\n/g, " ")}\`\n`; + md += `\n`; + } + + // Multi-namespace files (confidence note) + if (multiNsFiles.length) { + md += `## Files with >1 useTranslation namespace (verify client-key attribution)\n\n`; + for (const f of multiNsFiles) md += `- ${f.file} — ${f.namespaces.join(", ")}\n`; + md += `\n`; + } + + return md; +} + +main(); From 501e054efe4546049a92e2dd5ede7ee46fff716d Mon Sep 17 00:00:00 2001 From: Haim Barad Date: Thu, 4 Jun 2026 19:32:23 +0300 Subject: [PATCH 2/4] feat(audit): add hardcoded-string audit script + report Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/audits/2026-06-04-hardcoded-strings.json | 2837 +++++++++++++++++ docs/audits/2026-06-04-hardcoded-strings.md | 855 +++++ scripts/audit-hardcoded-strings.mjs | 452 +++ 3 files changed, 4144 insertions(+) create mode 100644 docs/audits/2026-06-04-hardcoded-strings.json create mode 100644 docs/audits/2026-06-04-hardcoded-strings.md create mode 100644 scripts/audit-hardcoded-strings.mjs diff --git a/docs/audits/2026-06-04-hardcoded-strings.json b/docs/audits/2026-06-04-hardcoded-strings.json new file mode 100644 index 0000000..aea7679 --- /dev/null +++ b/docs/audits/2026-06-04-hardcoded-strings.json @@ -0,0 +1,2837 @@ +{ + "summary": { + "generatedAt": "2026-06-04T16:31:55.856Z", + "inputs": { + "frontendSrc": "/tmp/wt-core_api-i18n/frontend/src", + "tsModule": "/tmp/wt-core_api-i18n/frontend/node_modules/typescript" + }, + "filesScanned": 661, + "filesAffected": 66, + "totalFindings": 469, + "byKind": { + "attr:alt": 16, + "jsx-text": 401, + "attr:helpertext": 16, + "attr:title": 26, + "attr:aria-label": 4, + "attr:placeholder": 5, + "attr:label": 1 + } + }, + "findings": [ + { + "file": "src/app/(auth)/auth/layout.tsx", + "line": 12, + "kind": "attr:alt", + "text": "LoyaltyDog Logo" + }, + { + "file": "src/app/(auth)/wordpress/auth/form.tsx", + "line": 18, + "kind": "jsx-text", + "text": "Connect LoyaltyDog" + }, + { + "file": "src/app/(auth)/wordpress/auth/form.tsx", + "line": 19, + "kind": "jsx-text", + "text": "Sign in to your LoyaltyDog account to activate your WordPress plugin." + }, + { + "file": "src/app/(auth)/wordpress/auth/form.tsx", + "line": 36, + "kind": "jsx-text", + "text": "Email address" + }, + { + "file": "src/app/(auth)/wordpress/auth/form.tsx", + "line": 63, + "kind": "jsx-text", + "text": "Activation Error" + }, + { + "file": "src/app/(auth)/wordpress/auth/form.tsx", + "line": 67, + "kind": "jsx-text", + "text": "Go to Dashboard" + }, + { + "file": "src/app/(auth)/wordpress/auth/form.tsx", + "line": 105, + "kind": "jsx-text", + "text": "Select a WordPress Site" + }, + { + "file": "src/app/(auth)/wordpress/auth/form.tsx", + "line": 106, + "kind": "jsx-text", + "text": "Choose which LoyaltyDog program to connect to your WordPress plugin." + }, + { + "file": "src/app/(cpanel)/(user)/account/templates/[templateId]/client.tsx", + "line": 137, + "kind": "jsx-text", + "text": "Date reached" + }, + { + "file": "src/app/(cpanel)/(user)/account/templates/[templateId]/client.tsx", + "line": 151, + "kind": "jsx-text", + "text": "Pass Yourself" + }, + { + "file": "src/app/(cpanel)/(user)/giftcard/programs/new/form.tsx", + "line": 156, + "kind": "jsx-text", + "text": "Company Name" + }, + { + "file": "src/app/(cpanel)/(user)/giftcard/programs/new/form.tsx", + "line": 159, + "kind": "attr:helpertext", + "text": "Legal name of your business" + }, + { + "file": "src/app/(cpanel)/(user)/giftcard/programs/new/form.tsx", + "line": 190, + "kind": "jsx-text", + "text": "Terms & Conditions" + }, + { + "file": "src/app/(cpanel)/(user)/giftcard/programs/new/form.tsx", + "line": 193, + "kind": "attr:helpertext", + "text": "You can specify terms for your gift card program" + }, + { + "file": "src/app/(cpanel)/(user)/layout.tsx", + "line": 81, + "kind": "jsx-text", + "text": "We are now setting up the LoyaltyDog Management Portal for your use." + }, + { + "file": "src/app/(cpanel)/(user)/layout.tsx", + "line": 83, + "kind": "jsx-text", + "text": "As soon as it is ready your dedicated Account Manager will contact you and make…" + }, + { + "file": "src/app/(cpanel)/(user)/layout.tsx", + "line": 85, + "kind": "jsx-text", + "text": "If you have any questions in the meantime please feel free to email me at" + }, + { + "file": "src/app/(cpanel)/(user)/layout.tsx", + "line": 87, + "kind": "jsx-text", + "text": "lee@loyalty.dog" + }, + { + "file": "src/app/(cpanel)/(user)/layout.tsx", + "line": 91, + "kind": "jsx-text", + "text": "More Money; More Customers; More Transactions" + }, + { + "file": "src/app/(cpanel)/(user)/layout.tsx", + "line": 94, + "kind": "jsx-text", + "text": "Thank you" + }, + { + "file": "src/app/(cpanel)/(user)/layout.tsx", + "line": 96, + "kind": "jsx-text", + "text": "The LoyaltyDog Team" + }, + { + "file": "src/app/(cpanel)/(user)/layout.tsx", + "line": 100, + "kind": "jsx-text", + "text": "Welcome to LoyaltyDog!" + }, + { + "file": "src/app/(cpanel)/(user)/layout.tsx", + "line": 100, + "kind": "jsx-text", + "text": "Thank you for choosing to add LoyaltyDog." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/customers/page.tsx", + "line": 39, + "kind": "jsx-text", + "text": "Total customers:" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-birthday-period/page.tsx", + "line": 47, + "kind": "jsx-text", + "text": "Loading data..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-birthday-period/page.tsx", + "line": 97, + "kind": "jsx-text", + "text": "Total Records" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-birthday-period/page.tsx", + "line": 113, + "kind": "jsx-text", + "text": "Don't have any qualified customers." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-email-phone-not-installed/page.tsx", + "line": 37, + "kind": "jsx-text", + "text": "Loading data..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-email-phone-not-installed/page.tsx", + "line": 56, + "kind": "jsx-text", + "text": "Total Records" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-email-phone-not-installed/page.tsx", + "line": 71, + "kind": "jsx-text", + "text": "Don't have any qualified customers." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-name-email/page.tsx", + "line": 37, + "kind": "jsx-text", + "text": "Loading data..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-name-email/page.tsx", + "line": 56, + "kind": "jsx-text", + "text": "Total Records" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-name-email/page.tsx", + "line": 70, + "kind": "jsx-text", + "text": "Don't have any qualified customers." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-name-phone/page.tsx", + "line": 37, + "kind": "jsx-text", + "text": "Loading data..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-name-phone/page.tsx", + "line": 56, + "kind": "jsx-text", + "text": "Total Records" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-name-phone/page.tsx", + "line": 70, + "kind": "jsx-text", + "text": "Don't have any qualified customers." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-opting-in/page.tsx", + "line": 37, + "kind": "jsx-text", + "text": "Loading data..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-opting-in/page.tsx", + "line": 56, + "kind": "jsx-text", + "text": "Total Records" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-opting-in/page.tsx", + "line": 70, + "kind": "jsx-text", + "text": "Don't have any qualified customers." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-opting-out/page.tsx", + "line": 37, + "kind": "jsx-text", + "text": "Loading data..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-opting-out/page.tsx", + "line": 56, + "kind": "jsx-text", + "text": "Total Records" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-opting-out/page.tsx", + "line": 70, + "kind": "jsx-text", + "text": "Don't have any qualified customers." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-recent-not-visited-period/page.tsx", + "line": 45, + "kind": "jsx-text", + "text": "Loading data..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-recent-not-visited-period/page.tsx", + "line": 67, + "kind": "jsx-text", + "text": "Total Records" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-recent-not-visited-period/page.tsx", + "line": 82, + "kind": "jsx-text", + "text": "Don't have any qualified customers." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-sorted-by-points/page.tsx", + "line": 43, + "kind": "jsx-text", + "text": "Loading data..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-sorted-by-points/page.tsx", + "line": 62, + "kind": "jsx-text", + "text": "Total Records" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-sorted-by-points/page.tsx", + "line": 77, + "kind": "jsx-text", + "text": "Don't have any qualified customers." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-with-rewards-claimed-not-redeemed/page.tsx", + "line": 43, + "kind": "jsx-text", + "text": "Loading data..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-with-rewards-claimed-not-redeemed/page.tsx", + "line": 62, + "kind": "jsx-text", + "text": "Total Records" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-with-rewards-claimed-not-redeemed/page.tsx", + "line": 77, + "kind": "jsx-text", + "text": "Don't have any qualified customers." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-common-time-members-join-program/page.tsx", + "line": 24, + "kind": "jsx-text", + "text": "* Click on a column to download the list of customers" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-common-time-members-join-program/page.tsx", + "line": 36, + "kind": "jsx-text", + "text": "Loading data..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-common-time-members-use-program/page.tsx", + "line": 37, + "kind": "jsx-text", + "text": "Loading data..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-popular-rewards-period/page.tsx", + "line": 37, + "kind": "jsx-text", + "text": "Loading data..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-rewards-redeemed/form.tsx", + "line": 29, + "kind": "jsx-text", + "text": "count of offers" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-rewards-redeemed/page.tsx", + "line": 41, + "kind": "jsx-text", + "text": "Loading data..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-rewards-redeemed/page.tsx", + "line": 68, + "kind": "jsx-text", + "text": "Don't have any qualified offers." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-rewards/page.tsx", + "line": 42, + "kind": "jsx-text", + "text": "Loading data..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-rewards/page.tsx", + "line": 61, + "kind": "jsx-text", + "text": "Total Records" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-rewards/page.tsx", + "line": 75, + "kind": "jsx-text", + "text": "Don't have any qualified offers." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/rules/price/new/page.tsx", + "line": 16, + "kind": "jsx-text", + "text": "New Price Rule" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 71, + "kind": "jsx-text", + "text": "Pass Type" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 88, + "kind": "jsx-text", + "text": "Discount Amount & Conditions" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 91, + "kind": "jsx-text", + "text": "Discount Type" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 96, + "kind": "jsx-text", + "text": "Fixed amount" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 103, + "kind": "jsx-text", + "text": "Discount Value" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 113, + "kind": "attr:helpertext", + "text": "Discount amount or percentage (0.00-1.00)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 121, + "kind": "jsx-text", + "text": "WARNING: The discount percentage value is" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 121, + "kind": "jsx-text", + "text": "%. Ensure this is the intended discount rate." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 131, + "kind": "jsx-text", + "text": "Currency Code" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 134, + "kind": "attr:helpertext", + "text": "For discount amount display" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 146, + "kind": "jsx-text", + "text": "Apply Condition" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 150, + "kind": "jsx-text", + "text": "No condition applied" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 151, + "kind": "jsx-text", + "text": "The value of the most expensive item" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 152, + "kind": "jsx-text", + "text": "The value of the cheapest item" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 158, + "kind": "jsx-text", + "text": "Special Categories" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 167, + "kind": "jsx-text", + "text": "Separate multiple categories by comma ","" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 169, + "kind": "jsx-text", + "text": "If there are many selected products in these categories, only one product which…" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 179, + "kind": "jsx-text", + "text": "Special Products" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 188, + "kind": "jsx-text", + "text": "Separate multiple products by comma ","" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 190, + "kind": "jsx-text", + "text": "If there are many selected products, only one product which satisfies the apply…" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 197, + "kind": "jsx-text", + "text": "Points & Limitations" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 207, + "kind": "jsx-text", + "text": "Issue Before Birthday (hours)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 216, + "kind": "attr:helpertext", + "text": "The voucher will be issued this many hours before the customer’s birthday." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 225, + "kind": "jsx-text", + "text": "Expires In (hours)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 235, + "kind": "attr:helpertext", + "text": "The voucher will expire after this duration (in hours) from the time it is issu…" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 241, + "kind": "jsx-text", + "text": "Limit per Customer" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 262, + "kind": "jsx-text", + "text": "How many times a customer can get this voucher." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 267, + "kind": "jsx-text", + "text": "Availability Count" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 288, + "kind": "jsx-text", + "text": "Restrict how often this voucher can be claimed in total by all customers." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 293, + "kind": "jsx-text", + "text": "Require Fields" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx", + "line": 314, + "kind": "jsx-text", + "text": "The customer must complete all required fields to receive the voucher." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/page.tsx", + "line": 60, + "kind": "jsx-text", + "text": "Pass Type" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/page.tsx", + "line": 68, + "kind": "jsx-text", + "text": "Discount Amount" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/page.tsx", + "line": 78, + "kind": "jsx-text", + "text": "Apply Condition" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/page.tsx", + "line": 84, + "kind": "jsx-text", + "text": "Start Date" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/page.tsx", + "line": 92, + "kind": "jsx-text", + "text": "End Date" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/page.tsx", + "line": 100, + "kind": "jsx-text", + "text": "Limit per Customer" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/page.tsx", + "line": 106, + "kind": "jsx-text", + "text": "Availability Count" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/forms.tsx", + "line": 24, + "kind": "jsx-text", + "text": "Are you sure you want to delete this voucher?" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/forms.tsx", + "line": 28, + "kind": "jsx-text", + "text": "No, cancel" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/forms.tsx", + "line": 31, + "kind": "jsx-text", + "text": "Yes, confirm delete" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/forms.tsx", + "line": 60, + "kind": "attr:helpertext", + "text": "Multiple emails can be separated by commas." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 66, + "kind": "jsx-text", + "text": "Pass Type" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 83, + "kind": "jsx-text", + "text": "Discount Amount & Conditions" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 86, + "kind": "jsx-text", + "text": "Discount Type" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 91, + "kind": "jsx-text", + "text": "Fixed amount" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 98, + "kind": "jsx-text", + "text": "Discount Value" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 108, + "kind": "attr:helpertext", + "text": "Discount amount or percentage (0.00-1.00)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 116, + "kind": "jsx-text", + "text": "WARNING: The discount percentage value is" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 116, + "kind": "jsx-text", + "text": "%. Ensure this is the intended discount rate." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 126, + "kind": "jsx-text", + "text": "Currency Code" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 129, + "kind": "attr:helpertext", + "text": "For discount amount display" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 141, + "kind": "jsx-text", + "text": "Apply Condition" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 145, + "kind": "jsx-text", + "text": "No condition applied" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 146, + "kind": "jsx-text", + "text": "The value of the most expensive item" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 147, + "kind": "jsx-text", + "text": "The value of the cheapest item" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 153, + "kind": "jsx-text", + "text": "Special Categories" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 162, + "kind": "jsx-text", + "text": "Separate multiple categories by comma ","" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 164, + "kind": "jsx-text", + "text": "If there are many selected products in these categories, only one product which…" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 174, + "kind": "jsx-text", + "text": "Special Products" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 183, + "kind": "jsx-text", + "text": "Separate multiple products by comma ","" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 185, + "kind": "jsx-text", + "text": "If there are many selected products, only one product which satisfies the apply…" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 202, + "kind": "jsx-text", + "text": "Issue Before Birthday (hours)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 211, + "kind": "attr:helpertext", + "text": "The voucher will be issued this many hours before the customer’s birthday." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 220, + "kind": "jsx-text", + "text": "Expires In (hours)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 230, + "kind": "attr:helpertext", + "text": "The voucher will expire after this duration (in hours) from the time it is issu…" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 236, + "kind": "jsx-text", + "text": "Limit per Customer" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 257, + "kind": "jsx-text", + "text": "How many times a customer can get this voucher." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 262, + "kind": "jsx-text", + "text": "Availability Count" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 283, + "kind": "jsx-text", + "text": "Restrict how often this voucher can be claimed in total by all customers." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 288, + "kind": "jsx-text", + "text": "Require Fields" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx", + "line": 309, + "kind": "jsx-text", + "text": "The customer must complete all required fields to receive the voucher." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/page.tsx", + "line": 15, + "kind": "jsx-text", + "text": "New Pass" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 82, + "kind": "jsx-text", + "text": "Loyalty Program" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 96, + "kind": "attr:helpertext", + "text": "Short promotional teaser for your program" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 105, + "kind": "jsx-text", + "text": "Select country" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 124, + "kind": "attr:helpertext", + "text": "The icon needs to be of square format" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 151, + "kind": "jsx-text", + "text": "Pass Type ID" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 172, + "kind": "jsx-text", + "text": "Program Type" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 177, + "kind": "jsx-text", + "text": "Gift Card" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 213, + "kind": "jsx-text", + "text": "Stamp Card" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 237, + "kind": "jsx-text", + "text": "Tiered Membership" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 261, + "kind": "jsx-text", + "text": "Special Offers" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 271, + "kind": "jsx-text", + "text": "Choose from one of our ready made schemes above or build your own custom loyalt…" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 276, + "kind": "jsx-text", + "text": "Points for Activities" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 278, + "kind": "jsx-text", + "text": "Reward your customer points for certain activities" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 283, + "kind": "jsx-text", + "text": "Add Email" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 289, + "kind": "jsx-text", + "text": "Add Phone Number" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 295, + "kind": "jsx-text", + "text": "Install Pass" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 301, + "kind": "jsx-text", + "text": "Pass Scanned" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 311, + "kind": "jsx-text", + "text": "Chose dynamic if you want to reward a custom number of points for each scan." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 316, + "kind": "jsx-text", + "text": "Customer Referral" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 320, + "kind": "jsx-text", + "text": "points after referred customer has" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 326, + "kind": "jsx-text", + "text": "Contact & Legal" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 329, + "kind": "jsx-text", + "text": "Company Name" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 332, + "kind": "attr:helpertext", + "text": "Legal name of your business" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 363, + "kind": "jsx-text", + "text": "Terms & Conditions" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 366, + "kind": "attr:helpertext", + "text": "You can specify terms for your loyalty program" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 370, + "kind": "jsx-text", + "text": "Scanning & Redemption" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 382, + "kind": "jsx-text", + "text": "Browser: I want to use another scanning solution that can open URLs." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 386, + "kind": "jsx-text", + "text": "None: I don't need to scan the cards." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 392, + "kind": "jsx-text", + "text": "Points Change Message" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 399, + "kind": "jsx-text", + "text": "Disable Message" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 403, + "kind": "jsx-text", + "text": "The default message is" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 403, + "kind": "jsx-text", + "text": ""New points: %@"" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 409, + "kind": "jsx-text", + "text": "Point Names" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 417, + "kind": "jsx-text", + "text": "The default values are One:" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 417, + "kind": "jsx-text", + "text": ""Point"" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 417, + "kind": "jsx-text", + "text": ""Points"" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 422, + "kind": "jsx-text", + "text": "Customer Fields" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 425, + "kind": "jsx-text", + "text": "Required Fields" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 455, + "kind": "jsx-text", + "text": "Display Name" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 518, + "kind": "attr:helpertext", + "text": "Enter available options as comma separated values" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx", + "line": 553, + "kind": "jsx-text", + "text": "Add Field" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/programs/new/page.tsx", + "line": 22, + "kind": "jsx-text", + "text": "New Program" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 85, + "kind": "jsx-text", + "text": "Hang tight! We’re getting everything ready for you..." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 90, + "kind": "jsx-text", + "text": "Please go to Shopify Admin and enable the Customer Accounts option to use full …" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 101, + "kind": "jsx-text", + "text": "It looks like you've uninstalled the Loyalty Dog app. We're sad to se…" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 106, + "kind": "jsx-text", + "text": "Reinstall Loyalty Dog" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 112, + "kind": "jsx-text", + "text": "You uninstalled the Loyalty Dog app." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 123, + "kind": "jsx-text", + "text": "Please accept the application charge to active your account and use full functi…" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 127, + "kind": "jsx-text", + "text": "Active charge" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 133, + "kind": "jsx-text", + "text": "Incomplete application charge" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 144, + "kind": "jsx-text", + "text": "By default, app embed blocks are deactivated after an app is installed." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 146, + "kind": "jsx-text", + "text": "You need to activate app embed blocks in the theme editor to enable the widget." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 154, + "kind": "jsx-text", + "text": "Activate widget" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 157, + "kind": "jsx-text", + "text": "Recheck status" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 163, + "kind": "jsx-text", + "text": "Rewards widget inactivated" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 220, + "kind": "jsx-text", + "text": "Loyalty Program" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 232, + "kind": "jsx-text", + "text": "You have to configure the loyalty program to use full functionality." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 233, + "kind": "jsx-text", + "text": "You can't change the program after saved!" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 237, + "kind": "jsx-text", + "text": "Add new program" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 244, + "kind": "jsx-text", + "text": "Select program" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 253, + "kind": "attr:title", + "text": "Sync" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 263, + "kind": "jsx-text", + "text": "Sync all Shopify customers" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 270, + "kind": "jsx-text", + "text": "Reward customers when the financial status is one of" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 288, + "kind": "jsx-text", + "text": "Cancel rewards when the financial status is one of" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 306, + "kind": "jsx-text", + "text": "Reward customers for shopping" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 310, + "kind": "jsx-text", + "text": "Online and in-store (POS)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 314, + "kind": "jsx-text", + "text": "Shop Online only" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 318, + "kind": "jsx-text", + "text": "In-store only (POS)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 323, + "kind": "jsx-text", + "text": "Reward customers for the following parts of an order" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 331, + "kind": "jsx-text", + "text": "Exclude coupon discounts used" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 335, + "kind": "jsx-text", + "text": "Exclude taxes" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 339, + "kind": "jsx-text", + "text": "Exclude shipping" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 386, + "kind": "jsx-text", + "text": "Sign Up" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 401, + "kind": "jsx-text", + "text": "Points awarded" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 415, + "kind": "jsx-text", + "text": "Celebrate a birthday" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 430, + "kind": "jsx-text", + "text": "Points awarded" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 444, + "kind": "jsx-text", + "text": "Place first order" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 459, + "kind": "jsx-text", + "text": "Points awarded" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 485, + "kind": "jsx-text", + "text": "Like on Facebook" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 492, + "kind": "jsx-text", + "text": "Facebook page URL" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 500, + "kind": "jsx-text", + "text": "Points awarded" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 506, + "kind": "jsx-text", + "text": "Share to Facebook" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 513, + "kind": "jsx-text", + "text": "URL to share" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 521, + "kind": "jsx-text", + "text": "Points awarded" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 547, + "kind": "jsx-text", + "text": "Follow on Twitter" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 554, + "kind": "jsx-text", + "text": "Twitter username" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 562, + "kind": "jsx-text", + "text": "Points awarded" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 568, + "kind": "jsx-text", + "text": "Share to Twitter" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 575, + "kind": "jsx-text", + "text": "URL to share" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 589, + "kind": "jsx-text", + "text": "Points awarded" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 611, + "kind": "jsx-text", + "text": "Follow on Instagram" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 618, + "kind": "jsx-text", + "text": "Instagram username" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 626, + "kind": "jsx-text", + "text": "Points awarded" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 664, + "kind": "jsx-text", + "text": "Order Discounts" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 665, + "kind": "jsx-text", + "text": "Product Discounts" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 666, + "kind": "jsx-text", + "text": "Shipping Discounts" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 683, + "kind": "attr:title", + "text": "View" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 692, + "kind": "attr:title", + "text": "Configure" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 709, + "kind": "jsx-text", + "text": "No offers found." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 716, + "kind": "jsx-text", + "text": "Edit Options for" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 719, + "kind": "jsx-text", + "text": "Combines with" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 737, + "kind": "jsx-text", + "text": "Order Discounts" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 756, + "kind": "jsx-text", + "text": "Product Discounts" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 775, + "kind": "jsx-text", + "text": "Shipping Discounts" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1092, + "kind": "jsx-text", + "text": "Show the Rewards button on the Online Store" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1095, + "kind": "jsx-text", + "text": "Trigger mode" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1099, + "kind": "jsx-text", + "text": "Manual (Advance user)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1102, + "kind": "jsx-text", + "text": "Manual: hide the launcher button; you can show the panel via JS methods of" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1107, + "kind": "jsx-text", + "text": "Button position" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1119, + "kind": "jsx-text", + "text": "Edge margin (px)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1146, + "kind": "jsx-text", + "text": "Launcher Button" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1152, + "kind": "jsx-text", + "text": "Button width (px)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1158, + "kind": "jsx-text", + "text": "Button height (px)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1164, + "kind": "jsx-text", + "text": "Border radius (px)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1172, + "kind": "jsx-text", + "text": "Background color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1178, + "kind": "jsx-text", + "text": "Pulse color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1186, + "kind": "jsx-text", + "text": "Enable pulse animation when active offer available" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1190, + "kind": "jsx-text", + "text": "Show button text" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1193, + "kind": "jsx-text", + "text": "Button text" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1200, + "kind": "jsx-text", + "text": "Text size (px)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1206, + "kind": "jsx-text", + "text": "Text color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1214, + "kind": "jsx-text", + "text": "Show button icon" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1218, + "kind": "jsx-text", + "text": "Icon size (px)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1224, + "kind": "jsx-text", + "text": "Icon color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1231, + "kind": "jsx-text", + "text": "Custom icon url" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1241, + "kind": "jsx-text", + "text": "Widget Theme" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1248, + "kind": "jsx-text", + "text": "Panel width (px)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1254, + "kind": "jsx-text", + "text": "Panel height (px)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1260, + "kind": "jsx-text", + "text": "Background color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1268, + "kind": "jsx-text", + "text": "Border width (px)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1274, + "kind": "jsx-text", + "text": "Border radius (px)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1280, + "kind": "jsx-text", + "text": "Border color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1289, + "kind": "jsx-text", + "text": "Border width (px)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1295, + "kind": "jsx-text", + "text": "Border radius (px)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1301, + "kind": "jsx-text", + "text": "Border color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1307, + "kind": "jsx-text", + "text": "Background color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1313, + "kind": "jsx-text", + "text": "Banner image / gradient" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1316, + "kind": "jsx-text", + "text": "Primary color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1322, + "kind": "jsx-text", + "text": "Secondary color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1328, + "kind": "jsx-text", + "text": "Text color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1336, + "kind": "jsx-text", + "text": "Banner height" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1343, + "kind": "jsx-text", + "text": "% of panel height" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1349, + "kind": "jsx-text", + "text": "Content padding (px)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1360, + "kind": "jsx-text", + "text": "Banner image url" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1363, + "kind": "jsx-text", + "text": "Tip: should use a small and light image to improve the loading speed." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1369, + "kind": "jsx-text", + "text": "Text color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1375, + "kind": "jsx-text", + "text": "Border radius (px)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1384, + "kind": "jsx-text", + "text": "Primary color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1390, + "kind": "jsx-text", + "text": "Success color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1396, + "kind": "jsx-text", + "text": "Error color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1402, + "kind": "jsx-text", + "text": "Divider color" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1504, + "kind": "jsx-text", + "text": "Widget Text" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1532, + "kind": "jsx-text", + "text": "Advanced Styling" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1536, + "kind": "jsx-text", + "text": "You can add custom css using prefix class name to styling our widget." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1538, + "kind": "jsx-text", + "text": "Custom styles" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1542, + "kind": "jsx-text", + "text": "Custom root styles (widget size, position)" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1545, + "kind": "jsx-text", + "text": "Caution: This style will be injected to your site." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1597, + "kind": "attr:title", + "text": "Reset" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx", + "line": 1602, + "kind": "jsx-text", + "text": "Reset to Defaults" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/page.tsx", + "line": 34, + "kind": "jsx-text", + "text": "Your current authorization does not include the" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/page.tsx", + "line": 34, + "kind": "jsx-text", + "text": "write_discounts" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/page.tsx", + "line": 34, + "kind": "jsx-text", + "text": "scope. Discount features such as offer combination options will use the legacy …" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/page.tsx", + "line": 39, + "kind": "jsx-text", + "text": "Re-authorize" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/page.tsx", + "line": 45, + "kind": "jsx-text", + "text": "Missing "write_discounts" scope" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/page.tsx", + "line": 54, + "kind": "attr:title", + "text": "Settings" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/page.tsx", + "line": 57, + "kind": "attr:title", + "text": "Actions" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/page.tsx", + "line": 60, + "kind": "attr:title", + "text": "Offers" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/page.tsx", + "line": 63, + "kind": "attr:title", + "text": "Widget" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/ActionsForm.tsx", + "line": 46, + "kind": "jsx-text", + "text": "Enable points for purchases" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/ActionsForm.tsx", + "line": 66, + "kind": "jsx-text", + "text": "Sign-up Bonus" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/ActionsForm.tsx", + "line": 69, + "kind": "jsx-text", + "text": "Award bonus points on sign-up" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/ActionsForm.tsx", + "line": 89, + "kind": "jsx-text", + "text": "Save Actions" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/OffersForm.tsx", + "line": 28, + "kind": "jsx-text", + "text": "No loyalty program linked." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/OffersForm.tsx", + "line": 28, + "kind": "jsx-text", + "text": "Go to the" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/OffersForm.tsx", + "line": 28, + "kind": "jsx-text", + "text": "tab to link a loyalty program. Once linked, offers from that program will be av…" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/OffersForm.tsx", + "line": 38, + "kind": "jsx-text", + "text": "Manage offers on the" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/OffersForm.tsx", + "line": 40, + "kind": "jsx-text", + "text": "Offers page" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/OffersForm.tsx", + "line": 50, + "kind": "jsx-text", + "text": "No active offers yet. Create one on the Offers page to make it available for re…" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/SettingsForm.tsx", + "line": 40, + "kind": "jsx-text", + "text": "— No program linked —" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/SettingsForm.tsx", + "line": 47, + "kind": "jsx-text", + "text": "Link a loyalty program to enable points earning and offer redemption." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/SettingsForm.tsx", + "line": 51, + "kind": "jsx-text", + "text": "Save Settings" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/WidgetForm.tsx", + "line": 38, + "kind": "jsx-text", + "text": "Plugin not yet initialized." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/WidgetForm.tsx", + "line": 38, + "kind": "jsx-text", + "text": "Complete the plugin activation on your WordPress site before configuring widget…" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/WidgetForm.tsx", + "line": 46, + "kind": "jsx-text", + "text": "The LoyaltyDog widget can be placed in your WordPress site using any of the fol…" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/page.tsx", + "line": 22, + "kind": "jsx-text", + "text": "Plugin not yet activated. Visit your WordPress admin to complete setup." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/page.tsx", + "line": 23, + "kind": "jsx-text", + "text": "No loyalty program linked. Select a program in the Settings tab." + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/page.tsx", + "line": 25, + "kind": "attr:title", + "text": "Settings" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/page.tsx", + "line": 28, + "kind": "attr:title", + "text": "Actions" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/page.tsx", + "line": 31, + "kind": "attr:title", + "text": "Offers" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/page.tsx", + "line": 34, + "kind": "attr:title", + "text": "Widget" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/page.tsx", + "line": 18, + "kind": "jsx-text", + "text": "No WordPress Sites Connected" + }, + { + "file": "src/app/(cpanel)/(user)/loyalty/wordpress/page.tsx", + "line": 19, + "kind": "jsx-text", + "text": "Install the LoyaltyDog plugin on your WordPress / WooCommerce site to get start…" + }, + { + "file": "src/app/(cpanel)/(user)/profile/page.tsx", + "line": 20, + "kind": "attr:title", + "text": "Update" + }, + { + "file": "src/app/(cpanel)/(user)/profile/page.tsx", + "line": 45, + "kind": "jsx-text", + "text": "Email address" + }, + { + "file": "src/app/(cpanel)/(user)/profile/page.tsx", + "line": 51, + "kind": "jsx-text", + "text": "Phone number" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/form.tsx", + "line": 42, + "kind": "jsx-text", + "text": "First Name" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/form.tsx", + "line": 50, + "kind": "jsx-text", + "text": "Last Name" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/form.tsx", + "line": 74, + "kind": "jsx-text", + "text": "Favorite Amount Spent" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/form.tsx", + "line": 151, + "kind": "jsx-text", + "text": "Current password" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/form.tsx", + "line": 159, + "kind": "jsx-text", + "text": "New password" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/form.tsx", + "line": 167, + "kind": "jsx-text", + "text": "Confirm password" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/form.tsx", + "line": 174, + "kind": "jsx-text", + "text": "Password requirements:" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/form.tsx", + "line": 175, + "kind": "jsx-text", + "text": "Ensure that these requirements are met:" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/form.tsx", + "line": 177, + "kind": "jsx-text", + "text": "At least 10 characters (and up to 100 characters)" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/form.tsx", + "line": 178, + "kind": "jsx-text", + "text": "At least one lowercase character" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/form.tsx", + "line": 179, + "kind": "jsx-text", + "text": "Inclusion of at least one special character, e.g., ! @ # ?" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/form.tsx", + "line": 180, + "kind": "jsx-text", + "text": "Some text here zoltan" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/page.tsx", + "line": 17, + "kind": "attr:title", + "text": "General information" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/page.tsx", + "line": 20, + "kind": "attr:title", + "text": "Password information" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/page.tsx", + "line": 23, + "kind": "attr:title", + "text": "Language & Time" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/page.tsx", + "line": 24, + "kind": "attr:title", + "text": "Social accounts" + }, + { + "file": "src/app/(cpanel)/(user)/profile/settings/page.tsx", + "line": 25, + "kind": "attr:title", + "text": "Alerts & Notifications" + }, + { + "file": "src/app/(cpanel)/(user)/programs/new/form.tsx", + "line": 29, + "kind": "jsx-text", + "text": "Program Type" + }, + { + "file": "src/app/(cpanel)/(user)/programs/new/form.tsx", + "line": 34, + "kind": "jsx-text", + "text": "Gift Card" + }, + { + "file": "src/app/(cpanel)/(user)/programs/new/page.tsx", + "line": 19, + "kind": "jsx-text", + "text": "New Program" + }, + { + "file": "src/app/(marketing)/_components/CookieConsentBanner.tsx", + "line": 58, + "kind": "attr:aria-label", + "text": "Cookie consent" + }, + { + "file": "src/app/(marketing)/_components/LanguageSwitcher.tsx", + "line": 28, + "kind": "attr:aria-label", + "text": "Language" + }, + { + "file": "src/app/(mobile)/l/[programSid]/form.tsx", + "line": 51, + "kind": "jsx-text", + "text": "Enter Your Details" + }, + { + "file": "src/app/(mobile)/l/[programSid]/form.tsx", + "line": 54, + "kind": "jsx-text", + "text": "First Name" + }, + { + "file": "src/app/(mobile)/l/[programSid]/form.tsx", + "line": 60, + "kind": "jsx-text", + "text": "Last Name" + }, + { + "file": "src/app/(mobile)/l/[programSid]/form.tsx", + "line": 66, + "kind": "jsx-text", + "text": "Email Address" + }, + { + "file": "src/app/(mobile)/l/[programSid]/form.tsx", + "line": 72, + "kind": "jsx-text", + "text": "Cell Number" + }, + { + "file": "src/app/(mobile)/l/[programSid]/form.tsx", + "line": 113, + "kind": "jsx-text", + "text": "I accept the" + }, + { + "file": "src/app/(mobile)/l/[programSid]/form.tsx", + "line": 115, + "kind": "jsx-text", + "text": "Terms and Conditions" + }, + { + "file": "src/app/(mobile)/l/[programSid]/form.tsx", + "line": 122, + "kind": "jsx-text", + "text": "Terms & Conditions" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/offers/[offerSid]/form.tsx", + "line": 18, + "kind": "jsx-text", + "text": "You currently have an active offer, if you confirm, your active offer will be r…" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/offers/[offerSid]/form.tsx", + "line": 45, + "kind": "jsx-text", + "text": "Back to Pass" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/offers/[offerSid]/form.tsx", + "line": 51, + "kind": "jsx-text", + "text": "Your Personal Offer" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/offers/[offerSid]/form.tsx", + "line": 53, + "kind": "jsx-text", + "text": "You will be notified when it's ready." + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/offers/[offerSid]/form.tsx", + "line": 55, + "kind": "jsx-text", + "text": "You can close this window" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/offers/page.tsx", + "line": 25, + "kind": "attr:title", + "text": "Back to Pass" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/offers/page.tsx", + "line": 26, + "kind": "jsx-text", + "text": "Back to Pass" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/offers/page.tsx", + "line": 34, + "kind": "jsx-text", + "text": "Your Active Offer" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/offers/page.tsx", + "line": 47, + "kind": "jsx-text", + "text": "No new offers." + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/profile/form.tsx", + "line": 39, + "kind": "jsx-text", + "text": "Enter Your Details" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/profile/form.tsx", + "line": 41, + "kind": "attr:placeholder", + "text": "First Name" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/profile/form.tsx", + "line": 44, + "kind": "attr:placeholder", + "text": "Last Name" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/profile/form.tsx", + "line": 47, + "kind": "attr:placeholder", + "text": "Email Address" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/profile/form.tsx", + "line": 50, + "kind": "attr:placeholder", + "text": "Phone Number" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/profile/page.tsx", + "line": 18, + "kind": "attr:title", + "text": "Back to Pass" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/profile/page.tsx", + "line": 19, + "kind": "jsx-text", + "text": "Back to Pass" + }, + { + "file": "src/app/(mobile)/l/c/[customerSid]/profile/page.tsx", + "line": 25, + "kind": "jsx-text", + "text": "Your Profile" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 36, + "kind": "attr:alt", + "text": "Add to Apple Wallet" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 41, + "kind": "attr:alt", + "text": "Add to Google Wallet" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 44, + "kind": "attr:alt", + "text": "Add to Wallet Passes" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 50, + "kind": "attr:alt", + "text": "Add to Apple Wallet" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 53, + "kind": "attr:alt", + "text": "Add to Google Wallet" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 83, + "kind": "jsx-text", + "text": "You pass was sent." + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 112, + "kind": "jsx-text", + "text": "Enter Your Details" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 266, + "kind": "jsx-text", + "text": "Pass Added" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 267, + "kind": "attr:title", + "text": "Open Wallet" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 268, + "kind": "jsx-text", + "text": "Open Wallet" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 271, + "kind": "jsx-text", + "text": "Show Preview" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 279, + "kind": "jsx-text", + "text": "Pass Not Loaded?" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 282, + "kind": "attr:title", + "text": "Download Pass" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 283, + "kind": "jsx-text", + "text": "Download Pass" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 285, + "kind": "attr:title", + "text": "Open Wallet" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 286, + "kind": "jsx-text", + "text": "Open Wallet" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 292, + "kind": "attr:alt", + "text": "Add to Google Wallet" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 294, + "kind": "jsx-text", + "text": "If you don't have a Wallet app, please download it now:" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 298, + "kind": "attr:title", + "text": "Install WalletPasses" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 301, + "kind": "attr:alt", + "text": "Add to WalletPasses" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 303, + "kind": "jsx-text", + "text": "Your pass will automatically load after you open the app after installation." + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 307, + "kind": "jsx-text", + "text": "Show Preview" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 318, + "kind": "attr:title", + "text": "auto download" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 422, + "kind": "jsx-text", + "text": "Verifying session…" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 452, + "kind": "jsx-text", + "text": "Scan this QR Code on your smartphone to download the pass." + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 465, + "kind": "jsx-text", + "text": "Email the pass to your mobile device." + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 483, + "kind": "jsx-text", + "text": "Print this pass if you don't want to use your mobile device." + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 697, + "kind": "jsx-text", + "text": "How would you like to verify?" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 719, + "kind": "jsx-text", + "text": "Get a code by email" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 742, + "kind": "jsx-text", + "text": "SMS / Phone" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 743, + "kind": "jsx-text", + "text": "Get a code by text message" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 785, + "kind": "jsx-text", + "text": "We'll send a 6-digit verification code to your" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 807, + "kind": "jsx-text", + "text": "Code sent to" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 811, + "kind": "jsx-text", + "text": "Dev mode — code:" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 850, + "kind": "jsx-text", + "text": "← Change method" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 875, + "kind": "jsx-text", + "text": "Activate Your Card" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 877, + "kind": "jsx-text", + "text": "Enter the 6-digit security code printed on your gift card to activate it and lo…" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 900, + "kind": "jsx-text", + "text": "Security Code (6 digits)" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 927, + "kind": "jsx-text", + "text": "Activate Gift Card" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 929, + "kind": "jsx-text", + "text": "The security code is printed on the back or sticker of your physical card." + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 941, + "kind": "jsx-text", + "text": "← Start over" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 953, + "kind": "jsx-text", + "text": "Activating your gift card…" + }, + { + "file": "src/app/(mobile)/p/[passSid]/forms.tsx", + "line": 966, + "kind": "jsx-text", + "text": "Loading your pass…" + }, + { + "file": "src/app/(mobile)/p/[passSid]/page.tsx", + "line": 90, + "kind": "jsx-text", + "text": "The pass is only available on mobile device, please use your smartphone to scan…" + }, + { + "file": "src/app/(mobile)/p/[passSid]/page.tsx", + "line": 103, + "kind": "jsx-text", + "text": "You can email the pass to your email address so that you can open it on your mo…" + }, + { + "file": "src/app/(mobile)/p/[passSid]/page.tsx", + "line": 121, + "kind": "jsx-text", + "text": "Print this pass if you don't want to use your mobile device" + }, + { + "file": "src/app/(mobile)/s/[passSid]/status/forms.tsx", + "line": 136, + "kind": "jsx-text", + "text": "Points added:" + }, + { + "file": "src/app/(mobile)/s/[passSid]/status/forms.tsx", + "line": 137, + "kind": "jsx-text", + "text": "New points:" + }, + { + "file": "src/app/(mobile)/t/[templateSid]/form.tsx", + "line": 38, + "kind": "jsx-text", + "text": "Enter Your Details" + }, + { + "file": "src/app/(mobile)/t/[templateSid]/page.tsx", + "line": 21, + "kind": "jsx-text", + "text": "Not available" + }, + { + "file": "src/app/(mobile)/t/[templateSid]/page.tsx", + "line": 23, + "kind": "jsx-text", + "text": "Distribution of this pass has been stopped by the pass issuer." + }, + { + "file": "src/app/(mobile)/t/[templateSid]/page.tsx", + "line": 41, + "kind": "jsx-text", + "text": "Existing Pass" + }, + { + "file": "src/app/(mobile)/t/[templateSid]/page.tsx", + "line": 42, + "kind": "jsx-text", + "text": "You have already created a pass." + }, + { + "file": "src/app/(mobile)/t/[templateSid]/page.tsx", + "line": 45, + "kind": "jsx-text", + "text": "Open exist Pass" + }, + { + "file": "src/app/error.tsx", + "line": 26, + "kind": "jsx-text", + "text": "Something went wrong! We are already working to solve the problem." + }, + { + "file": "src/app/error.tsx", + "line": 30, + "kind": "jsx-text", + "text": "Try again" + }, + { + "file": "src/app/error.tsx", + "line": 33, + "kind": "jsx-text", + "text": "Return Home" + }, + { + "file": "src/app/error/page.tsx", + "line": 22, + "kind": "jsx-text", + "text": "Return Home" + }, + { + "file": "src/app/not-found.tsx", + "line": 12, + "kind": "jsx-text", + "text": "Whoops! That page doesn't exist." + }, + { + "file": "src/app/not-found.tsx", + "line": 18, + "kind": "jsx-text", + "text": "Return Home" + }, + { + "file": "src/app/test-ai-advisor/page.tsx", + "line": 92, + "kind": "jsx-text", + "text": "🧪 AI Advisor - Test Mode" + }, + { + "file": "src/app/test-ai-advisor/page.tsx", + "line": 93, + "kind": "jsx-text", + "text": "This is a mock version for testing UI changes. Try asking:" + }, + { + "file": "src/app/test-ai-advisor/page.tsx", + "line": 95, + "kind": "jsx-text", + "text": ""Show me a table analysis" - to test table pagination" + }, + { + "file": "src/app/test-ai-advisor/page.tsx", + "line": 96, + "kind": "jsx-text", + "text": ""How is my program performing?" - to test heading hierarchy" + }, + { + "file": "src/app/test-ai-advisor/page.tsx", + "line": 97, + "kind": "jsx-text", + "text": "Any message - to test auto-scroll behavior" + }, + { + "file": "src/components/AIAdvisorChat.tsx", + "line": 270, + "kind": "jsx-text", + "text": "🐕 Loyalty Dog AI Business Advisor" + }, + { + "file": "src/components/AIAdvisorChat.tsx", + "line": 275, + "kind": "jsx-text", + "text": "Total Transactions" + }, + { + "file": "src/components/AIAdvisorChat.tsx", + "line": 282, + "kind": "jsx-text", + "text": "Member Participation" + }, + { + "file": "src/components/AIAdvisorChat.tsx", + "line": 289, + "kind": "jsx-text", + "text": "Total Revenue" + }, + { + "file": "src/components/AIAdvisorChat.tsx", + "line": 367, + "kind": "jsx-text", + "text": "💾 Your Download" + }, + { + "file": "src/components/AIAdvisorChat.tsx", + "line": 413, + "kind": "jsx-text", + "text": "💡 Try asking:" + }, + { + "file": "src/components/AIAdvisorChat.tsx", + "line": 444, + "kind": "attr:placeholder", + "text": "Ask about your loyalty program..." + }, + { + "file": "src/components/CropModal.tsx", + "line": 36, + "kind": "attr:alt", + "text": "Crop Modal Image" + }, + { + "file": "src/components/design-tabs/FieldForm.tsx", + "line": 317, + "kind": "jsx-text", + "text": "Normal Field" + }, + { + "file": "src/components/design-tabs/FieldForm.tsx", + "line": 424, + "kind": "jsx-text", + "text": "Pass ID" + }, + { + "file": "src/components/design-tabs/FieldForm.tsx", + "line": 425, + "kind": "jsx-text", + "text": "Pass URL" + }, + { + "file": "src/components/design-tabs/FieldForm.tsx", + "line": 426, + "kind": "jsx-text", + "text": "Template URL" + }, + { + "file": "src/components/design-tabs/FieldForm.tsx", + "line": 427, + "kind": "jsx-text", + "text": "Scan URL" + }, + { + "file": "src/components/design-tabs/PlaceholderTab.tsx", + "line": 186, + "kind": "jsx-text", + "text": "6fed93a1-4214-44a5-bae2-a7eefd7a29af" + }, + { + "file": "src/components/design-tabs/PlaceholderTab.tsx", + "line": 192, + "kind": "jsx-text", + "text": "b-2ToUIURKW64qfu_Xoprw" + }, + { + "file": "src/components/design-tabs/StripTab.tsx", + "line": 138, + "kind": "attr:title", + "text": "Background" + }, + { + "file": "src/components/layouts/Navbar.tsx", + "line": 49, + "kind": "attr:alt", + "text": "LoyaltyDog Logo" + }, + { + "file": "src/components/layouts/Navbar.tsx", + "line": 50, + "kind": "attr:alt", + "text": "LoyaltyDog Logo" + }, + { + "file": "src/components/layouts/Navbar.tsx", + "line": 51, + "kind": "attr:alt", + "text": "LoyaltyDog Logo" + }, + { + "file": "src/components/layouts/Navbar.tsx", + "line": 63, + "kind": "attr:alt", + "text": "User settings" + }, + { + "file": "src/components/layouts/Navbar.tsx", + "line": 104, + "kind": "attr:alt", + "text": "LoyaltyDog Logo" + }, + { + "file": "src/components/layouts/Navbar.tsx", + "line": 105, + "kind": "attr:alt", + "text": "LoyaltyDog Logo" + }, + { + "file": "src/components/layouts/Navbar.tsx", + "line": 116, + "kind": "attr:alt", + "text": "User settings" + }, + { + "file": "src/components/layouts/Sidebar.tsx", + "line": 202, + "kind": "attr:label", + "text": "WordPress" + }, + { + "file": "src/components/layouts/Sidebar.tsx", + "line": 339, + "kind": "jsx-text", + "text": "AI Settings" + }, + { + "file": "src/components/PaginatedTable.tsx", + "line": 71, + "kind": "attr:aria-label", + "text": "Previous page" + }, + { + "file": "src/components/PaginatedTable.tsx", + "line": 85, + "kind": "attr:aria-label", + "text": "Next page" + } + ] +} \ No newline at end of file diff --git a/docs/audits/2026-06-04-hardcoded-strings.md b/docs/audits/2026-06-04-hardcoded-strings.md new file mode 100644 index 0000000..f058c58 --- /dev/null +++ b/docs/audits/2026-06-04-hardcoded-strings.md @@ -0,0 +1,855 @@ +# Dashboard Hardcoded-String Audit (i18n triage) + +_Generated: 2026-06-04T16:31:55.856Z_ + +Reproduce: `node scripts/audit-hardcoded-strings.mjs` (paths overridable via flags/env). + +## How to read this + +This is a **heuristic triage list**, not an auto-fixer. It flags user-facing English text (JSX text and a small allowlist of attributes) that is **not** wrapped in a `t(...)` call. **False positives are expected** — e.g. brand names, code-ish text in JSX, or strings that are intentionally untranslated. Treat each finding as a candidate to review, not a guaranteed bug. Prioritize files with the highest finding counts (see the ranking below). + +**Spot-check (2026-06-04):** ~15 findings were checked against source; the estimated false-positive rate is roughly **5–10%**. The residual false positives are mostly brand-only single-word labels (e.g. `alt="LoyaltyDog Logo"`, `label="WordPress"`) and short tooltips (e.g. `title="Sync"`) — user-facing, but with little/nothing to translate. Most JSX-text findings are genuine un-localized English copy. + +## Summary + +| Metric | Value | +|---|---| +| Files scanned | 661 | +| Files affected | 66 | +| Total findings | 469 | +| Kind `attr:alt` | 16 | +| Kind `attr:aria-label` | 4 | +| Kind `attr:helpertext` | 16 | +| Kind `attr:label` | 1 | +| Kind `attr:placeholder` | 5 | +| Kind `attr:title` | 26 | +| Kind `jsx-text` | 401 | + +### Inputs + +- Frontend src: `/tmp/wt-core_api-i18n/frontend/src` +- TypeScript module: `/tmp/wt-core_api-i18n/frontend/node_modules/typescript` + +## Top files by finding count + +These are the highest-value un-localized surfaces to prioritize. + +| # | File | Findings | +|---|---|---| +| 1 | `src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx` | 117 | +| 2 | `src/app/(mobile)/p/[passSid]/forms.tsx` | 43 | +| 3 | `src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx` | 41 | +| 4 | `src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx` | 31 | +| 5 | `src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx` | 30 | +| 6 | `src/app/(cpanel)/(user)/profile/settings/form.tsx` | 12 | +| 7 | `src/app/(cpanel)/(user)/layout.tsx` | 9 | +| 8 | `src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/page.tsx` | 9 | +| 9 | `src/app/(mobile)/l/[programSid]/form.tsx` | 8 | +| 10 | `src/app/(auth)/wordpress/auth/form.tsx` | 7 | +| 11 | `src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/page.tsx` | 7 | +| 12 | `src/components/AIAdvisorChat.tsx` | 7 | +| 13 | `src/components/layouts/Navbar.tsx` | 7 | +| 14 | `src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/OffersForm.tsx` | 6 | +| 15 | `src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/page.tsx` | 6 | + +## Findings by file + +### `src/app/(auth)/auth/layout.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 12 | `attr:alt` | LoyaltyDog Logo | + +### `src/app/(auth)/wordpress/auth/form.tsx` (7) + +| Line | Kind | Text | +|---|---|---| +| 18 | `jsx-text` | Connect LoyaltyDog | +| 19 | `jsx-text` | Sign in to your LoyaltyDog account to activate your WordPress plugin. | +| 36 | `jsx-text` | Email address | +| 63 | `jsx-text` | Activation Error | +| 67 | `jsx-text` | Go to Dashboard | +| 105 | `jsx-text` | Select a WordPress Site | +| 106 | `jsx-text` | Choose which LoyaltyDog program to connect to your WordPress plugin. | + +### `src/app/(cpanel)/(user)/account/templates/[templateId]/client.tsx` (2) + +| Line | Kind | Text | +|---|---|---| +| 137 | `jsx-text` | Date reached | +| 151 | `jsx-text` | Pass Yourself | + +### `src/app/(cpanel)/(user)/giftcard/programs/new/form.tsx` (4) + +| Line | Kind | Text | +|---|---|---| +| 156 | `jsx-text` | Company Name | +| 159 | `attr:helpertext` | Legal name of your business | +| 190 | `jsx-text` | Terms & Conditions | +| 193 | `attr:helpertext` | You can specify terms for your gift card program | + +### `src/app/(cpanel)/(user)/layout.tsx` (9) + +| Line | Kind | Text | +|---|---|---| +| 81 | `jsx-text` | We are now setting up the LoyaltyDog Management Portal for your use. | +| 83 | `jsx-text` | As soon as it is ready your dedicated Account Manager will contact you and make… | +| 85 | `jsx-text` | If you have any questions in the meantime please feel free to email me at | +| 87 | `jsx-text` | lee@loyalty.dog | +| 91 | `jsx-text` | More Money; More Customers; More Transactions | +| 94 | `jsx-text` | Thank you | +| 96 | `jsx-text` | The LoyaltyDog Team | +| 100 | `jsx-text` | Welcome to LoyaltyDog! | +| 100 | `jsx-text` | Thank you for choosing to add LoyaltyDog. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/customers/page.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 39 | `jsx-text` | Total customers: | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-birthday-period/page.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 47 | `jsx-text` | Loading data... | +| 97 | `jsx-text` | Total Records | +| 113 | `jsx-text` | Don't have any qualified customers. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-email-phone-not-installed/page.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 37 | `jsx-text` | Loading data... | +| 56 | `jsx-text` | Total Records | +| 71 | `jsx-text` | Don't have any qualified customers. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-name-email/page.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 37 | `jsx-text` | Loading data... | +| 56 | `jsx-text` | Total Records | +| 70 | `jsx-text` | Don't have any qualified customers. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-name-phone/page.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 37 | `jsx-text` | Loading data... | +| 56 | `jsx-text` | Total Records | +| 70 | `jsx-text` | Don't have any qualified customers. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-opting-in/page.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 37 | `jsx-text` | Loading data... | +| 56 | `jsx-text` | Total Records | +| 70 | `jsx-text` | Don't have any qualified customers. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-opting-out/page.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 37 | `jsx-text` | Loading data... | +| 56 | `jsx-text` | Total Records | +| 70 | `jsx-text` | Don't have any qualified customers. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-recent-not-visited-period/page.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 45 | `jsx-text` | Loading data... | +| 67 | `jsx-text` | Total Records | +| 82 | `jsx-text` | Don't have any qualified customers. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-sorted-by-points/page.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 43 | `jsx-text` | Loading data... | +| 62 | `jsx-text` | Total Records | +| 77 | `jsx-text` | Don't have any qualified customers. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-members-with-rewards-claimed-not-redeemed/page.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 43 | `jsx-text` | Loading data... | +| 62 | `jsx-text` | Total Records | +| 77 | `jsx-text` | Don't have any qualified customers. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-common-time-members-join-program/page.tsx` (2) + +| Line | Kind | Text | +|---|---|---| +| 24 | `jsx-text` | * Click on a column to download the list of customers | +| 36 | `jsx-text` | Loading data... | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-common-time-members-use-program/page.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 37 | `jsx-text` | Loading data... | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-popular-rewards-period/page.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 37 | `jsx-text` | Loading data... | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-rewards-redeemed/form.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 29 | `jsx-text` | count of offers | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-rewards-redeemed/page.tsx` (2) + +| Line | Kind | Text | +|---|---|---| +| 41 | `jsx-text` | Loading data... | +| 68 | `jsx-text` | Don't have any qualified offers. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/reports/list-most-rewards/page.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 42 | `jsx-text` | Loading data... | +| 61 | `jsx-text` | Total Records | +| 75 | `jsx-text` | Don't have any qualified offers. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/rules/price/new/page.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 16 | `jsx-text` | New Price Rule | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/edit/form.tsx` (31) + +| Line | Kind | Text | +|---|---|---| +| 71 | `jsx-text` | Pass Type | +| 88 | `jsx-text` | Discount Amount & Conditions | +| 91 | `jsx-text` | Discount Type | +| 96 | `jsx-text` | Fixed amount | +| 103 | `jsx-text` | Discount Value | +| 113 | `attr:helpertext` | Discount amount or percentage (0.00-1.00) | +| 121 | `jsx-text` | WARNING: The discount percentage value is | +| 121 | `jsx-text` | %. Ensure this is the intended discount rate. | +| 131 | `jsx-text` | Currency Code | +| 134 | `attr:helpertext` | For discount amount display | +| 146 | `jsx-text` | Apply Condition | +| 150 | `jsx-text` | No condition applied | +| 151 | `jsx-text` | The value of the most expensive item | +| 152 | `jsx-text` | The value of the cheapest item | +| 158 | `jsx-text` | Special Categories | +| 167 | `jsx-text` | Separate multiple categories by comma "," | +| 169 | `jsx-text` | If there are many selected products in these categories, only one product which… | +| 179 | `jsx-text` | Special Products | +| 188 | `jsx-text` | Separate multiple products by comma "," | +| 190 | `jsx-text` | If there are many selected products, only one product which satisfies the apply… | +| 197 | `jsx-text` | Points & Limitations | +| 207 | `jsx-text` | Issue Before Birthday (hours) | +| 216 | `attr:helpertext` | The voucher will be issued this many hours before the customer’s birthday. | +| 225 | `jsx-text` | Expires In (hours) | +| 235 | `attr:helpertext` | The voucher will expire after this duration (in hours) from the time it is issu… | +| 241 | `jsx-text` | Limit per Customer | +| 262 | `jsx-text` | How many times a customer can get this voucher. | +| 267 | `jsx-text` | Availability Count | +| 288 | `jsx-text` | Restrict how often this voucher can be claimed in total by all customers. | +| 293 | `jsx-text` | Require Fields | +| 314 | `jsx-text` | The customer must complete all required fields to receive the voucher. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/[voucherId]/page.tsx` (7) + +| Line | Kind | Text | +|---|---|---| +| 60 | `jsx-text` | Pass Type | +| 68 | `jsx-text` | Discount Amount | +| 78 | `jsx-text` | Apply Condition | +| 84 | `jsx-text` | Start Date | +| 92 | `jsx-text` | End Date | +| 100 | `jsx-text` | Limit per Customer | +| 106 | `jsx-text` | Availability Count | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/forms.tsx` (4) + +| Line | Kind | Text | +|---|---|---| +| 24 | `jsx-text` | Are you sure you want to delete this voucher? | +| 28 | `jsx-text` | No, cancel | +| 31 | `jsx-text` | Yes, confirm delete | +| 60 | `attr:helpertext` | Multiple emails can be separated by commas. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/form.tsx` (30) + +| Line | Kind | Text | +|---|---|---| +| 66 | `jsx-text` | Pass Type | +| 83 | `jsx-text` | Discount Amount & Conditions | +| 86 | `jsx-text` | Discount Type | +| 91 | `jsx-text` | Fixed amount | +| 98 | `jsx-text` | Discount Value | +| 108 | `attr:helpertext` | Discount amount or percentage (0.00-1.00) | +| 116 | `jsx-text` | WARNING: The discount percentage value is | +| 116 | `jsx-text` | %. Ensure this is the intended discount rate. | +| 126 | `jsx-text` | Currency Code | +| 129 | `attr:helpertext` | For discount amount display | +| 141 | `jsx-text` | Apply Condition | +| 145 | `jsx-text` | No condition applied | +| 146 | `jsx-text` | The value of the most expensive item | +| 147 | `jsx-text` | The value of the cheapest item | +| 153 | `jsx-text` | Special Categories | +| 162 | `jsx-text` | Separate multiple categories by comma "," | +| 164 | `jsx-text` | If there are many selected products in these categories, only one product which… | +| 174 | `jsx-text` | Special Products | +| 183 | `jsx-text` | Separate multiple products by comma "," | +| 185 | `jsx-text` | If there are many selected products, only one product which satisfies the apply… | +| 202 | `jsx-text` | Issue Before Birthday (hours) | +| 211 | `attr:helpertext` | The voucher will be issued this many hours before the customer’s birthday. | +| 220 | `jsx-text` | Expires In (hours) | +| 230 | `attr:helpertext` | The voucher will expire after this duration (in hours) from the time it is issu… | +| 236 | `jsx-text` | Limit per Customer | +| 257 | `jsx-text` | How many times a customer can get this voucher. | +| 262 | `jsx-text` | Availability Count | +| 283 | `jsx-text` | Restrict how often this voucher can be claimed in total by all customers. | +| 288 | `jsx-text` | Require Fields | +| 309 | `jsx-text` | The customer must complete all required fields to receive the voucher. | + +### `src/app/(cpanel)/(user)/loyalty/programs/[programId]/vouchers/new/page.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 15 | `jsx-text` | New Pass | + +### `src/app/(cpanel)/(user)/loyalty/programs/new/form.tsx` (41) + +| Line | Kind | Text | +|---|---|---| +| 82 | `jsx-text` | Loyalty Program | +| 96 | `attr:helpertext` | Short promotional teaser for your program | +| 105 | `jsx-text` | Select country | +| 124 | `attr:helpertext` | The icon needs to be of square format | +| 151 | `jsx-text` | Pass Type ID | +| 172 | `jsx-text` | Program Type | +| 177 | `jsx-text` | Gift Card | +| 213 | `jsx-text` | Stamp Card | +| 237 | `jsx-text` | Tiered Membership | +| 261 | `jsx-text` | Special Offers | +| 271 | `jsx-text` | Choose from one of our ready made schemes above or build your own custom loyalt… | +| 276 | `jsx-text` | Points for Activities | +| 278 | `jsx-text` | Reward your customer points for certain activities | +| 283 | `jsx-text` | Add Email | +| 289 | `jsx-text` | Add Phone Number | +| 295 | `jsx-text` | Install Pass | +| 301 | `jsx-text` | Pass Scanned | +| 311 | `jsx-text` | Chose dynamic if you want to reward a custom number of points for each scan. | +| 316 | `jsx-text` | Customer Referral | +| 320 | `jsx-text` | points after referred customer has | +| 326 | `jsx-text` | Contact & Legal | +| 329 | `jsx-text` | Company Name | +| 332 | `attr:helpertext` | Legal name of your business | +| 363 | `jsx-text` | Terms & Conditions | +| 366 | `attr:helpertext` | You can specify terms for your loyalty program | +| 370 | `jsx-text` | Scanning & Redemption | +| 382 | `jsx-text` | Browser: I want to use another scanning solution that can open URLs. | +| 386 | `jsx-text` | None: I don't need to scan the cards. | +| 392 | `jsx-text` | Points Change Message | +| 399 | `jsx-text` | Disable Message | +| 403 | `jsx-text` | The default message is | +| 403 | `jsx-text` | "New points: %@" | +| 409 | `jsx-text` | Point Names | +| 417 | `jsx-text` | The default values are One: | +| 417 | `jsx-text` | "Point" | +| 417 | `jsx-text` | "Points" | +| 422 | `jsx-text` | Customer Fields | +| 425 | `jsx-text` | Required Fields | +| 455 | `jsx-text` | Display Name | +| 518 | `attr:helpertext` | Enter available options as comma separated values | +| 553 | `jsx-text` | Add Field | + +### `src/app/(cpanel)/(user)/loyalty/programs/new/page.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 22 | `jsx-text` | New Program | + +### `src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/forms.tsx` (117) + +| Line | Kind | Text | +|---|---|---| +| 85 | `jsx-text` | Hang tight! We’re getting everything ready for you... | +| 90 | `jsx-text` | Please go to Shopify Admin and enable the Customer Accounts option to use full … | +| 101 | `jsx-text` | It looks like you've uninstalled the Loyalty Dog app. We're sad to se… | +| 106 | `jsx-text` | Reinstall Loyalty Dog | +| 112 | `jsx-text` | You uninstalled the Loyalty Dog app. | +| 123 | `jsx-text` | Please accept the application charge to active your account and use full functi… | +| 127 | `jsx-text` | Active charge | +| 133 | `jsx-text` | Incomplete application charge | +| 144 | `jsx-text` | By default, app embed blocks are deactivated after an app is installed. | +| 146 | `jsx-text` | You need to activate app embed blocks in the theme editor to enable the widget. | +| 154 | `jsx-text` | Activate widget | +| 157 | `jsx-text` | Recheck status | +| 163 | `jsx-text` | Rewards widget inactivated | +| 220 | `jsx-text` | Loyalty Program | +| 232 | `jsx-text` | You have to configure the loyalty program to use full functionality. | +| 233 | `jsx-text` | You can't change the program after saved! | +| 237 | `jsx-text` | Add new program | +| 244 | `jsx-text` | Select program | +| 253 | `attr:title` | Sync | +| 263 | `jsx-text` | Sync all Shopify customers | +| 270 | `jsx-text` | Reward customers when the financial status is one of | +| 288 | `jsx-text` | Cancel rewards when the financial status is one of | +| 306 | `jsx-text` | Reward customers for shopping | +| 310 | `jsx-text` | Online and in-store (POS) | +| 314 | `jsx-text` | Shop Online only | +| 318 | `jsx-text` | In-store only (POS) | +| 323 | `jsx-text` | Reward customers for the following parts of an order | +| 331 | `jsx-text` | Exclude coupon discounts used | +| 335 | `jsx-text` | Exclude taxes | +| 339 | `jsx-text` | Exclude shipping | +| 386 | `jsx-text` | Sign Up | +| 401 | `jsx-text` | Points awarded | +| 415 | `jsx-text` | Celebrate a birthday | +| 430 | `jsx-text` | Points awarded | +| 444 | `jsx-text` | Place first order | +| 459 | `jsx-text` | Points awarded | +| 485 | `jsx-text` | Like on Facebook | +| 492 | `jsx-text` | Facebook page URL | +| 500 | `jsx-text` | Points awarded | +| 506 | `jsx-text` | Share to Facebook | +| 513 | `jsx-text` | URL to share | +| 521 | `jsx-text` | Points awarded | +| 547 | `jsx-text` | Follow on Twitter | +| 554 | `jsx-text` | Twitter username | +| 562 | `jsx-text` | Points awarded | +| 568 | `jsx-text` | Share to Twitter | +| 575 | `jsx-text` | URL to share | +| 589 | `jsx-text` | Points awarded | +| 611 | `jsx-text` | Follow on Instagram | +| 618 | `jsx-text` | Instagram username | +| 626 | `jsx-text` | Points awarded | +| 664 | `jsx-text` | Order Discounts | +| 665 | `jsx-text` | Product Discounts | +| 666 | `jsx-text` | Shipping Discounts | +| 683 | `attr:title` | View | +| 692 | `attr:title` | Configure | +| 709 | `jsx-text` | No offers found. | +| 716 | `jsx-text` | Edit Options for | +| 719 | `jsx-text` | Combines with | +| 737 | `jsx-text` | Order Discounts | +| 756 | `jsx-text` | Product Discounts | +| 775 | `jsx-text` | Shipping Discounts | +| 1092 | `jsx-text` | Show the Rewards button on the Online Store | +| 1095 | `jsx-text` | Trigger mode | +| 1099 | `jsx-text` | Manual (Advance user) | +| 1102 | `jsx-text` | Manual: hide the launcher button; you can show the panel via JS methods of | +| 1107 | `jsx-text` | Button position | +| 1119 | `jsx-text` | Edge margin (px) | +| 1146 | `jsx-text` | Launcher Button | +| 1152 | `jsx-text` | Button width (px) | +| 1158 | `jsx-text` | Button height (px) | +| 1164 | `jsx-text` | Border radius (px) | +| 1172 | `jsx-text` | Background color | +| 1178 | `jsx-text` | Pulse color | +| 1186 | `jsx-text` | Enable pulse animation when active offer available | +| 1190 | `jsx-text` | Show button text | +| 1193 | `jsx-text` | Button text | +| 1200 | `jsx-text` | Text size (px) | +| 1206 | `jsx-text` | Text color | +| 1214 | `jsx-text` | Show button icon | +| 1218 | `jsx-text` | Icon size (px) | +| 1224 | `jsx-text` | Icon color | +| 1231 | `jsx-text` | Custom icon url | +| 1241 | `jsx-text` | Widget Theme | +| 1248 | `jsx-text` | Panel width (px) | +| 1254 | `jsx-text` | Panel height (px) | +| 1260 | `jsx-text` | Background color | +| 1268 | `jsx-text` | Border width (px) | +| 1274 | `jsx-text` | Border radius (px) | +| 1280 | `jsx-text` | Border color | +| 1289 | `jsx-text` | Border width (px) | +| 1295 | `jsx-text` | Border radius (px) | +| 1301 | `jsx-text` | Border color | +| 1307 | `jsx-text` | Background color | +| 1313 | `jsx-text` | Banner image / gradient | +| 1316 | `jsx-text` | Primary color | +| 1322 | `jsx-text` | Secondary color | +| 1328 | `jsx-text` | Text color | +| 1336 | `jsx-text` | Banner height | +| 1343 | `jsx-text` | % of panel height | +| 1349 | `jsx-text` | Content padding (px) | +| 1360 | `jsx-text` | Banner image url | +| 1363 | `jsx-text` | Tip: should use a small and light image to improve the loading speed. | +| 1369 | `jsx-text` | Text color | +| 1375 | `jsx-text` | Border radius (px) | +| 1384 | `jsx-text` | Primary color | +| 1390 | `jsx-text` | Success color | +| 1396 | `jsx-text` | Error color | +| 1402 | `jsx-text` | Divider color | +| 1504 | `jsx-text` | Widget Text | +| 1532 | `jsx-text` | Advanced Styling | +| 1536 | `jsx-text` | You can add custom css using prefix class name to styling our widget. | +| 1538 | `jsx-text` | Custom styles | +| 1542 | `jsx-text` | Custom root styles (widget size, position) | +| 1545 | `jsx-text` | Caution: This style will be injected to your site. | +| 1597 | `attr:title` | Reset | +| 1602 | `jsx-text` | Reset to Defaults | + +### `src/app/(cpanel)/(user)/loyalty/shopify/[shopId]/page.tsx` (9) + +| Line | Kind | Text | +|---|---|---| +| 34 | `jsx-text` | Your current authorization does not include the | +| 34 | `jsx-text` | write_discounts | +| 34 | `jsx-text` | scope. Discount features such as offer combination options will use the legacy … | +| 39 | `jsx-text` | Re-authorize | +| 45 | `jsx-text` | Missing "write_discounts" scope | +| 54 | `attr:title` | Settings | +| 57 | `attr:title` | Actions | +| 60 | `attr:title` | Offers | +| 63 | `attr:title` | Widget | + +### `src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/ActionsForm.tsx` (4) + +| Line | Kind | Text | +|---|---|---| +| 46 | `jsx-text` | Enable points for purchases | +| 66 | `jsx-text` | Sign-up Bonus | +| 69 | `jsx-text` | Award bonus points on sign-up | +| 89 | `jsx-text` | Save Actions | + +### `src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/OffersForm.tsx` (6) + +| Line | Kind | Text | +|---|---|---| +| 28 | `jsx-text` | No loyalty program linked. | +| 28 | `jsx-text` | Go to the | +| 28 | `jsx-text` | tab to link a loyalty program. Once linked, offers from that program will be av… | +| 38 | `jsx-text` | Manage offers on the | +| 40 | `jsx-text` | Offers page | +| 50 | `jsx-text` | No active offers yet. Create one on the Offers page to make it available for re… | + +### `src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/SettingsForm.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 40 | `jsx-text` | — No program linked — | +| 47 | `jsx-text` | Link a loyalty program to enable points earning and offer redemption. | +| 51 | `jsx-text` | Save Settings | + +### `src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/components/WidgetForm.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 38 | `jsx-text` | Plugin not yet initialized. | +| 38 | `jsx-text` | Complete the plugin activation on your WordPress site before configuring widget… | +| 46 | `jsx-text` | The LoyaltyDog widget can be placed in your WordPress site using any of the fol… | + +### `src/app/(cpanel)/(user)/loyalty/wordpress/[wpId]/page.tsx` (6) + +| Line | Kind | Text | +|---|---|---| +| 22 | `jsx-text` | Plugin not yet activated. Visit your WordPress admin to complete setup. | +| 23 | `jsx-text` | No loyalty program linked. Select a program in the Settings tab. | +| 25 | `attr:title` | Settings | +| 28 | `attr:title` | Actions | +| 31 | `attr:title` | Offers | +| 34 | `attr:title` | Widget | + +### `src/app/(cpanel)/(user)/loyalty/wordpress/page.tsx` (2) + +| Line | Kind | Text | +|---|---|---| +| 18 | `jsx-text` | No WordPress Sites Connected | +| 19 | `jsx-text` | Install the LoyaltyDog plugin on your WordPress / WooCommerce site to get start… | + +### `src/app/(cpanel)/(user)/profile/page.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 20 | `attr:title` | Update | +| 45 | `jsx-text` | Email address | +| 51 | `jsx-text` | Phone number | + +### `src/app/(cpanel)/(user)/profile/settings/form.tsx` (12) + +| Line | Kind | Text | +|---|---|---| +| 42 | `jsx-text` | First Name | +| 50 | `jsx-text` | Last Name | +| 74 | `jsx-text` | Favorite Amount Spent | +| 151 | `jsx-text` | Current password | +| 159 | `jsx-text` | New password | +| 167 | `jsx-text` | Confirm password | +| 174 | `jsx-text` | Password requirements: | +| 175 | `jsx-text` | Ensure that these requirements are met: | +| 177 | `jsx-text` | At least 10 characters (and up to 100 characters) | +| 178 | `jsx-text` | At least one lowercase character | +| 179 | `jsx-text` | Inclusion of at least one special character, e.g., ! @ # ? | +| 180 | `jsx-text` | Some text here zoltan | + +### `src/app/(cpanel)/(user)/profile/settings/page.tsx` (5) + +| Line | Kind | Text | +|---|---|---| +| 17 | `attr:title` | General information | +| 20 | `attr:title` | Password information | +| 23 | `attr:title` | Language & Time | +| 24 | `attr:title` | Social accounts | +| 25 | `attr:title` | Alerts & Notifications | + +### `src/app/(cpanel)/(user)/programs/new/form.tsx` (2) + +| Line | Kind | Text | +|---|---|---| +| 29 | `jsx-text` | Program Type | +| 34 | `jsx-text` | Gift Card | + +### `src/app/(cpanel)/(user)/programs/new/page.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 19 | `jsx-text` | New Program | + +### `src/app/(marketing)/_components/CookieConsentBanner.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 58 | `attr:aria-label` | Cookie consent | + +### `src/app/(marketing)/_components/LanguageSwitcher.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 28 | `attr:aria-label` | Language | + +### `src/app/(mobile)/l/[programSid]/form.tsx` (8) + +| Line | Kind | Text | +|---|---|---| +| 51 | `jsx-text` | Enter Your Details | +| 54 | `jsx-text` | First Name | +| 60 | `jsx-text` | Last Name | +| 66 | `jsx-text` | Email Address | +| 72 | `jsx-text` | Cell Number | +| 113 | `jsx-text` | I accept the | +| 115 | `jsx-text` | Terms and Conditions | +| 122 | `jsx-text` | Terms & Conditions | + +### `src/app/(mobile)/l/c/[customerSid]/offers/[offerSid]/form.tsx` (5) + +| Line | Kind | Text | +|---|---|---| +| 18 | `jsx-text` | You currently have an active offer, if you confirm, your active offer will be r… | +| 45 | `jsx-text` | Back to Pass | +| 51 | `jsx-text` | Your Personal Offer | +| 53 | `jsx-text` | You will be notified when it's ready. | +| 55 | `jsx-text` | You can close this window | + +### `src/app/(mobile)/l/c/[customerSid]/offers/page.tsx` (4) + +| Line | Kind | Text | +|---|---|---| +| 25 | `attr:title` | Back to Pass | +| 26 | `jsx-text` | Back to Pass | +| 34 | `jsx-text` | Your Active Offer | +| 47 | `jsx-text` | No new offers. | + +### `src/app/(mobile)/l/c/[customerSid]/profile/form.tsx` (5) + +| Line | Kind | Text | +|---|---|---| +| 39 | `jsx-text` | Enter Your Details | +| 41 | `attr:placeholder` | First Name | +| 44 | `attr:placeholder` | Last Name | +| 47 | `attr:placeholder` | Email Address | +| 50 | `attr:placeholder` | Phone Number | + +### `src/app/(mobile)/l/c/[customerSid]/profile/page.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 18 | `attr:title` | Back to Pass | +| 19 | `jsx-text` | Back to Pass | +| 25 | `jsx-text` | Your Profile | + +### `src/app/(mobile)/p/[passSid]/forms.tsx` (43) + +| Line | Kind | Text | +|---|---|---| +| 36 | `attr:alt` | Add to Apple Wallet | +| 41 | `attr:alt` | Add to Google Wallet | +| 44 | `attr:alt` | Add to Wallet Passes | +| 50 | `attr:alt` | Add to Apple Wallet | +| 53 | `attr:alt` | Add to Google Wallet | +| 83 | `jsx-text` | You pass was sent. | +| 112 | `jsx-text` | Enter Your Details | +| 266 | `jsx-text` | Pass Added | +| 267 | `attr:title` | Open Wallet | +| 268 | `jsx-text` | Open Wallet | +| 271 | `jsx-text` | Show Preview | +| 279 | `jsx-text` | Pass Not Loaded? | +| 282 | `attr:title` | Download Pass | +| 283 | `jsx-text` | Download Pass | +| 285 | `attr:title` | Open Wallet | +| 286 | `jsx-text` | Open Wallet | +| 292 | `attr:alt` | Add to Google Wallet | +| 294 | `jsx-text` | If you don't have a Wallet app, please download it now: | +| 298 | `attr:title` | Install WalletPasses | +| 301 | `attr:alt` | Add to WalletPasses | +| 303 | `jsx-text` | Your pass will automatically load after you open the app after installation. | +| 307 | `jsx-text` | Show Preview | +| 318 | `attr:title` | auto download | +| 422 | `jsx-text` | Verifying session… | +| 452 | `jsx-text` | Scan this QR Code on your smartphone to download the pass. | +| 465 | `jsx-text` | Email the pass to your mobile device. | +| 483 | `jsx-text` | Print this pass if you don't want to use your mobile device. | +| 697 | `jsx-text` | How would you like to verify? | +| 719 | `jsx-text` | Get a code by email | +| 742 | `jsx-text` | SMS / Phone | +| 743 | `jsx-text` | Get a code by text message | +| 785 | `jsx-text` | We'll send a 6-digit verification code to your | +| 807 | `jsx-text` | Code sent to | +| 811 | `jsx-text` | Dev mode — code: | +| 850 | `jsx-text` | ← Change method | +| 875 | `jsx-text` | Activate Your Card | +| 877 | `jsx-text` | Enter the 6-digit security code printed on your gift card to activate it and lo… | +| 900 | `jsx-text` | Security Code (6 digits) | +| 927 | `jsx-text` | Activate Gift Card | +| 929 | `jsx-text` | The security code is printed on the back or sticker of your physical card. | +| 941 | `jsx-text` | ← Start over | +| 953 | `jsx-text` | Activating your gift card… | +| 966 | `jsx-text` | Loading your pass… | + +### `src/app/(mobile)/p/[passSid]/page.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 90 | `jsx-text` | The pass is only available on mobile device, please use your smartphone to scan… | +| 103 | `jsx-text` | You can email the pass to your email address so that you can open it on your mo… | +| 121 | `jsx-text` | Print this pass if you don't want to use your mobile device | + +### `src/app/(mobile)/s/[passSid]/status/forms.tsx` (2) + +| Line | Kind | Text | +|---|---|---| +| 136 | `jsx-text` | Points added: | +| 137 | `jsx-text` | New points: | + +### `src/app/(mobile)/t/[templateSid]/form.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 38 | `jsx-text` | Enter Your Details | + +### `src/app/(mobile)/t/[templateSid]/page.tsx` (5) + +| Line | Kind | Text | +|---|---|---| +| 21 | `jsx-text` | Not available | +| 23 | `jsx-text` | Distribution of this pass has been stopped by the pass issuer. | +| 41 | `jsx-text` | Existing Pass | +| 42 | `jsx-text` | You have already created a pass. | +| 45 | `jsx-text` | Open exist Pass | + +### `src/app/error.tsx` (3) + +| Line | Kind | Text | +|---|---|---| +| 26 | `jsx-text` | Something went wrong! We are already working to solve the problem. | +| 30 | `jsx-text` | Try again | +| 33 | `jsx-text` | Return Home | + +### `src/app/error/page.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 22 | `jsx-text` | Return Home | + +### `src/app/not-found.tsx` (2) + +| Line | Kind | Text | +|---|---|---| +| 12 | `jsx-text` | Whoops! That page doesn't exist. | +| 18 | `jsx-text` | Return Home | + +### `src/app/test-ai-advisor/page.tsx` (5) + +| Line | Kind | Text | +|---|---|---| +| 92 | `jsx-text` | 🧪 AI Advisor - Test Mode | +| 93 | `jsx-text` | This is a mock version for testing UI changes. Try asking: | +| 95 | `jsx-text` | "Show me a table analysis" - to test table pagination | +| 96 | `jsx-text` | "How is my program performing?" - to test heading hierarchy | +| 97 | `jsx-text` | Any message - to test auto-scroll behavior | + +### `src/components/AIAdvisorChat.tsx` (7) + +| Line | Kind | Text | +|---|---|---| +| 270 | `jsx-text` | 🐕 Loyalty Dog AI Business Advisor | +| 275 | `jsx-text` | Total Transactions | +| 282 | `jsx-text` | Member Participation | +| 289 | `jsx-text` | Total Revenue | +| 367 | `jsx-text` | 💾 Your Download | +| 413 | `jsx-text` | 💡 Try asking: | +| 444 | `attr:placeholder` | Ask about your loyalty program... | + +### `src/components/CropModal.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 36 | `attr:alt` | Crop Modal Image | + +### `src/components/design-tabs/FieldForm.tsx` (5) + +| Line | Kind | Text | +|---|---|---| +| 317 | `jsx-text` | Normal Field | +| 424 | `jsx-text` | Pass ID | +| 425 | `jsx-text` | Pass URL | +| 426 | `jsx-text` | Template URL | +| 427 | `jsx-text` | Scan URL | + +### `src/components/design-tabs/PlaceholderTab.tsx` (2) + +| Line | Kind | Text | +|---|---|---| +| 186 | `jsx-text` | 6fed93a1-4214-44a5-bae2-a7eefd7a29af | +| 192 | `jsx-text` | b-2ToUIURKW64qfu_Xoprw | + +### `src/components/design-tabs/StripTab.tsx` (1) + +| Line | Kind | Text | +|---|---|---| +| 138 | `attr:title` | Background | + +### `src/components/layouts/Navbar.tsx` (7) + +| Line | Kind | Text | +|---|---|---| +| 49 | `attr:alt` | LoyaltyDog Logo | +| 50 | `attr:alt` | LoyaltyDog Logo | +| 51 | `attr:alt` | LoyaltyDog Logo | +| 63 | `attr:alt` | User settings | +| 104 | `attr:alt` | LoyaltyDog Logo | +| 105 | `attr:alt` | LoyaltyDog Logo | +| 116 | `attr:alt` | User settings | + +### `src/components/layouts/Sidebar.tsx` (2) + +| Line | Kind | Text | +|---|---|---| +| 202 | `attr:label` | WordPress | +| 339 | `jsx-text` | AI Settings | + +### `src/components/PaginatedTable.tsx` (2) + +| Line | Kind | Text | +|---|---|---| +| 71 | `attr:aria-label` | Previous page | +| 85 | `attr:aria-label` | Next page | + diff --git a/scripts/audit-hardcoded-strings.mjs b/scripts/audit-hardcoded-strings.mjs new file mode 100644 index 0000000..9f2b385 --- /dev/null +++ b/scripts/audit-hardcoded-strings.mjs @@ -0,0 +1,452 @@ +#!/usr/bin/env node +/** + * audit-hardcoded-strings.mjs + * ------------------------------------------------------------------------------ + * Heuristic triage scanner that finds user-facing ENGLISH text in the core_api + * frontend that is NOT wrapped in a `t(...)` translation call. This is the + * complement to `audit-frontend-keys.mjs` (which audits keys that ARE wrapped). + * + * THIS IS A TRIAGE LIST, NOT AN AUTO-FIXER. False positives are expected — the + * goal is to surface un-localized surfaces for human review, ranked by file. + * + * WHAT IT FLAGS + * ------------- + * 1. JSX text nodes (`JsxText`) with >= 2 runs of word-characters (i.e. 2+ + * real words). Whitespace/punctuation-only, numeric, and single-token + * nodes are ignored. + * 2. User-facing string-literal ATTRIBUTES from an allowlist: + * placeholder, title, aria-label, alt, label, helperText + * ...but ONLY when the attribute value is a plain string literal — not a + * `t(...)` call and not any other JS expression. + * + * HOW FALSE POSITIVES ARE REDUCED + * ------------------------------- + * - Text already inside `t(...)` / `{t(...)}` is skipped (we detect JSX + * expression containers whose expression is a t() call, and never treat + * them as text). + * - JsxText nodes are literal text BETWEEN tags, so `{t("x")}` is an + * expression container, never JsxText — already excluded by the AST. + * - Non-display strings are dropped: URLs, routes ("/..."), className-like + * tokens, all-caps constants, pure numbers/symbols, single words <= 2 chars. + * - Excluded attributes are never read for the "string literal" rule + * (key, data-*, id, href, className, type, name, role, etc.). + * - Import/require lines contribute nothing (imports have no JsxText and we + * only read allowlisted JSX attributes). + * + * EXTRACTION STRATEGY + * ------------------- + * Parsing uses the TypeScript compiler API (AST), resolved FROM THE FRONTEND'S + * node_modules (same as the sibling `audit-frontend-keys.mjs`). No devDependency + * is added to this repo. Override with --ts-module / TS_MODULE. + * + * SCOPE + * ----- + * - Scans `*.tsx` under src/app/** and src/components/**. + * - Scans `*.ts` ONLY if it contains JSX (cheap heuristic: file parses with a + * JsxElement/JsxFragment somewhere). Plain `.ts` logic files are skipped. + * - Excludes: node_modules, .next, __tests__, *.test.*, *.d.ts, and + * src/lib/i18n-utils.ts (that file IS the translation data). + * + * USAGE + * node scripts/audit-hardcoded-strings.mjs \ + * [--frontend-src DIR] [--out-json FILE] [--out-md FILE] [--ts-module DIR] + * Env overrides: FRONTEND_SRC, OUT_JSON, OUT_MD, TS_MODULE. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; + +// --------------------------------------------------------------------------- +// Config / CLI +// --------------------------------------------------------------------------- +const DEFAULTS = { + frontendSrc: "/tmp/wt-core_api-i18n/frontend/src", + tsModule: "/tmp/wt-core_api-i18n/frontend/node_modules/typescript", + outJson: "/tmp/wt-locplat-i18n/docs/audits/2026-06-04-hardcoded-strings.json", + outMd: "/tmp/wt-locplat-i18n/docs/audits/2026-06-04-hardcoded-strings.md", +}; + +function parseArgs(argv) { + const map = { + "--frontend-src": "frontendSrc", + "--ts-module": "tsModule", + "--out-json": "outJson", + "--out-md": "outMd", + }; + const cfg = { ...DEFAULTS }; + if (process.env.FRONTEND_SRC) cfg.frontendSrc = process.env.FRONTEND_SRC; + if (process.env.TS_MODULE) cfg.tsModule = process.env.TS_MODULE; + if (process.env.OUT_JSON) cfg.outJson = process.env.OUT_JSON; + if (process.env.OUT_MD) cfg.outMd = process.env.OUT_MD; + for (let i = 2; i < argv.length; i += 2) { + const key = map[argv[i]]; + if (key) cfg[key] = argv[i + 1]; + } + return cfg; +} + +const cfg = parseArgs(process.argv); + +// Resolve the TypeScript compiler API from the frontend's node_modules. +const require = createRequire(import.meta.url); +let ts; +try { + ts = require(cfg.tsModule); +} catch (e) { + console.error(`Failed to load TypeScript from ${cfg.tsModule}: ${e.message}`); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// File discovery — limited to src/app/** and src/components/** +// --------------------------------------------------------------------------- +const EXCLUDE_DIR = new Set(["node_modules", ".next", "__tests__"]); +// Subtrees of frontendSrc we scan (per spec). +const SCAN_SUBDIRS = ["app", "components"]; + +function isExcludedFile(p) { + const base = path.basename(p); + if (/\.d\.ts$/.test(base)) return true; + if (/\.test\.(ts|tsx)$/.test(base)) return true; + if (/\.spec\.(ts|tsx)$/.test(base)) return true; + // The translation data file itself is not a localization target. + if (p.endsWith(path.join("lib", "i18n-utils.ts"))) return true; + return false; +} + +function collectFiles(dir, out = []) { + if (!fs.existsSync(dir)) return out; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (EXCLUDE_DIR.has(entry.name)) continue; + collectFiles(full, out); + } else if (/\.(ts|tsx)$/.test(entry.name) && !isExcludedFile(full)) { + out.push(full); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Text classification heuristics +// --------------------------------------------------------------------------- + +// Count runs of word characters. "Save changes" -> 2, "OK" -> 1, "" -> 0. +function wordRuns(s) { + const m = s.match(/[A-Za-z][A-Za-z'’]*/g); + return m ? m.length : 0; +} + +// Does the text contain at least one alphabetic character (vs pure numbers/symbols)? +function hasLetters(s) { + return /[A-Za-z]/.test(s); +} + +// Looks like a CSS class string? (tailwind etc.): many tokens, hyphens, colons, +// no spaces-that-form-sentences. Heuristic: mostly lowercase tokens joined by +// spaces where most tokens contain a hyphen or colon (tailwind utility classes). +function looksLikeClassName(s) { + const tokens = s.trim().split(/\s+/); + if (tokens.length === 0) return false; + const utilLike = tokens.filter((t) => /[-:]/.test(t) && /^[a-z0-9[\]/.#%-:]+$/i.test(t)).length; + return utilLike >= Math.max(1, Math.ceil(tokens.length * 0.6)); +} + +// Looks like a URL / route / path / file? +function looksLikePathOrUrl(s) { + const t = s.trim(); + if (/^https?:\/\//i.test(t)) return true; + if (/^\//.test(t)) return true; // route + if (/^[./]/.test(t)) return true; // relative path + if (/^[\w-]+\.(png|jpe?g|svg|gif|webp|css|js|tsx?|json|ico)$/i.test(t)) return true; + if (/^(mailto:|tel:)/i.test(t)) return true; + return false; +} + +// All-caps constant-like? ("USD", "API_BASE", "GET") +function looksLikeConstant(s) { + const t = s.trim(); + return /^[A-Z0-9_]+$/.test(t) && t.length >= 2; +} + +// Dotted identifier with no spaces? This catches: +// - i18n keys passed to custom components: label="loyalty.analytics.title" +// - domains / package ids: "crontab.guru", "com.google.android.youtube" +// None of these are display sentences, so treat them as non-display. +function looksLikeDottedIdent(s) { + const t = s.trim(); + return /^[A-Za-z][\w-]*(\.[A-Za-z0-9][\w-]*)+$/.test(t) && !/\s/.test(t); +} + +// Decide if a free string (attribute value) is plausibly user-facing display text. +function isDisplayString(s) { + const t = s.trim(); + if (!t) return false; + if (!hasLetters(t)) return false; // pure numbers/symbols + if (looksLikePathOrUrl(t)) return false; + if (looksLikeConstant(t)) return false; + if (looksLikeDottedIdent(t)) return false; + if (looksLikeClassName(t)) return false; + // Single short token (<= 2 chars, or a single word that is itself <= 2 chars). + if (wordRuns(t) < 1) return false; + if (wordRuns(t) === 1) { + const sole = t.match(/[A-Za-z][A-Za-z'’]*/)[0]; + if (sole.length <= 2) return false; + // A single word like "Settings" / "Dashboard" IS display text — keep it for + // attributes (placeholder/title/etc.), where one word is normal. + } + return true; +} + +// Decide if a JsxText node should be flagged (stricter: needs 2+ words). +function isDisplayJsxText(s) { + const t = s.replace(/\s+/g, " ").trim(); + if (!t) return false; + if (!hasLetters(t)) return false; + if (wordRuns(t) < 2) return false; // require 2+ real words + if (looksLikePathOrUrl(t)) return false; + if (looksLikeConstant(t)) return false; + if (looksLikeDottedIdent(t)) return false; + return true; +} + +function truncate(s, n = 80) { + const t = s.replace(/\s+/g, " ").trim(); + return t.length > n ? t.slice(0, n - 1) + "…" : t; +} + +// --------------------------------------------------------------------------- +// Attribute allowlist (user-facing). All lowercased for comparison. +// --------------------------------------------------------------------------- +const ATTR_ALLOWLIST = new Set([ + "placeholder", + "title", + "aria-label", + "alt", + "label", + "helpertext", // Flowbite `helperText` +]); + +// --------------------------------------------------------------------------- +// Detect whether a file contains any JSX (so plain-logic .ts is skipped). +// --------------------------------------------------------------------------- +function fileHasJsx(sf) { + let found = false; + (function walk(node) { + if (found) return; + if ( + ts.isJsxElement(node) || + ts.isJsxSelfClosingElement(node) || + ts.isJsxFragment(node) + ) { + found = true; + return; + } + ts.forEachChild(node, walk); + })(sf); + return found; +} + +// --------------------------------------------------------------------------- +// Extract findings from one source file +// --------------------------------------------------------------------------- +function extractFromFile(file) { + const text = fs.readFileSync(file, "utf8"); + // IMPORTANT: parse with the script kind that matches the extension. A `.ts` + // file cannot legally contain JSX (TypeScript requires `.tsx`), and parsing + // `.ts` AS TSX turns ordinary code like `a < b > c` into phantom JsxText + // nodes — a major false-positive source. So we parse `.ts` as TS (no JSX + // grammar), which means real-JSX-bearing files must be `.tsx` to be flagged. + const scriptKind = file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind); + + // For .ts files, only proceed if they actually contain JSX (parsed as TS, + // this is effectively never true, so plain logic files are skipped). + if (file.endsWith(".ts") && !fileHasJsx(sf)) return []; + + const findings = []; // { line, kind, text } + const lineOf = (node) => + sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; + + function visit(node) { + // --- JSX text nodes between tags --- + if (ts.isJsxText(node)) { + // node.text is the raw literal text between tags; `{t(...)}` is an + // expression container (a sibling), never part of JsxText. + if (isDisplayJsxText(node.text)) { + findings.push({ + line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, + kind: "jsx-text", + text: truncate(node.text), + }); + } + } + + // --- JSX string-literal attributes from the allowlist --- + if (ts.isJsxAttribute(node)) { + const attrName = (node.name && node.name.getText(sf) ? node.name.getText(sf) : "").toLowerCase(); + if (ATTR_ALLOWLIST.has(attrName)) { + const init = node.initializer; + let strVal; + if (init && ts.isStringLiteral(init)) { + // placeholder="Search..." + strVal = init.text; + } else if ( + init && + ts.isJsxExpression(init) && + init.expression && + (ts.isStringLiteral(init.expression) || + ts.isNoSubstitutionTemplateLiteral(init.expression)) + ) { + // placeholder={"Search..."} or {`Search`} — still a plain literal, + // NOT a t(...) call or interpolation. + strVal = init.expression.text; + } + // If init is a t(...) call or any other expression, strVal stays + // undefined and we skip it (already localized / dynamic). + if (strVal !== undefined && isDisplayString(strVal)) { + findings.push({ + line: lineOf(node), + kind: `attr:${attrName}`, + text: truncate(strVal), + }); + } + } + } + + ts.forEachChild(node, visit); + } + visit(sf); + + return findings; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +function rel(p) { + // Relative to the frontend/ root (parent of src), for readable report paths. + return path.relative(path.dirname(cfg.frontendSrc), p); +} + +function main() { + // Build the file list, restricted to the scan subdirs under src. + let files = []; + for (const sub of SCAN_SUBDIRS) { + files = files.concat(collectFiles(path.join(cfg.frontendSrc, sub))); + } + files.sort(); + + const perFile = new Map(); // relFile -> [findings] + const byKind = {}; // kind -> count + let total = 0; + + for (const file of files) { + const findings = extractFromFile(file); + if (findings.length === 0) continue; + const r = rel(file); + perFile.set(r, findings); + for (const f of findings) { + byKind[f.kind] = (byKind[f.kind] || 0) + 1; + total += 1; + } + } + + // Collapse attr:* kinds for the summary breakdown (and keep raw too). + const kindBreakdown = {}; + for (const [k, n] of Object.entries(byKind)) { + kindBreakdown[k] = n; + } + + const summary = { + generatedAt: new Date().toISOString(), + inputs: { frontendSrc: cfg.frontendSrc, tsModule: cfg.tsModule }, + filesScanned: files.length, + filesAffected: perFile.size, + totalFindings: total, + byKind: kindBreakdown, + }; + + // Flat findings list (machine-readable). + const findings = []; + for (const [file, list] of [...perFile.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + for (const f of list) { + findings.push({ file, line: f.line, kind: f.kind, text: f.text }); + } + } + + const report = { summary, findings }; + + fs.mkdirSync(path.dirname(cfg.outJson), { recursive: true }); + fs.writeFileSync(cfg.outJson, JSON.stringify(report, null, 2)); + fs.writeFileSync(cfg.outMd, renderMarkdown(report, perFile)); + + console.log(JSON.stringify(summary, null, 2)); + return report; +} + +function renderMarkdown(report, perFile) { + const { summary } = report; + let md = ""; + md += `# Dashboard Hardcoded-String Audit (i18n triage)\n\n`; + md += `_Generated: ${summary.generatedAt}_\n\n`; + md += `Reproduce: \`node scripts/audit-hardcoded-strings.mjs\` (paths overridable via flags/env).\n\n`; + + md += `## How to read this\n\n`; + md += `This is a **heuristic triage list**, not an auto-fixer. It flags user-facing English text `; + md += `(JSX text and a small allowlist of attributes) that is **not** wrapped in a \`t(...)\` call. `; + md += `**False positives are expected** — e.g. brand names, code-ish text in JSX, or strings that are `; + md += `intentionally untranslated. Treat each finding as a candidate to review, not a guaranteed bug. `; + md += `Prioritize files with the highest finding counts (see the ranking below).\n\n`; + md += `**Spot-check (2026-06-04):** ~15 findings were checked against source; the estimated `; + md += `false-positive rate is roughly **5–10%**. The residual false positives are mostly `; + md += `brand-only single-word labels (e.g. \`alt="LoyaltyDog Logo"\`, \`label="WordPress"\`) and `; + md += `short tooltips (e.g. \`title="Sync"\`) — user-facing, but with little/nothing to translate. `; + md += `Most JSX-text findings are genuine un-localized English copy.\n\n`; + + md += `## Summary\n\n`; + md += `| Metric | Value |\n|---|---|\n`; + md += `| Files scanned | ${summary.filesScanned} |\n`; + md += `| Files affected | ${summary.filesAffected} |\n`; + md += `| Total findings | ${summary.totalFindings} |\n`; + for (const [k, n] of Object.entries(summary.byKind).sort()) { + md += `| Kind \`${k}\` | ${n} |\n`; + } + md += `\n`; + + md += `### Inputs\n\n`; + md += `- Frontend src: \`${summary.inputs.frontendSrc}\`\n`; + md += `- TypeScript module: \`${summary.inputs.tsModule}\`\n\n`; + + // Ranking: top files by finding count. + const ranked = [...perFile.entries()] + .map(([file, list]) => ({ file, count: list.length })) + .sort((a, b) => b.count - a.count || a.file.localeCompare(b.file)); + md += `## Top files by finding count\n\n`; + md += `These are the highest-value un-localized surfaces to prioritize.\n\n`; + md += `| # | File | Findings |\n|---|---|---|\n`; + ranked.slice(0, 15).forEach((r, i) => { + md += `| ${i + 1} | \`${r.file}\` | ${r.count} |\n`; + }); + md += `\n`; + + // Findings grouped by file, sorted by file path. + md += `## Findings by file\n\n`; + const sortedFiles = [...perFile.entries()].sort((a, b) => a[0].localeCompare(b[0])); + for (const [file, list] of sortedFiles) { + md += `### \`${file}\` (${list.length})\n\n`; + md += `| Line | Kind | Text |\n|---|---|---|\n`; + for (const f of [...list].sort((a, b) => a.line - b.line)) { + // Escape pipes/backticks in displayed text for table safety. + const safe = f.text.replace(/\|/g, "\\|").replace(/`/g, "'"); + md += `| ${f.line} | \`${f.kind}\` | ${safe} |\n`; + } + md += `\n`; + } + + return md; +} + +main(); From a7ea90866655028abe0ee488ab8e7e63d0f5bc96 Mon Sep 17 00:00:00 2001 From: Haim Barad Date: Thu, 4 Jun 2026 20:08:47 +0300 Subject: [PATCH 3/4] feat(i18n): add aiAdvisor admin keys + nav.noProgram across all 8 locales (audit M1) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/i18n/locales/en-GB/common.json | 16 +++++++++++++++- packages/i18n/locales/en-US/common.json | 16 +++++++++++++++- packages/i18n/locales/es-ES/common.json | 16 +++++++++++++++- packages/i18n/locales/es-MX/common.json | 16 +++++++++++++++- packages/i18n/locales/fr/common.json | 16 +++++++++++++++- packages/i18n/locales/it/common.json | 16 +++++++++++++++- packages/i18n/locales/pt-BR/common.json | 16 +++++++++++++++- packages/i18n/locales/pt-PT/common.json | 16 +++++++++++++++- 8 files changed, 120 insertions(+), 8 deletions(-) diff --git a/packages/i18n/locales/en-GB/common.json b/packages/i18n/locales/en-GB/common.json index 8ed7b52..0eaa520 100644 --- a/packages/i18n/locales/en-GB/common.json +++ b/packages/i18n/locales/en-GB/common.json @@ -14,6 +14,7 @@ "logout": "Log Out", "programs": "Programs", "newProgram": "New Programme", + "noProgram": "No Programme Selected", "customers": "Customers", "offers": "Offers", "vouchers": "Vouchers", @@ -1112,7 +1113,20 @@ "errorTitle": "Error Title", "retryConnection": "Retry Connection", "loadingTitle": "Loading Title", - "loadingDescription": "Loading Description" + "loadingDescription": "Loading Description", + "aiConnected": "AI Connected", + "aiDisconnected": "AI Disconnected", + "aiStatus": "AI Status", + "applied": "Applied: {{description}}", + "failedToFetchData": "Failed to fetch data", + "fromSource": " from {{source}}", + "initialSummaryMessage": "Initial summary message", + "liveData": "Live data", + "noDataDescription": "No data available", + "noDataTitle": "No Data", + "pageTitle": "AI Advisor", + "pocNotice": "Showing {{count}} transactions (POC)", + "refreshData": "Refresh data" }, "shopify": { "plans": { diff --git a/packages/i18n/locales/en-US/common.json b/packages/i18n/locales/en-US/common.json index 06404aa..a7c1177 100644 --- a/packages/i18n/locales/en-US/common.json +++ b/packages/i18n/locales/en-US/common.json @@ -24,6 +24,7 @@ "logout": "Log Out", "programs": "Programs", "newProgram": "New Program", + "noProgram": "No Program Selected", "customers": "Customers", "offers": "Offers", "vouchers": "Vouchers", @@ -901,7 +902,20 @@ "errorTitle": "Connection Error", "retryConnection": "Retry Connection", "loadingTitle": "Loading Loyalty Data", - "loadingDescription": "Connecting to Loyalty Dog API..." + "loadingDescription": "Connecting to Loyalty Dog API...", + "aiConnected": "AI Connected", + "aiDisconnected": "AI Disconnected", + "aiStatus": "AI Status", + "applied": "Applied: {{description}}", + "failedToFetchData": "Failed to fetch data", + "fromSource": " from {{source}}", + "initialSummaryMessage": "Initial summary message", + "liveData": "Live data", + "noDataDescription": "No data available", + "noDataTitle": "No Data", + "pageTitle": "AI Advisor", + "pocNotice": "Showing {{count}} transactions (POC)", + "refreshData": "Refresh data" }, "shopify": { "plans": { diff --git a/packages/i18n/locales/es-ES/common.json b/packages/i18n/locales/es-ES/common.json index 9e0024e..8f87353 100644 --- a/packages/i18n/locales/es-ES/common.json +++ b/packages/i18n/locales/es-ES/common.json @@ -14,6 +14,7 @@ "logout": "Cerrar sesión", "programs": "Programas", "newProgram": "Nuevo programa", + "noProgram": "Ningún Programa Seleccionado", "customers": "Clientes", "offers": "Ofertas", "vouchers": "Vales", @@ -891,7 +892,20 @@ "errorTitle": "Error Title", "retryConnection": "Reintentar conexión", "loadingTitle": "Loading Title", - "loadingDescription": "Loading Description" + "loadingDescription": "Loading Description", + "aiConnected": "IA conectada", + "aiDisconnected": "IA desconectada", + "aiStatus": "Estado de la IA", + "applied": "Aplicado: {{description}}", + "failedToFetchData": "Error al obtener los datos", + "fromSource": " de {{source}}", + "initialSummaryMessage": "Mensaje de resumen inicial", + "liveData": "Datos en vivo", + "noDataDescription": "No hay datos disponibles", + "noDataTitle": "Sin Datos", + "pageTitle": "Asesor de IA", + "pocNotice": "Mostrando {{count}} transacciones (POC)", + "refreshData": "Actualizar datos" }, "shopify": { "plans": { diff --git a/packages/i18n/locales/es-MX/common.json b/packages/i18n/locales/es-MX/common.json index bb8fbdf..b38451e 100644 --- a/packages/i18n/locales/es-MX/common.json +++ b/packages/i18n/locales/es-MX/common.json @@ -14,6 +14,7 @@ "logout": "Cerrar sesión", "programs": "Programas", "newProgram": "Nuevo programa", + "noProgram": "Ningún Programa Seleccionado", "customers": "Clientes", "offers": "Ofertas", "vouchers": "Vales", @@ -891,7 +892,20 @@ "errorTitle": "Error Title", "retryConnection": "Reintentar conexión", "loadingTitle": "Loading Title", - "loadingDescription": "Loading Description" + "loadingDescription": "Loading Description", + "aiConnected": "IA conectada", + "aiDisconnected": "IA desconectada", + "aiStatus": "Estado de la IA", + "applied": "Aplicado: {{description}}", + "failedToFetchData": "Error al obtener los datos", + "fromSource": " de {{source}}", + "initialSummaryMessage": "Mensaje de resumen inicial", + "liveData": "Datos en vivo", + "noDataDescription": "No hay datos disponibles", + "noDataTitle": "Sin Datos", + "pageTitle": "Asesor de IA", + "pocNotice": "Mostrando {{count}} transacciones (POC)", + "refreshData": "Actualizar datos" }, "shopify": { "plans": { diff --git a/packages/i18n/locales/fr/common.json b/packages/i18n/locales/fr/common.json index f1a36f8..edffa38 100644 --- a/packages/i18n/locales/fr/common.json +++ b/packages/i18n/locales/fr/common.json @@ -14,6 +14,7 @@ "logout": "Se déconnecter", "programs": "Programmes", "newProgram": "Nouveau programme", + "noProgram": "Aucun Programme Sélectionné", "customers": "Clients", "offers": "Offres", "vouchers": "Bons", @@ -891,7 +892,20 @@ "errorTitle": "Error Title", "retryConnection": "Réessayer la connexion", "loadingTitle": "Loading Title", - "loadingDescription": "Loading Description" + "loadingDescription": "Loading Description", + "aiConnected": "IA connectée", + "aiDisconnected": "IA déconnectée", + "aiStatus": "État de l'IA", + "applied": "Appliqué: {{description}}", + "failedToFetchData": "Échec de la récupération des données", + "fromSource": " de {{source}}", + "initialSummaryMessage": "Message de résumé initial", + "liveData": "Données en direct", + "noDataDescription": "Aucune donnée disponible", + "noDataTitle": "Aucune Donnée", + "pageTitle": "Conseiller IA", + "pocNotice": "Affichage de {{count}} transactions (POC)", + "refreshData": "Actualiser les données" }, "shopify": { "plans": { diff --git a/packages/i18n/locales/it/common.json b/packages/i18n/locales/it/common.json index a1eb95b..7cc5615 100644 --- a/packages/i18n/locales/it/common.json +++ b/packages/i18n/locales/it/common.json @@ -24,6 +24,7 @@ "logout": "Disconnettersi", "programs": "Programmi", "newProgram": "Nuovo programma", + "noProgram": "Nessun Programma Selezionato", "customers": "Clienti", "offers": "Offerte", "vouchers": "Buoni", @@ -901,7 +902,20 @@ "errorTitle": "Error Title", "retryConnection": "Retry Connection", "loadingTitle": "Loading Title", - "loadingDescription": "Loading Description" + "loadingDescription": "Loading Description", + "aiConnected": "IA connessa", + "aiDisconnected": "IA disconnessa", + "aiStatus": "Stato dell'IA", + "applied": "Applicato: {{description}}", + "failedToFetchData": "Impossibile recuperare i dati", + "fromSource": " da {{source}}", + "initialSummaryMessage": "Messaggio di riepilogo iniziale", + "liveData": "Dati in tempo reale", + "noDataDescription": "Nessun dato disponibile", + "noDataTitle": "Nessun Dato", + "pageTitle": "Consulente IA", + "pocNotice": "Visualizzazione di {{count}} transazioni (POC)", + "refreshData": "Aggiorna dati" }, "shopify": { "plans": { diff --git a/packages/i18n/locales/pt-BR/common.json b/packages/i18n/locales/pt-BR/common.json index 8dbe764..60f2e0e 100644 --- a/packages/i18n/locales/pt-BR/common.json +++ b/packages/i18n/locales/pt-BR/common.json @@ -24,6 +24,7 @@ "logout": "Sair do sistema", "programs": "Programas", "newProgram": "Novo Programa", + "noProgram": "Nenhum Programa Selecionado", "customers": "Clientes", "offers": "Ofertas", "vouchers": "Vale-presentes", @@ -901,7 +902,20 @@ "errorTitle": "Error Title", "retryConnection": "Tentar conexão novamente", "loadingTitle": "Loading Title", - "loadingDescription": "Loading Description" + "loadingDescription": "Loading Description", + "aiConnected": "IA conectada", + "aiDisconnected": "IA desconectada", + "aiStatus": "Status da IA", + "applied": "Aplicado: {{description}}", + "failedToFetchData": "Falha ao buscar os dados", + "fromSource": " de {{source}}", + "initialSummaryMessage": "Mensagem de resumo inicial", + "liveData": "Dados em tempo real", + "noDataDescription": "Nenhum dado disponível", + "noDataTitle": "Sem Dados", + "pageTitle": "Consultor IA", + "pocNotice": "Exibindo {{count}} transações (POC)", + "refreshData": "Atualizar dados" }, "shopify": { "plans": { diff --git a/packages/i18n/locales/pt-PT/common.json b/packages/i18n/locales/pt-PT/common.json index 96e12de..e4dfd88 100644 --- a/packages/i18n/locales/pt-PT/common.json +++ b/packages/i18n/locales/pt-PT/common.json @@ -14,6 +14,7 @@ "logout": "Sair do sistema", "programs": "Programas", "newProgram": "Novo Programa", + "noProgram": "Nenhum Programa Selecionado", "customers": "Clientes", "offers": "Ofertas", "vouchers": "Cupões", @@ -891,7 +892,20 @@ "errorTitle": "Error Title", "retryConnection": "Tentar novamente a ligação", "loadingTitle": "Loading Title", - "loadingDescription": "Loading Description" + "loadingDescription": "Loading Description", + "aiConnected": "IA ligada", + "aiDisconnected": "IA desligada", + "aiStatus": "Estado da IA", + "applied": "Aplicado: {{description}}", + "failedToFetchData": "Falha ao obter os dados", + "fromSource": " de {{source}}", + "initialSummaryMessage": "Mensagem de resumo inicial", + "liveData": "Dados em direto", + "noDataDescription": "Nenhum dado disponível", + "noDataTitle": "Sem Dados", + "pageTitle": "Consultor IA", + "pocNotice": "A mostrar {{count}} transações (POC)", + "refreshData": "Atualizar dados" }, "shopify": { "plans": { From 3a012f5dabfb731ba61c797dd26f3c7c9a0bb1ac Mon Sep 17 00:00:00 2001 From: Haim Barad Date: Thu, 4 Jun 2026 20:31:20 +0300 Subject: [PATCH 4/4] fix(audit): namespace-resolved key matching + output-dir/regex/existsSync fixes (codeant+greptile) - audit-frontend-keys: classify refs against their RESOLVED namespace only (drop inJsonAnywhere/inBundleAnywhere); surface wrong-namespace refs in a new informational `namespaceMismatch` category instead of hiding them or forcing into M1/M3. - audit-frontend-keys: add existsSync guard to collectFiles (parity with sibling). - both scripts: mkdir the markdown out-dir before writing (was ENOENT on a missing --out-md dir). - audit-hardcoded-strings: escape hyphen in className char class so `%-:` is not an unintended range (no longer matches & ' ( ) * + ,). - regenerate committed reports against current locale: M1=0, M3=0, namespaceMismatch=0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../audits/2026-06-04-frontend-key-audit.json | 204 +----------------- docs/audits/2026-06-04-frontend-key-audit.md | 55 ++--- docs/audits/2026-06-04-hardcoded-strings.json | 2 +- docs/audits/2026-06-04-hardcoded-strings.md | 2 +- scripts/audit-frontend-keys.mjs | 85 ++++++-- scripts/audit-hardcoded-strings.mjs | 3 +- 6 files changed, 101 insertions(+), 250 deletions(-) diff --git a/docs/audits/2026-06-04-frontend-key-audit.json b/docs/audits/2026-06-04-frontend-key-audit.json index be046da..add3508 100644 --- a/docs/audits/2026-06-04-frontend-key-audit.json +++ b/docs/audits/2026-06-04-frontend-key-audit.json @@ -1,6 +1,6 @@ { "summary": { - "generatedAt": "2026-06-04T16:23:02.310Z", + "generatedAt": "2026-06-04T17:29:36.751Z", "inputs": { "frontendSrc": "/tmp/wt-core_api-i18n/frontend/src", "localeDir": "/tmp/wt-locplat-i18n/packages/i18n/locales/en-US", @@ -12,7 +12,7 @@ "uniqueReferencedKeys": 1858, "localeNamespaces": { "clover": 35, - "common": 2768, + "common": 2782, "emails": 409, "eposnow": 18, "errors": 284, @@ -28,196 +28,25 @@ "marketing": 32 }, "counts": { - "m1_missingFromJson": 14, + "m1_missingFromJson": 0, "m2_referenced_jsonNotBundle": 0, - "m2_referenced_bundleNotJson": 14, + "m2_referenced_bundleNotJson": 0, "m2_full_jsonNotBundle": 2187, - "m2_full_bundleNotJson": 18, + "m2_full_bundleNotJson": 4, "m3_missingFromBoth": 0, + "namespaceMismatch": 0, "dynamic_manualReview": 32 } }, - "m1": { - "common": [ - { - "key": "app.admin.aiAdvisor.initialSummaryMessage", - "locations": [ - { - "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", - "line": 70, - "shape": "client" - } - ], - "inBundle": true - }, - { - "key": "app.admin.aiAdvisor.failedToFetchData", - "locations": [ - { - "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", - "line": 103, - "shape": "client" - } - ], - "inBundle": true - }, - { - "key": "app.admin.aiAdvisor.pageTitle", - "locations": [ - { - "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", - "line": 189, - "shape": "client" - } - ], - "inBundle": true - }, - { - "key": "app.admin.aiAdvisor.liveData", - "locations": [ - { - "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", - "line": 237, - "shape": "client" - } - ], - "inBundle": true - }, - { - "key": "app.admin.aiAdvisor.applied", - "locations": [ - { - "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", - "line": 247, - "shape": "client" - } - ], - "inBundle": true - }, - { - "key": "app.admin.aiAdvisor.fromSource", - "locations": [ - { - "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", - "line": 248, - "shape": "client" - } - ], - "inBundle": true - }, - { - "key": "app.admin.aiAdvisor.aiStatus", - "locations": [ - { - "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", - "line": 259, - "shape": "client" - } - ], - "inBundle": true - }, - { - "key": "app.admin.aiAdvisor.aiConnected", - "locations": [ - { - "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", - "line": 262, - "shape": "client" - } - ], - "inBundle": true - }, - { - "key": "app.admin.aiAdvisor.aiDisconnected", - "locations": [ - { - "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", - "line": 263, - "shape": "client" - } - ], - "inBundle": true - }, - { - "key": "app.admin.aiAdvisor.refreshData", - "locations": [ - { - "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", - "line": 268, - "shape": "client" - } - ], - "inBundle": true - }, - { - "key": "app.admin.aiAdvisor.noDataTitle", - "locations": [ - { - "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", - "line": 299, - "shape": "client" - } - ], - "inBundle": true - }, - { - "key": "app.admin.aiAdvisor.noDataDescription", - "locations": [ - { - "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", - "line": 300, - "shape": "client" - } - ], - "inBundle": true - }, - { - "key": "app.admin.aiAdvisor.pocNotice", - "locations": [ - { - "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", - "line": 308, - "shape": "client" - } - ], - "inBundle": true - }, - { - "key": "nav.noProgram", - "locations": [ - { - "file": "src/components/layouts/Navbar.tsx", - "line": 236, - "shape": "client" - } - ], - "inBundle": true - } - ] - }, + "m1": {}, "m2": { "referenced": { "jsonNotBundle": [], - "bundleNotJson": [ - "common:nav.noProgram", - "common:app.admin.aiAdvisor.aiConnected", - "common:app.admin.aiAdvisor.aiDisconnected", - "common:app.admin.aiAdvisor.aiStatus", - "common:app.admin.aiAdvisor.applied", - "common:app.admin.aiAdvisor.failedToFetchData", - "common:app.admin.aiAdvisor.fromSource", - "common:app.admin.aiAdvisor.initialSummaryMessage", - "common:app.admin.aiAdvisor.liveData", - "common:app.admin.aiAdvisor.noDataDescription", - "common:app.admin.aiAdvisor.noDataTitle", - "common:app.admin.aiAdvisor.pageTitle", - "common:app.admin.aiAdvisor.pocNotice", - "common:app.admin.aiAdvisor.refreshData" - ] + "bundleNotJson": [] }, "full": { "jsonNotBundleCount": 2187, - "bundleNotJsonCount": 18, + "bundleNotJsonCount": 4, "jsonNotBundle": [ "clover:clover.pos.title", "clover:clover.pos.enrollPrompt", @@ -2408,20 +2237,6 @@ "wordpress:wordpress.privacy.whenYouUseLoyaltydogYourEmail" ], "bundleNotJson": [ - "common:nav.noProgram", - "common:app.admin.aiAdvisor.aiConnected", - "common:app.admin.aiAdvisor.aiDisconnected", - "common:app.admin.aiAdvisor.aiStatus", - "common:app.admin.aiAdvisor.applied", - "common:app.admin.aiAdvisor.failedToFetchData", - "common:app.admin.aiAdvisor.fromSource", - "common:app.admin.aiAdvisor.initialSummaryMessage", - "common:app.admin.aiAdvisor.liveData", - "common:app.admin.aiAdvisor.noDataDescription", - "common:app.admin.aiAdvisor.noDataTitle", - "common:app.admin.aiAdvisor.pageTitle", - "common:app.admin.aiAdvisor.pocNotice", - "common:app.admin.aiAdvisor.refreshData", "common:app.auth.register.dataProcessingAgreement", "common:app.auth.register.privacyPolicy", "common:app.auth.register.termsOfService", @@ -2430,6 +2245,7 @@ } }, "m3": [], + "namespaceMismatch": [], "dynamic": [ { "file": "src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx", diff --git a/docs/audits/2026-06-04-frontend-key-audit.md b/docs/audits/2026-06-04-frontend-key-audit.md index 6b9168f..e0ccf54 100644 --- a/docs/audits/2026-06-04-frontend-key-audit.md +++ b/docs/audits/2026-06-04-frontend-key-audit.md @@ -1,6 +1,6 @@ # Frontend i18n Key-Reference Audit -_Generated: 2026-06-04T16:23:02.310Z_ +_Generated: 2026-06-04T17:29:36.751Z_ Reproduce: `node scripts/audit-frontend-keys.mjs` (paths overridable via flags/env). @@ -11,12 +11,13 @@ Reproduce: `node scripts/audit-frontend-keys.mjs` (paths overridable via flags/e | Files scanned | 716 | | Total `t()` reference occurrences | 2124 | | Unique referenced keys (ns:key) | 1858 | -| **M1** referenced but MISSING from en-US JSON | **14** | +| **M1** referenced but MISSING from en-US JSON | **0** | | **M3** referenced but MISSING from BOTH JSON & bundle | **0** | +| Namespace mismatch (key exists, wrong namespace) | 0 | | M2 referenced: in JSON, not in bundle | 0 | -| M2 referenced: in bundle, not in JSON | 14 | +| M2 referenced: in bundle, not in JSON | 0 | | M2 full drift: in JSON, not in bundle | 2187 | -| M2 full drift: in bundle, not in JSON | 18 | +| M2 full drift: in bundle, not in JSON | 4 | | Dynamic keys (manual review) | 32 | ### Inputs @@ -29,7 +30,7 @@ Reproduce: `node scripts/audit-frontend-keys.mjs` (paths overridable via flags/e ### Locale JSON namespaces (leaf key counts) - `clover`: 35 -- `common`: 2768 +- `common`: 2782 - `emails`: 409 - `eposnow`: 18 - `errors`: 284 @@ -50,22 +51,7 @@ Reproduce: `node scripts/audit-frontend-keys.mjs` (paths overridable via flags/e These keys must be ADDED to the en-US source-of-truth (grouped by namespace). `(bundled)` = also present in the frontend fallback bundle (so it renders today but isn't in source JSON). -### `common` (14) - -- `app.admin.aiAdvisor.aiConnected` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:262 -- `app.admin.aiAdvisor.aiDisconnected` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:263 -- `app.admin.aiAdvisor.aiStatus` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:259 -- `app.admin.aiAdvisor.applied` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:247 -- `app.admin.aiAdvisor.failedToFetchData` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:103 -- `app.admin.aiAdvisor.fromSource` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:248 -- `app.admin.aiAdvisor.initialSummaryMessage` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:70 -- `app.admin.aiAdvisor.liveData` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:237 -- `app.admin.aiAdvisor.noDataDescription` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:300 -- `app.admin.aiAdvisor.noDataTitle` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:299 -- `app.admin.aiAdvisor.pageTitle` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:189 -- `app.admin.aiAdvisor.pocNotice` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:308 -- `app.admin.aiAdvisor.refreshData` _(bundled)_ — src/app/(cpanel)/(admin)/admin/ai-advisor/page.tsx:268 -- `nav.noProgram` _(bundled)_ — src/components/layouts/Navbar.tsx:236 +_None._ ## M3 — Referenced but MISSING from BOTH JSON and bundle (likely code bug) @@ -73,6 +59,12 @@ Probable wrong path/casing in frontend code — these need a CODE FIX. _None._ +## Namespace mismatch — key exists, but under a DIFFERENT namespace + +The code references `resolvedNs:key`, and `key` is absent from `resolvedNs` but present under another namespace in JSON (and/or bundle). These are informational: either the code's namespace attribution is wrong, or the resolved namespace (often via the nearest `useTranslation` heuristic) mis-attributed the call. They are NOT counted as truly-missing (M1/M3). Spot-check before acting. + +_None._ + ## M2 — JSON / bundle drift ### Referenced keys present in JSON but NOT in bundle (0) @@ -81,27 +73,14 @@ These render from the API but would be missing if the backend is unreachable (fa _None._ -### Referenced keys present in bundle but NOT in JSON (14) - -- `common:app.admin.aiAdvisor.aiConnected` -- `common:app.admin.aiAdvisor.aiDisconnected` -- `common:app.admin.aiAdvisor.aiStatus` -- `common:app.admin.aiAdvisor.applied` -- `common:app.admin.aiAdvisor.failedToFetchData` -- `common:app.admin.aiAdvisor.fromSource` -- `common:app.admin.aiAdvisor.initialSummaryMessage` -- `common:app.admin.aiAdvisor.liveData` -- `common:app.admin.aiAdvisor.noDataDescription` -- `common:app.admin.aiAdvisor.noDataTitle` -- `common:app.admin.aiAdvisor.pageTitle` -- `common:app.admin.aiAdvisor.pocNotice` -- `common:app.admin.aiAdvisor.refreshData` -- `common:nav.noProgram` +### Referenced keys present in bundle but NOT in JSON (0) + +_None._ ### Full drift (all keys, not just referenced) - In JSON but not bundle: **2187** -- In bundle but not JSON: **18** +- In bundle but not JSON: **4** ## Dynamic keys — manual review diff --git a/docs/audits/2026-06-04-hardcoded-strings.json b/docs/audits/2026-06-04-hardcoded-strings.json index aea7679..15b063d 100644 --- a/docs/audits/2026-06-04-hardcoded-strings.json +++ b/docs/audits/2026-06-04-hardcoded-strings.json @@ -1,6 +1,6 @@ { "summary": { - "generatedAt": "2026-06-04T16:31:55.856Z", + "generatedAt": "2026-06-04T17:29:42.161Z", "inputs": { "frontendSrc": "/tmp/wt-core_api-i18n/frontend/src", "tsModule": "/tmp/wt-core_api-i18n/frontend/node_modules/typescript" diff --git a/docs/audits/2026-06-04-hardcoded-strings.md b/docs/audits/2026-06-04-hardcoded-strings.md index f058c58..61c9f37 100644 --- a/docs/audits/2026-06-04-hardcoded-strings.md +++ b/docs/audits/2026-06-04-hardcoded-strings.md @@ -1,6 +1,6 @@ # Dashboard Hardcoded-String Audit (i18n triage) -_Generated: 2026-06-04T16:31:55.856Z_ +_Generated: 2026-06-04T17:29:42.161Z_ Reproduce: `node scripts/audit-hardcoded-strings.mjs` (paths overridable via flags/env). diff --git a/scripts/audit-frontend-keys.mjs b/scripts/audit-frontend-keys.mjs index 77de556..0df567f 100644 --- a/scripts/audit-frontend-keys.mjs +++ b/scripts/audit-frontend-keys.mjs @@ -128,6 +128,7 @@ function isExcludedFile(p) { return false; } function collectFiles(dir, out = []) { + if (!fs.existsSync(dir)) return out; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { @@ -371,9 +372,6 @@ function main() { const localeNs = loadLocaleJson(cfg.localeDir); // ns -> Set const bundleNs = loadBundle(cfg.fallback); // ns -> Set - const localeAll = new Set(); - for (const s of Object.values(localeNs)) for (const k of s) localeAll.add(k); - // Known namespaces (for stripping i18next `ns:key` prefixes during extraction). const knownNamespaces = new Set([...Object.keys(localeNs), ...Object.keys(bundleNs)]); @@ -397,22 +395,55 @@ function main() { const totalRefOccurrences = [...refMap.values()].reduce((n, r) => n + r.locations.length, 0); + // Helper: which namespaces contain `key` (in a given ns->Set map)? + function namespacesContaining(nsMap, key) { + const found = []; + for (const [ns, set] of Object.entries(nsMap)) { + if (set.has(key)) found.push(ns); + } + return found; + } + // Classify each unique referenced key. - const m1 = {}; // ns -> [{key, locations}] (missing from JSON) - const m3 = []; // [{ns,key,locations}] (missing from JSON AND bundle) + // + // A reference is "present" ONLY when the key exists in its RESOLVED namespace + // (inJsonNs / inBundleNs). We deliberately do NOT treat a key found under a + // DIFFERENT namespace as present — that would hide wrong-namespace references + // and under-report real missing keys in M1/M3. + // + // References whose key is absent from its resolved ns but present under some + // OTHER namespace are surfaced in `namespaceMismatch` (informational) rather + // than forced into M1/M3 (which represent truly-absent keys). + const m1 = {}; // ns -> [{key, locations}] (missing from JSON in resolved ns) + const m3 = []; // [{ns,key,locations}] (missing from JSON AND bundle in resolved ns) + const namespaceMismatch = []; // [{ns,key,locations,foundInJsonNs,foundInBundleNs}] for (const ref of refMap.values()) { const inJsonNs = localeNs[ref.ns]?.has(ref.key) ?? false; - // A key may exist under its declared ns OR (defensively) anywhere in JSON. - const inJsonAnywhere = localeAll.has(ref.key); - const inJson = inJsonNs || inJsonAnywhere; const inBundleNs = bundleNs[ref.ns]?.has(ref.key) ?? false; - const inBundleAnywhere = Object.values(bundleNs).some((s) => s.has(ref.key)); - const inBundle = inBundleNs || inBundleAnywhere; - if (!inJson) { - (m1[ref.ns] ??= []).push({ key: ref.key, locations: ref.locations, inBundle }); + // Present in the RESOLVED namespace? If so, nothing to report. + if (inJsonNs) continue; + + // Not in resolved JSON ns. Is it present under some OTHER namespace? + const otherJsonNs = namespacesContaining(localeNs, ref.key).filter((n) => n !== ref.ns); + const otherBundleNs = namespacesContaining(bundleNs, ref.key).filter((n) => n !== ref.ns); + + if (otherJsonNs.length > 0 || (inBundleNs === false && otherBundleNs.length > 0)) { + // Exists somewhere, just not under the resolved namespace -> mismatch. + namespaceMismatch.push({ + ns: ref.ns, + key: ref.key, + locations: ref.locations, + foundInJsonNs: otherJsonNs, + foundInBundleNs: otherBundleNs, + inResolvedBundleNs: inBundleNs, + }); + continue; } - if (!inJson && !inBundle) { + + // Genuinely absent from the resolved JSON namespace (and not a mismatch). + (m1[ref.ns] ??= []).push({ key: ref.key, locations: ref.locations, inBundle: inBundleNs }); + if (!inBundleNs) { m3.push({ ns: ref.ns, key: ref.key, locations: ref.locations }); } } @@ -467,14 +498,16 @@ function main() { m2_full_jsonNotBundle: m2.full.jsonNotBundleCount, m2_full_bundleNotJson: m2.full.bundleNotJsonCount, m3_missingFromBoth: m3.length, + namespaceMismatch: namespaceMismatch.length, dynamic_manualReview: dynamicList.length, }, }; - const report = { summary, m1, m2, m3, dynamic: dynamicList, multiNsFiles }; + const report = { summary, m1, m2, m3, namespaceMismatch, dynamic: dynamicList, multiNsFiles }; // ----- write JSON ----- fs.mkdirSync(path.dirname(cfg.outJson), { recursive: true }); + fs.mkdirSync(path.dirname(cfg.outMd), { recursive: true }); fs.writeFileSync(cfg.outJson, JSON.stringify(report, null, 2)); // ----- write Markdown ----- @@ -490,7 +523,7 @@ function locStr(locations) { } function renderMarkdown(report) { - const { summary, m1, m2, m3, dynamic, multiNsFiles } = report; + const { summary, m1, m2, m3, namespaceMismatch = [], dynamic, multiNsFiles } = report; const c = summary.counts; let md = ""; md += `# Frontend i18n Key-Reference Audit\n\n`; @@ -504,6 +537,7 @@ function renderMarkdown(report) { md += `| Unique referenced keys (ns:key) | ${summary.uniqueReferencedKeys} |\n`; md += `| **M1** referenced but MISSING from en-US JSON | **${c.m1_missingFromJson}** |\n`; md += `| **M3** referenced but MISSING from BOTH JSON & bundle | **${c.m3_missingFromBoth}** |\n`; + md += `| Namespace mismatch (key exists, wrong namespace) | ${c.namespaceMismatch} |\n`; md += `| M2 referenced: in JSON, not in bundle | ${c.m2_referenced_jsonNotBundle} |\n`; md += `| M2 referenced: in bundle, not in JSON | ${c.m2_referenced_bundleNotJson} |\n`; md += `| M2 full drift: in JSON, not in bundle | ${c.m2_full_jsonNotBundle} |\n`; @@ -547,6 +581,27 @@ function renderMarkdown(report) { md += `\n`; } + // Namespace mismatch + md += `## Namespace mismatch — key exists, but under a DIFFERENT namespace\n\n`; + md += `The code references \`resolvedNs:key\`, and \`key\` is absent from \`resolvedNs\` but `; + md += `present under another namespace in JSON (and/or bundle). These are informational: `; + md += `either the code's namespace attribution is wrong, or the resolved namespace (often via the `; + md += `nearest \`useTranslation\` heuristic) mis-attributed the call. They are NOT counted as `; + md += `truly-missing (M1/M3). Spot-check before acting.\n\n`; + if (namespaceMismatch.length === 0) md += `_None._\n\n`; + else { + const sorted = [...namespaceMismatch].sort((a, b) => + `${a.ns}:${a.key}`.localeCompare(`${b.ns}:${b.key}`)); + for (const it of sorted) { + const where = [ + it.foundInJsonNs.length ? `JSON ns: ${it.foundInJsonNs.join(", ")}` : null, + it.foundInBundleNs.length ? `bundle ns: ${it.foundInBundleNs.join(", ")}` : null, + ].filter(Boolean).join("; "); + md += `- \`${it.ns}:${it.key}\` — found in [${where}] — ${locStr(it.locations)}\n`; + } + md += `\n`; + } + // M2 md += `## M2 — JSON / bundle drift\n\n`; md += `### Referenced keys present in JSON but NOT in bundle (${m2.referenced.jsonNotBundle.length})\n\n`; diff --git a/scripts/audit-hardcoded-strings.mjs b/scripts/audit-hardcoded-strings.mjs index 9f2b385..d57d43c 100644 --- a/scripts/audit-hardcoded-strings.mjs +++ b/scripts/audit-hardcoded-strings.mjs @@ -150,7 +150,7 @@ function hasLetters(s) { function looksLikeClassName(s) { const tokens = s.trim().split(/\s+/); if (tokens.length === 0) return false; - const utilLike = tokens.filter((t) => /[-:]/.test(t) && /^[a-z0-9[\]/.#%-:]+$/i.test(t)).length; + const utilLike = tokens.filter((t) => /[-:]/.test(t) && /^[a-z0-9[\]/.#%\-:]+$/i.test(t)).length; return utilLike >= Math.max(1, Math.ceil(tokens.length * 0.6)); } @@ -380,6 +380,7 @@ function main() { const report = { summary, findings }; fs.mkdirSync(path.dirname(cfg.outJson), { recursive: true }); + fs.mkdirSync(path.dirname(cfg.outMd), { recursive: true }); fs.writeFileSync(cfg.outJson, JSON.stringify(report, null, 2)); fs.writeFileSync(cfg.outMd, renderMarkdown(report, perFile));