kasy-cli 1.31.14 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/lib/commands/new.js +15 -1
  2. package/lib/scaffold/CHANGELOG.json +9 -0
  3. package/lib/scaffold/backends/api/patch/README.md +87 -2
  4. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +34 -0
  5. package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  6. package/lib/scaffold/backends/firebase/setup-from-scratch.js +22 -0
  7. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/README.md +26 -22
  8. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -11
  9. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +3 -1
  10. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +60 -3
  11. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +12 -0
  12. package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  13. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +3 -2
  14. package/lib/scaffold/generate.js +1 -1
  15. package/lib/scaffold/shared/generator-utils.js +22 -3
  16. package/lib/utils/i18n/messages-en.js +2 -0
  17. package/lib/utils/i18n/messages-es.js +2 -0
  18. package/lib/utils/i18n/messages-pt.js +2 -0
  19. package/package.json +2 -2
  20. package/templates/firebase/docs/auth-setup.en.md +7 -1
  21. package/templates/firebase/docs/auth-setup.es.md +7 -1
  22. package/templates/firebase/docs/auth-setup.pt.md +7 -1
  23. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +79 -5
  24. package/templates/firebase/lib/components/kasy_accordion.dart +2 -2
  25. package/templates/firebase/lib/components/kasy_alert.dart +1 -1
  26. package/templates/firebase/lib/components/kasy_app_bar.dart +3 -3
  27. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +1 -1
  28. package/templates/firebase/lib/components/kasy_button.dart +8 -8
  29. package/templates/firebase/lib/components/kasy_chip.dart +1 -1
  30. package/templates/firebase/lib/components/kasy_date_picker.dart +26 -21
  31. package/templates/firebase/lib/components/kasy_dialog.dart +2 -2
  32. package/templates/firebase/lib/components/kasy_sidebar.dart +2 -2
  33. package/templates/firebase/lib/components/kasy_tabs.dart +2 -2
  34. package/templates/firebase/lib/components/kasy_text_area.dart +37 -5
  35. package/templates/firebase/lib/components/kasy_text_field.dart +77 -16
  36. package/templates/firebase/lib/components/kasy_toast.dart +1 -1
  37. package/templates/firebase/lib/components/kasy_web_header.dart +4 -3
  38. package/templates/firebase/lib/core/config/features.dart +13 -0
  39. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +21 -0
  40. package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +1 -1
  41. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +1 -1
  42. package/templates/firebase/lib/core/theme/icon_sizes.dart +47 -0
  43. package/templates/firebase/lib/core/theme/shadows.dart +13 -0
  44. package/templates/firebase/lib/core/theme/texts.dart +32 -0
  45. package/templates/firebase/lib/core/theme/theme.dart +2 -0
  46. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -0
  47. package/templates/firebase/lib/core/web_viewport_scale.dart +23 -4
  48. package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +1 -1
  49. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -1
  50. package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +1 -1
  51. package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -1
  52. package/templates/firebase/lib/features/authentication/ui/recover_password_page.dart +3 -1
  53. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +36 -14
  54. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +27 -11
  55. package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +1 -1
  56. package/templates/firebase/lib/features/feedbacks/ui/feedback_page.dart +1 -1
  57. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +1 -1
  58. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +1 -1
  59. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +22 -3
  60. package/templates/firebase/lib/features/home/home_image_grid.dart +1 -1
  61. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +2 -2
  62. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +6 -1
  63. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +35 -38
  64. package/templates/firebase/lib/features/notifications/ui/widgets/permission_request_view.dart +1 -1
  65. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +1 -1
  66. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_module_mockups.dart +3 -3
  67. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +1 -1
  68. package/templates/firebase/lib/features/settings/settings_page.dart +264 -307
  69. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +2 -2
  70. package/templates/firebase/lib/features/settings/ui/components/edit_name_sheet.dart +115 -0
  71. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +2 -2
  72. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +1 -1
  73. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +13 -5
  74. package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  75. package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +7 -1
  76. package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +11 -3
  77. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -1
  78. package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +1 -1
  79. package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +2 -2
  80. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +1 -1
  81. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +1 -1
  82. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +3 -3
  83. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +1 -1
  84. package/templates/firebase/lib/i18n/en.i18n.json +8 -0
  85. package/templates/firebase/lib/i18n/es.i18n.json +8 -0
  86. package/templates/firebase/lib/i18n/pt.i18n.json +8 -0
  87. package/templates/firebase/pubspec.yaml +0 -1
  88. package/templates/firebase/web/stripe_success.html +64 -26
  89. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
  90. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
  91. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
  92. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
  93. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
  94. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
  95. package/templates/firebase/lib/features/authentication/ui/components/facebook_signin.dart +0 -19
  96. package/templates/firebase/login-redesign-preview.png +0 -0
@@ -616,7 +616,7 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
616
616
  * @param {object} [answers={}] - moduleAnswers (rcTestKey, stripe keys, etc.)
617
617
  * @param {string} [language='en'] - User's CLI language (en, pt, es)
618
618
  */
619
- async function writeFeaturesConfig(projectDir, modules, answers = {}, language = 'en') {
619
+ async function writeFeaturesConfig(projectDir, modules, answers = {}, language = 'en', backend = 'firebase') {
620
620
  const withOnboarding = modules.includes('onboarding');
621
621
  const withAiChat = modules.includes('ai_chat');
622
622
  const withFeedback = modules.includes('feedback');
@@ -624,6 +624,10 @@ async function writeFeaturesConfig(projectDir, modules, answers = {}, language =
624
624
  const withStripe = modules.includes('stripe');
625
625
  const withLocalReminders = modules.includes('local_reminders');
626
626
  const withWeb = modules.includes('web');
627
+ // Apple sign-in on web only works on Firebase (signInWithPopup). Supabase/API
628
+ // throw on web (no Service ID + token exchange), so they must ship this false
629
+ // and hide the Apple button on web. Native always shows it.
630
+ const withAppleWebSignin = backend === 'firebase';
627
631
 
628
632
  const f = getStrings(language).features;
629
633
  const content = `${f.comment1}
@@ -635,7 +639,14 @@ const bool withFeedback = ${withFeedback};
635
639
  const bool withRevenuecat = ${withRevenuecat};
636
640
  // Stripe web subscriptions module (independent from RevenueCat mobile IAP).
637
641
  const bool withStripe = ${withStripe};
642
+ // When true, Stripe Checkout shows a promo-code / coupon field.
643
+ const bool withStripePromoCodes = true;
644
+ // When true, the Stripe Customer Portal lets subscribers switch plans (upgrade / downgrade).
645
+ const bool withStripePlanSwitching = true;
638
646
  const bool withLocalReminders = ${withLocalReminders};
647
+ // Apple sign-in on web: Firebase supports it (signInWithPopup); Supabase/API throw
648
+ // on web, so they ship false. Native always shows the Apple button.
649
+ const bool withAppleWebSignin = ${withAppleWebSignin};
639
650
  ${f.comment3}
640
651
  ${f.comment4}
641
652
  ${f.comment5}
@@ -1347,10 +1358,18 @@ async function removeFacebookSigninFromAuthPages(projectDir) {
1347
1358
  for (const p of pages) {
1348
1359
  if (!(await fs.pathExists(p))) continue;
1349
1360
  let content = await fs.readFile(p, 'utf8');
1350
- // Remove import line
1361
+ // Legacy: remove the standalone FacebookSigninComponent + its import, if present.
1351
1362
  content = content.replace(/^import 'package:[^']+\/features\/authentication\/ui\/components\/facebook_signin\.dart';\n/m, '');
1352
- // Remove component line (any leading whitespace)
1353
1363
  content = content.replace(/[ \t]*const FacebookSigninComponent\(\),\n/g, '');
1364
+ // Current UI: the Facebook button is an inline _SocialSigninTile/_SocialSignupTile
1365
+ // in the social row (label: t.auth.signin.facebook ... signinWithFacebook()). Strip
1366
+ // the whole tile plus the SizedBox spacer that precedes it, anchored on the facebook
1367
+ // label so the Google/Apple tiles are never touched. Runs before dartFix/format, so
1368
+ // it matches the kit's raw formatting verbatim.
1369
+ content = content.replace(
1370
+ /\n[ \t]*const SizedBox\(width: KasySpacing\.sm\),\n[ \t]*Expanded\(\n[ \t]*child: _Social(?:Signin|Signup)Tile\(\n[ \t]*label: t\.auth\.signin\.facebook,[\s\S]*?\.signinWithFacebook\(\),\n[ \t]*\),\n[ \t]*\),/,
1371
+ '',
1372
+ );
1354
1373
  await fs.writeFile(p, content, 'utf8');
1355
1374
  }
1356
1375
  }
@@ -740,6 +740,7 @@ module.exports = {
740
740
  'new.success.featuresInstalled': 'Features enabled:',
741
741
  'new.success.bundleId': 'App identifier (bundle ID)',
742
742
  'new.success.bundleId.hint': "Your app's unique identifier on Android, iOS and Firebase (push).",
743
+ 'new.success.api.serverContracts': 'API backend: you must implement the server contracts (delete account, AI chat and push). See patch/README.md',
743
744
  'new.success.nextSteps': 'Next steps:',
744
745
  'new.success.step.cd': 'Go to your project folder:',
745
746
  'new.success.step.deploy': 'Push the server to Firebase (DB + functions):',
@@ -754,6 +755,7 @@ module.exports = {
754
755
  'new.google.manualHint': 'Google Sign-In: enable manually in the Console (Google provider):',
755
756
  'new.google.manualHint.noEmail': 'Google Sign-In: could not detect a support email (gcloud has no account). Enable manually in the Console:',
756
757
  'new.google.supabaseManual': 'Google Sign-In: client created, but the secret was not available yet. Enable it later in the Supabase dashboard (Authentication > Providers > Google).',
758
+ 'new.google.localhostDomainWarn': 'Google sign-in on web: could not authorize localhost in the Firebase authorized domains automatically. If the Google popup fails with "unauthorized-domain", add localhost in Firebase Console > Authentication > Settings > Authorized domains.',
757
759
  'new.fcm.ok': 'generated automatically',
758
760
  'new.fcm.failSupabase': 'not generated (GCP permission still propagating); set FIREBASE_SERVICE_ACCOUNT_JSON in your Supabase secrets',
759
761
  'new.fcm.failApi': 'not generated (GCP permission still propagating); run the command again in a few minutes',
@@ -740,6 +740,7 @@ module.exports = {
740
740
  'new.success.featuresInstalled': 'Recursos activados:',
741
741
  'new.success.bundleId': 'Identificador de la app (bundle ID)',
742
742
  'new.success.bundleId.hint': 'Identificador único de tu app en Android, iOS y Firebase (push).',
743
+ 'new.success.api.serverContracts': 'Backend API: debes implementar los contratos del servidor (eliminar cuenta, IA chat y push). Consulta patch/README.md',
743
744
  'new.success.nextSteps': 'Próximos pasos:',
744
745
  'new.success.step.cd': 'Ve a la carpeta del proyecto:',
745
746
  'new.success.step.deploy': 'Sube el servidor a Firebase (DB + funciones):',
@@ -754,6 +755,7 @@ module.exports = {
754
755
  'new.google.manualHint': 'Inicio de sesión con Google: actívalo manualmente en la consola (proveedor Google):',
755
756
  'new.google.manualHint.noEmail': 'Inicio de sesión con Google: no detecté un email de soporte (gcloud sin cuenta). Actívalo manualmente en la consola:',
756
757
  'new.google.supabaseManual': 'Inicio de sesión con Google: cliente creado, pero el secret aún no estaba disponible. Actívalo luego en el panel de Supabase (Authentication > Providers > Google).',
758
+ 'new.google.localhostDomainWarn': 'Inicio de sesión con Google en web: no se pudo autorizar localhost en los dominios de Firebase automáticamente. Si el popup de Google falla con "unauthorized-domain", agrega localhost en Firebase Console > Authentication > Settings > Authorized domains.',
757
759
  'new.fcm.ok': 'generada automáticamente',
758
760
  'new.fcm.failSupabase': 'no generada (permiso de GCP aún propagando); define FIREBASE_SERVICE_ACCOUNT_JSON en los secrets de Supabase',
759
761
  'new.fcm.failApi': 'no generada (permiso de GCP aún propagando); ejecuta el comando de nuevo en unos minutos',
@@ -740,6 +740,7 @@ module.exports = {
740
740
  'new.success.featuresInstalled': 'Recursos ativados:',
741
741
  'new.success.bundleId': 'Identificador do app (bundle ID)',
742
742
  'new.success.bundleId.hint': 'Identificador único do seu app no Android, iOS e Firebase (push).',
743
+ 'new.success.api.serverContracts': 'Backend API: você precisa implementar os contratos do servidor (excluir conta, IA chat e push). Veja patch/README.md',
743
744
  'new.success.nextSteps': 'Próximos passos:',
744
745
  'new.success.step.cd': 'Entre na pasta do projeto:',
745
746
  'new.success.step.deploy': 'Suba o servidor pro Firebase (banco + funções):',
@@ -754,6 +755,7 @@ module.exports = {
754
755
  'new.google.manualHint': 'Login com Google: ative manualmente no Console (provedor Google):',
755
756
  'new.google.manualHint.noEmail': 'Login com Google: não consegui detectar um e-mail de suporte (gcloud sem conta). Ative manualmente no Console:',
756
757
  'new.google.supabaseManual': 'Login com Google: client criado, mas o secret ainda não estava disponível. Ative depois no painel do Supabase (Authentication > Providers > Google).',
758
+ 'new.google.localhostDomainWarn': 'Login com Google na web: não consegui autorizar o localhost nos domínios do Firebase automaticamente. Se o popup do Google falhar com "unauthorized-domain", adicione localhost em Firebase Console > Authentication > Settings > Authorized domains.',
757
759
  'new.fcm.ok': 'gerada automaticamente',
758
760
  'new.fcm.failSupabase': 'não gerada (permissão do GCP ainda propagando); defina FIREBASE_SERVICE_ACCOUNT_JSON nos secrets do Supabase',
759
761
  'new.fcm.failApi': 'não gerada (permissão do GCP ainda propagando); rode o comando de novo em alguns minutos',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.31.14",
3
+ "version": "1.32.0",
4
4
  "description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
5
5
  "bin": {
6
6
  "kasy": "./bin/kasy.js"
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "scripts": {
34
- "prepack": "node scripts/check-feature-patches.js && node scripts/bundle-template.js && node test/generated-matches-kit.test.js && node test/supabase-verify-jwt.test.js && node test/supabase-cors.test.js && node test/supabase-google-web.test.js && node test/backend-pubspec-local-reminders.test.js",
34
+ "prepack": "node scripts/check-feature-patches.js && node scripts/bundle-template.js && node test/generated-matches-kit.test.js && node test/supabase-verify-jwt.test.js && node test/supabase-cors.test.js && node test/supabase-google-web.test.js && node test/backend-pubspec-local-reminders.test.js && node test/stripe-webhook-orphan-guard.test.js && node test/facebook-strip.test.js && node test/path-provider-pin.test.js && node test/features-flags-parity.test.js",
35
35
  "start": "node ./bin/kasy.js",
36
36
  "setup": "node ./bin/kasy.js setup",
37
37
  "doctor": "node ./bin/kasy.js doctor",
@@ -69,7 +69,13 @@ Requires an [Apple Developer](https://developer.apple.com) account (paid).
69
69
  1. Open `ios/Runner.xcworkspace` in Xcode
70
70
  2. Target **Runner** → **Signing & Capabilities** → **+ Capability** → add **Sign In with Apple**
71
71
 
72
- > **Android and Web**: no additional setup needed. The Firebase SDK handles the flow on Android. For Web, the Services ID (Step 3) already covers the redirect flow via `firebaseapp.com`.
72
+ > **iOS / macOS**: the Apple button shows automatically once the steps above are done.
73
+ >
74
+ > **Android**: the Apple button is hidden by design (it needs the paid Services ID web flow and adds little on Android for a SaaS). Leave it hidden.
75
+ >
76
+ > **Web (Firebase)**: works after Steps 1-4 above (`withAppleWebSignin` already ships `true`). The Services ID Return URL (`firebaseapp.com/__/auth/handler`) covers the popup flow.
77
+ >
78
+ > **Web (Supabase)**: the CLI ships `withAppleWebSignin = false` (native iOS works; web needs more). To enable it: in Supabase → Authentication → Providers → Apple, add the **client secret** (a JWT signed with your `.p8` key + Services ID), then set `withAppleWebSignin = true` in `lib/core/config/features.dart`.
73
79
 
74
80
  ---
75
81
 
@@ -69,7 +69,13 @@ Requiere cuenta de [Apple Developer](https://developer.apple.com) (de pago).
69
69
  1. Abre `ios/Runner.xcworkspace` en Xcode
70
70
  2. Target **Runner** → **Signing & Capabilities** → **+ Capability** → agrega **Sign In with Apple**
71
71
 
72
- > **Android y Web**: no se necesita configuración adicional. El SDK de Firebase gestiona el flujo en Android. Para Web, el Services ID (Paso 3) ya cubre el flujo de redirect vía `firebaseapp.com`.
72
+ > **iOS / macOS**: el botón Apple aparece automáticamente tras los pasos anteriores.
73
+ >
74
+ > **Android**: el botón Apple queda oculto por defecto (necesita el flujo del Services ID de pago y aporta poco en Android para un SaaS). Déjalo oculto.
75
+ >
76
+ > **Web (Firebase)**: funciona tras los Pasos 1 a 4 anteriores (`withAppleWebSignin` ya viene `true`). La Return URL del Services ID (`firebaseapp.com/__/auth/handler`) cubre el flujo de popup.
77
+ >
78
+ > **Web (Supabase)**: la CLI genera `withAppleWebSignin = false` (iOS nativo funciona; la web necesita más). Para habilitar: en Supabase → Authentication → Providers → Apple, agrega el **client secret** (un JWT firmado con la clave `.p8` + Services ID) y luego define `withAppleWebSignin = true` en `lib/core/config/features.dart`.
73
79
 
74
80
  ---
75
81
 
@@ -69,7 +69,13 @@ Requer conta [Apple Developer](https://developer.apple.com) (paga).
69
69
  1. Abra `ios/Runner.xcworkspace` no Xcode
70
70
  2. Target **Runner** → **Signing & Capabilities** → **+ Capability** → adicione **Sign In with Apple**
71
71
 
72
- > **Android e Web**: não precisam de configuração adicional. O Firebase SDK gerencia o fluxo no Android. Para Web, o Services ID (Passo 3) já cobre o fluxo de redirect via `firebaseapp.com`.
72
+ > **iOS / macOS**: o botão Apple aparece automaticamente depois dos passos acima.
73
+ >
74
+ > **Android**: o botão Apple fica escondido por padrão (exige o fluxo do Services ID pago e agrega pouco no Android para um SaaS). Deixe escondido.
75
+ >
76
+ > **Web (Firebase)**: funciona depois dos Passos 1 a 4 acima (`withAppleWebSignin` já vem `true`). A Return URL do Services ID (`firebaseapp.com/__/auth/handler`) cobre o fluxo de popup.
77
+ >
78
+ > **Web (Supabase)**: a CLI gera `withAppleWebSignin = false` (iOS nativo funciona; web precisa de mais). Para habilitar: em Supabase → Authentication → Providers → Apple, adicione o **client secret** (um JWT assinado com a chave `.p8` + Services ID) e depois defina `withAppleWebSignin = true` em `lib/core/config/features.dart`.
73
79
 
74
80
  ---
75
81
 
@@ -125,6 +125,8 @@ export const createCheckoutSession = onCall(
125
125
  const price = await stripe.prices.retrieve(priceId, {expand: ["product"]});
126
126
  const trialDays = trialDaysFor(price, price.product as Stripe.Product);
127
127
 
128
+ const allowPromoCodes = request.data?.allowPromoCodes as boolean | undefined;
129
+
128
130
  const session = await stripe.checkout.sessions.create({
129
131
  mode: "subscription",
130
132
  customer: customerId,
@@ -136,13 +138,70 @@ export const createCheckoutSession = onCall(
136
138
  metadata: {firebaseUID: uid},
137
139
  ...(trialDays ? {trial_period_days: trialDays} : {}),
138
140
  },
141
+ ...(allowPromoCodes ? {allow_promotion_codes: true} : {}),
139
142
  });
140
143
  return {url: session.url};
141
144
  },
142
145
  );
143
146
 
144
147
  // ---------------------------------------------------------------------------
145
- // createPortalSessionStripe Customer Portal (manage / cancel).
148
+ // getOrCreatePortalConfigcreates (once) a Customer Portal configuration
149
+ // with subscription_update (plan switching) enabled and caches its ID in
150
+ // Firestore so we don't recreate it on every portal open. Falls back to
151
+ // undefined (default portal) if there are no prices to switch between.
152
+ // ---------------------------------------------------------------------------
153
+ async function getOrCreatePortalConfig(stripe: Stripe): Promise<string | undefined> {
154
+ const db = admin.firestore();
155
+ const configRef = db.doc("_config/stripe_portal");
156
+ const snap = await configRef.get();
157
+ const cachedId = snap.data()?.configId as string | undefined;
158
+ if (cachedId) {
159
+ try {
160
+ const cfg = await stripe.billingPortal.configurations.retrieve(cachedId);
161
+ if (cfg.active) return cachedId;
162
+ } catch {
163
+ // Cached config was deleted on Stripe — recreate below
164
+ }
165
+ }
166
+
167
+ // Build allowed-prices list grouped by product. STRIPE_PRODUCT_ID narrows the
168
+ // query to a single product; if it's not set we use all active recurring prices.
169
+ const productId = stripeProductId.value();
170
+ const priceParams: Stripe.PriceListParams = {active: true, type: "recurring", limit: 100};
171
+ if (productId) priceParams.product = productId;
172
+ const {data: prices} = await stripe.prices.list(priceParams);
173
+
174
+ if (prices.length === 0) return undefined;
175
+
176
+ const byProduct: Record<string, string[]> = {};
177
+ for (const p of prices) {
178
+ const pid = typeof p.product === "string" ? p.product : p.product.id;
179
+ if (!byProduct[pid]) byProduct[pid] = [];
180
+ byProduct[pid].push(p.id);
181
+ }
182
+ const products = Object.entries(byProduct).map(([prod, priceIds]) => ({
183
+ product: prod,
184
+ prices: priceIds,
185
+ }));
186
+
187
+ const config = await stripe.billingPortal.configurations.create({
188
+ features: {
189
+ subscription_update: {
190
+ enabled: true,
191
+ default_allowed_updates: ["price"],
192
+ products,
193
+ },
194
+ subscription_cancel: {enabled: true, mode: "at_period_end"},
195
+ payment_method_update: {enabled: true},
196
+ },
197
+ });
198
+
199
+ await configRef.set({configId: config.id}, {merge: true});
200
+ return config.id;
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // createPortalSession — Stripe Customer Portal (manage / cancel / switch plan).
146
205
  // ---------------------------------------------------------------------------
147
206
  export const createPortalSession = onCall(
148
207
  {secrets: [stripeSecretKey]},
@@ -150,6 +209,7 @@ export const createPortalSession = onCall(
150
209
  const uid = request.auth?.uid;
151
210
  if (!uid) throw new HttpsError("unauthenticated", "Sign in required");
152
211
  const returnUrl = (request.data?.returnUrl as string | undefined) ?? "";
212
+ const planSwitching = request.data?.planSwitching as boolean | undefined;
153
213
 
154
214
  const stripe = stripeClient();
155
215
  const snap = await admin
@@ -161,9 +221,16 @@ export const createPortalSession = onCall(
161
221
  if (!customerId) {
162
222
  throw new HttpsError("failed-precondition", "No Stripe customer for user");
163
223
  }
224
+
225
+ // When plan switching is requested, resolve (or create) a portal configuration
226
+ // that has subscription_update enabled. This removes the need for any manual
227
+ // setup in the Stripe dashboard.
228
+ const configId = planSwitching ? await getOrCreatePortalConfig(stripe) : undefined;
229
+
164
230
  const session = await stripe.billingPortal.sessions.create({
165
231
  customer: customerId,
166
232
  return_url: returnUrl,
233
+ ...(configId ? {configuration: configId} : {}),
167
234
  });
168
235
  return {url: session.url};
169
236
  },
@@ -189,11 +256,18 @@ async function upsertFromStripeSubscription(sub: Stripe.Subscription): Promise<v
189
256
  console.log("[stripe-webhook] subscription without firebaseUID metadata, skipping");
190
257
  return;
191
258
  }
259
+ // Skip if the user no longer exists. Deleting an account cancels the Stripe
260
+ // customer, which fires customer.subscription.deleted AFTER deleteUserAccount
261
+ // already removed subscriptions/{uid} — without this guard the webhook would
262
+ // re-create an orphan doc for a user that is gone. (The Supabase webhook does
263
+ // the same check.) The lookup also gives us the email to denormalize below.
264
+ const user = await usersRepository.getFromId(uid);
265
+ if (!user) {
266
+ console.log(`[stripe-webhook] user ${uid} not found (likely deleted), skipping`);
267
+ return;
268
+ }
192
269
  const now = Timestamp.now();
193
270
  const existing = await subscriptionsRepository.getFromUserId(uid);
194
- // Denormalize the subscriber's email onto the Firestore subscription doc (see
195
- // SubscriptionData.email) so a subscribers list reads it without a second hop.
196
- const user = await usersRepository.getFromId(uid);
197
271
  // In Stripe API v18 the billing period lives on each subscription item.
198
272
  const item = sub.items.data[0];
199
273
  const priceId = item?.price?.id ?? "";
@@ -211,7 +285,7 @@ async function upsertFromStripeSubscription(sub: Stripe.Subscription): Promise<v
211
285
  expirationDate: expiration,
212
286
  store: Stores.STRIPE,
213
287
  productId: priceId,
214
- email: user?.email,
288
+ email: user.email,
215
289
  },
216
290
  subscriptionsRepository,
217
291
  );
@@ -366,7 +366,7 @@ class _TrailingChevron extends StatelessWidget {
366
366
  duration: const Duration(milliseconds: 200),
367
367
  child: Icon(
368
368
  KasyIcons.chevronDown,
369
- size: 18.5,
369
+ size: KasyIconSize.md,
370
370
  color: context.colors.muted,
371
371
  ),
372
372
  );
@@ -376,7 +376,7 @@ class _TrailingChevron extends StatelessWidget {
376
376
  duration: const Duration(milliseconds: 200),
377
377
  child: Icon(
378
378
  KasyIcons.chevronRight,
379
- size: 18.5,
379
+ size: KasyIconSize.md,
380
380
  color: context.colors.muted,
381
381
  ),
382
382
  );
@@ -232,7 +232,7 @@ class KasyAlertCircleButton extends StatelessWidget {
232
232
  height: 36,
233
233
  child: Icon(
234
234
  icon,
235
- size: 19,
235
+ size: KasyIconSize.md,
236
236
  color: context.colors.onSurface.withValues(alpha: 0.58),
237
237
  ),
238
238
  ),
@@ -292,7 +292,7 @@ class KasyAppBar extends StatelessWidget {
292
292
  ),
293
293
  _ => KasyChromeOrbIconButton(
294
294
  icon: KasyIcons.arrowBackIos,
295
- iconSize: 18,
295
+ iconSize: KasyIconSize.md,
296
296
  foregroundColor: orbFg,
297
297
  fillColor: orbFill,
298
298
  onPressed: handleBack,
@@ -404,7 +404,7 @@ class KasyAppBar extends StatelessWidget {
404
404
  case KasyAppBarStyle.subpage:
405
405
  return KasyChromeOrbIconButton(
406
406
  icon: isDark ? KasyIcons.lightMode : KasyIcons.darkMode,
407
- iconSize: 20,
407
+ iconSize: KasyIconSize.lg,
408
408
  foregroundColor: iconFg,
409
409
  fillColor: orbFill,
410
410
  onPressed: () {
@@ -421,7 +421,7 @@ class KasyAppBar extends StatelessWidget {
421
421
  if (trailing != null) return trailing!;
422
422
  return KasyChromeOrbIconButton(
423
423
  icon: isDark ? KasyIcons.lightMode : KasyIcons.darkMode,
424
- iconSize: 20,
424
+ iconSize: KasyIconSize.lg,
425
425
  foregroundColor: iconFg,
426
426
  fillColor: orbFill,
427
427
  onPressed: () {
@@ -298,7 +298,7 @@ class _IconBubble extends StatelessWidget {
298
298
  width: 72,
299
299
  height: 72,
300
300
  decoration: BoxDecoration(color: p.background, shape: BoxShape.circle),
301
- child: Icon(icon, size: 30, color: p.foreground),
301
+ child: Icon(icon, size: KasyIconSize.xxl, color: p.foreground),
302
302
  );
303
303
  }
304
304
 
@@ -417,17 +417,17 @@ class KasyButton extends StatelessWidget {
417
417
  iconOnlyExtent: 40,
418
418
  horizontalPadding: EdgeInsets.symmetric(horizontal: 16),
419
419
  labelFontSize: 13,
420
- iconSize: 16,
421
- iconOnlyGlyphSize: 12,
420
+ iconSize: KasyIconSize.sm,
421
+ iconOnlyGlyphSize: KasyIconSize.xxs,
422
422
  loadingSpinnerExtent: 13,
423
423
  ),
424
424
  KasyButtonSize.medium => const _KasyButtonMetrics(
425
- height: 47,
426
- iconOnlyExtent: 47,
425
+ height: 45,
426
+ iconOnlyExtent: 45,
427
427
  horizontalPadding: EdgeInsets.symmetric(horizontal: 18),
428
428
  labelFontSize: 14,
429
- iconSize: 18,
430
- iconOnlyGlyphSize: 14,
429
+ iconSize: KasyIconSize.md,
430
+ iconOnlyGlyphSize: KasyIconSize.xs,
431
431
  loadingSpinnerExtent: 14,
432
432
  ),
433
433
  KasyButtonSize.large => const _KasyButtonMetrics(
@@ -435,8 +435,8 @@ class KasyButton extends StatelessWidget {
435
435
  iconOnlyExtent: 54,
436
436
  horizontalPadding: EdgeInsets.symmetric(horizontal: 22),
437
437
  labelFontSize: 15,
438
- iconSize: 19,
439
- iconOnlyGlyphSize: 18,
438
+ iconSize: KasyIconSize.md,
439
+ iconOnlyGlyphSize: KasyIconSize.md,
440
440
  loadingSpinnerExtent: 15,
441
441
  ),
442
442
  };
@@ -36,7 +36,7 @@ class KasyChip extends StatelessWidget {
36
36
  final KasyColors c = context.colors;
37
37
  final Widget? avatar = icon == null
38
38
  ? null
39
- : Icon(icon, size: 18, color: enabled ? c.primary : c.muted);
39
+ : Icon(icon, size: KasyIconSize.md, color: enabled ? c.primary : c.muted);
40
40
  final OutlinedBorder chipShape = RoundedRectangleBorder(
41
41
  borderRadius: BorderRadius.circular(KasyRadius.full),
42
42
  side: BorderSide(color: c.outline.withValues(alpha: 0.45)),
@@ -552,11 +552,13 @@ class _KasyDatePickerState extends State<KasyDatePicker>
552
552
  // Key on the trigger field — used to measure its width when opening the popover.
553
553
  final GlobalKey _fieldKey = GlobalKey();
554
554
 
555
- // Focus node owned by the trigger field. We want focus so the
556
- // KasyTextField paints its focus border while the calendar is open — but
557
- // the underlying TextField runs in readOnly mode, so no soft keyboard
558
- // appears even when focused.
559
- final FocusNode _fieldFocusNode = FocusNode();
555
+ // Drives the trigger's "active" (focused-look) border while the calendar or
556
+ // overlay is open. We deliberately do NOT focus the field for this: real
557
+ // focus would (a) light the wrapping KasyFocusRing's keyboard ring on a mouse
558
+ // click a second outline — and (b) get stolen by the overlay after a
559
+ // moment, dropping the border. KasyTextField.forceFocusBorder paints it from
560
+ // this flag instead, so it stays put for as long as the calendar is open.
561
+ bool _triggerActive = false;
560
562
 
561
563
  // Controller backing the trigger KasyTextField — text mirrors the formatted
562
564
  // date (or stays empty so the hint renders).
@@ -639,7 +641,6 @@ class _KasyDatePickerState extends State<KasyDatePicker>
639
641
  @override
640
642
  void dispose() {
641
643
  _displayController.dispose();
642
- _fieldFocusNode.dispose();
643
644
  _animCtrl.dispose();
644
645
  super.dispose();
645
646
  }
@@ -741,11 +742,12 @@ class _KasyDatePickerState extends State<KasyDatePicker>
741
742
 
742
743
  _portalController.show();
743
744
  _animCtrl.forward();
744
- // Focus so the trigger field paints its focus border while the calendar
745
- // is open (only when focusBorder is enabled — otherwise we skip to avoid
746
- // the field announcing focus to assistive tech for no visual reason).
747
- if (widget.focusBorder) _fieldFocusNode.requestFocus();
748
- setState(() => _isOpen = true);
745
+ // Paint the trigger's "active" border while the calendar is open — driven
746
+ // by state, not real focus (see _triggerActive).
747
+ setState(() {
748
+ _isOpen = true;
749
+ _triggerActive = true;
750
+ });
749
751
  }
750
752
 
751
753
  void _close() {
@@ -765,7 +767,7 @@ class _KasyDatePickerState extends State<KasyDatePicker>
765
767
  _pendingPopoverRange = null;
766
768
  widget.onRangeChanged?.call(pendingRange);
767
769
  }
768
- _fieldFocusNode.unfocus();
770
+ setState(() => _triggerActive = false);
769
771
  });
770
772
  setState(() => _isOpen = false);
771
773
  }
@@ -830,7 +832,7 @@ class _KasyDatePickerState extends State<KasyDatePicker>
830
832
  // endpoint right away).
831
833
  DateTime dialogViewMonth = _viewMonth;
832
834
  KasyDateRange? dialogRange = _effectiveRange;
833
- if (widget.focusBorder) _fieldFocusNode.requestFocus();
835
+ setState(() => _triggerActive = true);
834
836
 
835
837
  showKasyDialog<void>(
836
838
  context: context,
@@ -904,12 +906,12 @@ class _KasyDatePickerState extends State<KasyDatePicker>
904
906
  },
905
907
  ),
906
908
  ).whenComplete(() {
907
- if (mounted) _fieldFocusNode.unfocus();
909
+ if (mounted) setState(() => _triggerActive = false);
908
910
  });
909
911
  }
910
912
 
911
913
  void _openBottomSheet() {
912
- if (widget.focusBorder) _fieldFocusNode.requestFocus();
914
+ setState(() => _triggerActive = true);
913
915
  showKasyBottomSheet<void>(
914
916
  context: context,
915
917
  isScrollControlled: true,
@@ -925,7 +927,7 @@ class _KasyDatePickerState extends State<KasyDatePicker>
925
927
  monthsToShow: widget.monthsToShow,
926
928
  ),
927
929
  ).whenComplete(() {
928
- if (mounted) _fieldFocusNode.unfocus();
930
+ if (mounted) setState(() => _triggerActive = false);
929
931
  });
930
932
  }
931
933
 
@@ -1050,15 +1052,18 @@ class _KasyDatePickerState extends State<KasyDatePicker>
1050
1052
  child: KasyTextField(
1051
1053
  key: _fieldKey,
1052
1054
  controller: _displayController,
1053
- focusNode: _fieldFocusNode,
1054
1055
  readOnly: true,
1055
1056
  enabled: widget.enabled,
1056
1057
  hint: _resolvedPlaceholder,
1057
1058
  isInvalid: hasInvalidState,
1058
1059
  variant: widget.variant,
1059
1060
  focusBorder: widget.focusBorder,
1060
- // No caret, no selection handles, no "blue text" when the
1061
- // trigger is focused while the calendar is open keeps the
1061
+ // "Active" border while the calendar is open painted from
1062
+ // state, not real focus, so it never doubles the wrapping
1063
+ // KasyFocusRing's keyboard ring on click and never gets
1064
+ // dropped when the overlay steals focus.
1065
+ forceFocusBorder: widget.focusBorder && _triggerActive,
1066
+ // No caret, no selection handles, no "blue text" — keeps the
1062
1067
  // field reading as a button, not an editable input.
1063
1068
  enableInteractiveSelection: false,
1064
1069
  suffix: widget.showSuffix
@@ -1691,7 +1696,7 @@ class _CalendarNavRow extends StatelessWidget {
1691
1696
  viewMode == _CalendarViewMode.month
1692
1697
  ? KasyIcons.chevronRight
1693
1698
  : KasyIcons.chevronDown,
1694
- size: 18,
1699
+ size: KasyIconSize.md,
1695
1700
  weight: 700,
1696
1701
  color: c.primary,
1697
1702
  ),
@@ -1766,7 +1771,7 @@ class _NavArrowButton extends StatelessWidget {
1766
1771
  child: Center(
1767
1772
  child: Icon(
1768
1773
  icon,
1769
- size: 20,
1774
+ size: KasyIconSize.lg,
1770
1775
  weight: 700,
1771
1776
  color: disabled ? c.muted.withValues(alpha: 0.45) : c.primary,
1772
1777
  ),
@@ -144,7 +144,7 @@ class KasyDialog extends StatelessWidget {
144
144
  padding: const EdgeInsets.all(6),
145
145
  minimumSize: const Size(40, 40),
146
146
  ),
147
- icon: const Icon(KasyIcons.close, size: 19),
147
+ icon: const Icon(KasyIcons.close, size: KasyIconSize.md),
148
148
  ),
149
149
  );
150
150
  }
@@ -449,7 +449,7 @@ class _DialogIconBubble extends StatelessWidget {
449
449
  width: _size,
450
450
  height: _size,
451
451
  decoration: BoxDecoration(color: p.background, shape: BoxShape.circle),
452
- child: Icon(icon, size: 20, color: p.foreground),
452
+ child: Icon(icon, size: KasyIconSize.lg, color: p.foreground),
453
453
  );
454
454
  }
455
455
 
@@ -1227,7 +1227,7 @@ class _ProHoverPopupIconState extends State<_ProHoverPopupIcon> {
1227
1227
  color: widget.iconBg,
1228
1228
  borderRadius: BorderRadius.circular(_kItemRadius),
1229
1229
  ),
1230
- child: Icon(widget.icon, size: 20, color: widget.iconColor),
1230
+ child: Icon(widget.icon, size: KasyIconSize.lg, color: widget.iconColor),
1231
1231
  ),
1232
1232
  ),
1233
1233
  ),
@@ -1365,7 +1365,7 @@ class _ProTooltipIconState extends State<_ProTooltipIcon> {
1365
1365
  color: widget.iconBg,
1366
1366
  borderRadius: BorderRadius.circular(_kItemRadius),
1367
1367
  ),
1368
- child: Icon(widget.icon, size: 20, color: widget.iconColor),
1368
+ child: Icon(widget.icon, size: KasyIconSize.lg, color: widget.iconColor),
1369
1369
  ),
1370
1370
  ),
1371
1371
  ),
@@ -486,7 +486,7 @@ class _PrimaryTabState extends State<_PrimaryTab> {
486
486
  opacity: disabled ? 0.4 : 1.0,
487
487
  child: Icon(
488
488
  item.icon,
489
- size: 16,
489
+ size: KasyIconSize.sm,
490
490
  color: fg,
491
491
  ),
492
492
  )
@@ -628,7 +628,7 @@ class _SecondaryTabState extends State<_SecondaryTab> {
628
628
  opacity: disabled ? 0.4 : 1.0,
629
629
  child: Icon(
630
630
  item.icon,
631
- size: 16,
631
+ size: KasyIconSize.sm,
632
632
  color: fg,
633
633
  ),
634
634
  ),