kasy-cli 1.31.9 → 1.31.11
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.
- package/lib/commands/add.js +31 -0
- package/lib/commands/new.js +7 -24
- package/lib/scaffold/CHANGELOG.json +9 -0
- package/lib/scaffold/backends/supabase/config.toml +39 -2
- package/lib/scaffold/backends/supabase/deploy.js +14 -16
- package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +27 -9
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +3 -1
- package/lib/scaffold/backends/supabase/edge-functions/stripe-list-prices/index.ts +3 -2
- package/lib/scaffold/catalog.js +24 -0
- package/lib/scaffold/shared/generator-utils.js +11 -0
- package/package.json +2 -2
- package/templates/firebase/assets/images/premium-bg.jpg +0 -0
- package/templates/firebase/assets/images/premium-switch-header.png +0 -0
- package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +21 -4
- package/templates/firebase/lib/components/kasy_app_bar.dart +109 -1
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +26 -7
- package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +22 -33
- package/templates/firebase/lib/core/chrome/chrome_visibility.dart +119 -0
- package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +12 -0
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +25 -3
- package/templates/firebase/lib/features/home/home_feed.dart +7 -1
- package/templates/firebase/lib/features/home/home_page.dart +6 -8
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -0
- package/templates/firebase/lib/features/settings/settings_page.dart +26 -0
- package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +5 -3
- package/templates/firebase/lib/features/subscriptions/api/subscription_payment_api.dart +8 -0
- package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +89 -47
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +169 -90
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +219 -202
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +67 -30
- package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +16 -6
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_bottom_menu.dart +11 -18
- package/templates/firebase/lib/i18n/en.i18n.json +3 -0
- package/templates/firebase/lib/i18n/es.i18n.json +3 -0
- package/templates/firebase/lib/i18n/pt.i18n.json +3 -0
package/lib/commands/add.js
CHANGED
|
@@ -30,6 +30,33 @@ const { toPackageName, buildTokens } = require('../scaffold/backends/firebase/to
|
|
|
30
30
|
|
|
31
31
|
const execAsync = promisify(exec);
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Ensure the target project's supabase/config.toml has a [functions.<name>] block
|
|
35
|
+
* with verify_jwt = false. `kasy new` already writes the full config, but a project
|
|
36
|
+
* created before 1.31.10 (or any project missing the block) needs it appended here —
|
|
37
|
+
* otherwise the deployed function keeps the platform JWT gate ON and the browser CORS
|
|
38
|
+
* preflight (OPTIONS, no auth header) is rejected => "Failed to fetch" on web. The
|
|
39
|
+
* deploy --no-verify-jwt flag is deprecated/ignored by recent CLIs, so config.toml is
|
|
40
|
+
* the authoritative setting. The function authenticates itself, so this stays secure.
|
|
41
|
+
*/
|
|
42
|
+
async function ensureSupabaseConfigNoVerifyJwt(projectDir, fnName) {
|
|
43
|
+
const configPath = path.join(projectDir, 'supabase', 'config.toml');
|
|
44
|
+
if (!(await fs.pathExists(configPath))) return;
|
|
45
|
+
const toml = await fs.readFile(configPath, 'utf8');
|
|
46
|
+
const safe = fnName.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&');
|
|
47
|
+
const block = toml.match(new RegExp(`\\[functions\\.${safe}\\]([\\s\\S]*?)(?=\\n\\[|$)`));
|
|
48
|
+
if (block && /verify_jwt\s*=\s*false/.test(block[1])) return; // already correct
|
|
49
|
+
let next;
|
|
50
|
+
if (block && /verify_jwt\s*=\s*true/.test(block[0])) {
|
|
51
|
+
next = toml.replace(block[0], block[0].replace(/verify_jwt\s*=\s*true/, 'verify_jwt = false'));
|
|
52
|
+
} else if (block) {
|
|
53
|
+
next = toml.replace(block[0], `${block[0].replace(/\s*$/, '')}\nverify_jwt = false`);
|
|
54
|
+
} else {
|
|
55
|
+
next = `${toml.replace(/\s*$/, '')}\n\n[functions.${fnName}]\nverify_jwt = false\n`;
|
|
56
|
+
}
|
|
57
|
+
await fs.writeFile(configPath, next, 'utf8');
|
|
58
|
+
}
|
|
59
|
+
|
|
33
60
|
/** URL do endpoint AI no app (espelha defineUpdates de ai_chat). */
|
|
34
61
|
function resolveAiChatEndpoint(answers, kitSetup) {
|
|
35
62
|
if (kitSetup?.backendProvider === 'api') {
|
|
@@ -513,6 +540,10 @@ async function postAddAiChat(projectDir, kitSetup, answers, t) {
|
|
|
513
540
|
if (backend === 'firebase') {
|
|
514
541
|
await execAsync('firebase deploy --only functions:aiChat', { cwd: projectDir, timeout: 180_000 });
|
|
515
542
|
} else if (backend === 'supabase') {
|
|
543
|
+
// Authoritative JWT setting lives in config.toml; ensure it before deploying so
|
|
544
|
+
// ai-chat is reachable from the web (the --no-verify-jwt flag alone is ignored
|
|
545
|
+
// by recent Supabase CLIs).
|
|
546
|
+
await ensureSupabaseConfigNoVerifyJwt(projectDir, 'ai-chat');
|
|
516
547
|
await execAsync(`supabase functions deploy ai-chat --no-verify-jwt${refFlag}`, { cwd: projectDir, timeout: 180_000 });
|
|
517
548
|
}
|
|
518
549
|
deploySpinner.stop(t('add.ai_chat.deployed'));
|
package/lib/commands/new.js
CHANGED
|
@@ -34,7 +34,7 @@ const {
|
|
|
34
34
|
runChecks,
|
|
35
35
|
hasRequiredFailures,
|
|
36
36
|
} = require('../utils/checks');
|
|
37
|
-
const { normalizeBackend, getVisibleFeatures, FEATURE_CATALOG } = require('../scaffold/catalog');
|
|
37
|
+
const { normalizeBackend, getVisibleFeatures, computeQuickModules, FEATURE_CATALOG } = require('../scaffold/catalog');
|
|
38
38
|
|
|
39
39
|
// Audience gate: set KASY_INTERNAL=1 to reveal beta/internal features.
|
|
40
40
|
const KASY_AUDIENCE = process.env.KASY_INTERNAL === '1' ? 'internal' : 'public';
|
|
@@ -575,20 +575,6 @@ async function promptSupabaseManual(tr, cancel) {
|
|
|
575
575
|
return { supabaseUrl, supabaseAnonKey };
|
|
576
576
|
}
|
|
577
577
|
|
|
578
|
-
// ── Module presets ────────────────────────────────────────────────────────────
|
|
579
|
-
|
|
580
|
-
function buildPresets(audience) {
|
|
581
|
-
const catalog = getVisibleFeatures({ audience });
|
|
582
|
-
const named = ['starter', 'saas', 'content', 'full'];
|
|
583
|
-
const result = { none: [], custom: null };
|
|
584
|
-
for (const name of named) {
|
|
585
|
-
result[name] = catalog.filter((f) => f.defaultInPresets.includes(name)).map((f) => f.id);
|
|
586
|
-
}
|
|
587
|
-
return result;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
const MODULE_PRESETS = buildPresets(KASY_AUDIENCE);
|
|
591
|
-
|
|
592
578
|
// ── Main wizard ───────────────────────────────────────────────────────────────
|
|
593
579
|
|
|
594
580
|
/**
|
|
@@ -1436,15 +1422,12 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
|
|
|
1436
1422
|
// --with flag was passed: use those modules directly, skip preset prompt.
|
|
1437
1423
|
modules = preselectedModules;
|
|
1438
1424
|
} else if (isQuick) {
|
|
1439
|
-
// Quick mode: ship
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1442
|
-
//
|
|
1443
|
-
//
|
|
1444
|
-
|
|
1445
|
-
getVisibleFeatures({ audience: KASY_AUDIENCE, backend }).map((f) => f.id)
|
|
1446
|
-
);
|
|
1447
|
-
modules = (MODULE_PRESETS.full || []).filter((m) => m !== 'facebook' && quickAvailable.has(m));
|
|
1425
|
+
// Quick mode: ship everything this backend supports, minus Facebook (needs an
|
|
1426
|
+
// App ID + token we can't auto-generate — added later via `kasy add facebook`).
|
|
1427
|
+
// computeQuickModules is shared with the anti-drift guard test, so the preset
|
|
1428
|
+
// can't silently drop a feature again. Dropping local_reminders is what used to
|
|
1429
|
+
// remove its dir and force the trimmed router that lost /admin + transitions.
|
|
1430
|
+
modules = computeQuickModules({ backend, audience: KASY_AUDIENCE });
|
|
1448
1431
|
} else {
|
|
1449
1432
|
section('new.advanced.section.features');
|
|
1450
1433
|
// Advanced mode: full multiselect — built from catalog, filtered by audience + backend.
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
+
"1.31.10": {
|
|
3
|
+
"modules": {
|
|
4
|
+
"core": {
|
|
5
|
+
"pt": "Web preview de dispositivo não lança mais o erro \"Not initialized\" no primeiro frame: ele espera o DevicePreview terminar de inicializar antes de ajustar a orientação.",
|
|
6
|
+
"en": "Device preview on web no longer throws \"Not initialized\" on the first frame: it waits for DevicePreview to finish initializing before syncing orientation.",
|
|
7
|
+
"es": "La vista previa de dispositivo en web ya no lanza \"Not initialized\" en el primer frame: espera a que DevicePreview termine de inicializar antes de ajustar la orientación."
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
},
|
|
2
11
|
"1.22.0": {
|
|
3
12
|
"modules": {
|
|
4
13
|
"components": {
|
|
@@ -28,7 +28,44 @@ enable_anonymous_sign_ins = true
|
|
|
28
28
|
enable_confirmations = false
|
|
29
29
|
double_confirm_changes = false
|
|
30
30
|
|
|
31
|
+
# Edge Functions — every function skips the platform JWT gate (verify_jwt = false)
|
|
32
|
+
# and authenticates ITSELF inside the handler. This is the authoritative setting:
|
|
33
|
+
# `supabase functions deploy` reads it (the legacy --no-verify-jwt flag is deprecated
|
|
34
|
+
# and silently ignored by recent CLIs). Two reasons every function needs it:
|
|
35
|
+
# 1. Browser-invoked functions receive a CORS preflight (OPTIONS, no Authorization
|
|
36
|
+
# header). With the gate ON the platform rejects the preflight before our CORS
|
|
37
|
+
# handler runs, so the web app sees "Failed to fetch".
|
|
38
|
+
# 2. Webhooks (Stripe, RevenueCat) are called server-to-server with a provider
|
|
39
|
+
# signature/key, never a Supabase user JWT.
|
|
40
|
+
# Security is preserved: each handler checks the user token (getUser) or the webhook
|
|
41
|
+
# signature/key before doing any work. Keep this list in sync with edge-functions/ —
|
|
42
|
+
# cli/test/supabase-verify-jwt.test.js fails the publish if a function is missing.
|
|
43
|
+
[functions.admin-list-users]
|
|
44
|
+
verify_jwt = false
|
|
45
|
+
|
|
46
|
+
[functions.ai-chat]
|
|
47
|
+
verify_jwt = false
|
|
48
|
+
|
|
49
|
+
[functions.delete-user-account]
|
|
50
|
+
verify_jwt = false
|
|
51
|
+
|
|
52
|
+
[functions.meta-track-event]
|
|
53
|
+
verify_jwt = false
|
|
54
|
+
|
|
31
55
|
[functions.revenuecat-webhook]
|
|
32
|
-
|
|
33
|
-
|
|
56
|
+
verify_jwt = false
|
|
57
|
+
|
|
58
|
+
[functions.send-push-notification]
|
|
59
|
+
verify_jwt = false
|
|
60
|
+
|
|
61
|
+
[functions.stripe-create-checkout-session]
|
|
62
|
+
verify_jwt = false
|
|
63
|
+
|
|
64
|
+
[functions.stripe-create-portal-session]
|
|
65
|
+
verify_jwt = false
|
|
66
|
+
|
|
67
|
+
[functions.stripe-list-prices]
|
|
68
|
+
verify_jwt = false
|
|
69
|
+
|
|
70
|
+
[functions.stripe-webhook]
|
|
34
71
|
verify_jwt = false
|
|
@@ -570,26 +570,24 @@ async function deployFunctions(projectDir, functionNames = []) {
|
|
|
570
570
|
}
|
|
571
571
|
if (toDeploy.length === 0) return { ok: true, skipped: true };
|
|
572
572
|
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
// web app sees "Failed to fetch".
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
573
|
+
// EVERY function in this kit skips the platform JWT gate. The authoritative
|
|
574
|
+
// setting lives in supabase/config.toml ([functions.<name>] verify_jwt = false),
|
|
575
|
+
// which `supabase functions deploy` reads; we ALSO pass --no-verify-jwt here as a
|
|
576
|
+
// belt-and-suspenders for older CLIs (the flag is deprecated but harmless). Why
|
|
577
|
+
// all of them:
|
|
578
|
+
// 1. Browser-invoked functions get a CORS preflight (OPTIONS, no Authorization
|
|
579
|
+
// header). With the gate ON the platform rejects the preflight before our
|
|
580
|
+
// CORS handler runs, so the web app sees "Failed to fetch".
|
|
581
|
+
// 2. Webhooks (Stripe, RevenueCat) are called server-to-server with a provider
|
|
582
|
+
// signature/key, never a Supabase user JWT.
|
|
583
|
+
// It stays secure because each handler authenticates itself (getUser on the token,
|
|
584
|
+
// or the webhook signature/key) before doing any work. config.toml is the source
|
|
585
|
+
// of truth — cli/test/supabase-verify-jwt.test.js asserts it covers every function.
|
|
587
586
|
const steps = [];
|
|
588
587
|
for (const name of toDeploy) {
|
|
589
588
|
const fnPath = path.join(functionsDir, name);
|
|
590
589
|
if (!(await fs.pathExists(fnPath))) continue;
|
|
591
|
-
const
|
|
592
|
-
const result = await run(`supabase functions deploy ${name}${noVerifyJwt}`, projectDir);
|
|
590
|
+
const result = await run(`supabase functions deploy ${name} --no-verify-jwt`, projectDir);
|
|
593
591
|
steps.push({ name: `deploy ${name}`, ok: result.ok, error: result.error });
|
|
594
592
|
}
|
|
595
593
|
return steps.length > 0 ? steps : { ok: true, skipped: true };
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* users — the users/subscriptions RLS exposes only a user's own row, so a
|
|
15
15
|
* non-admin can never reach other people's data.
|
|
16
16
|
*
|
|
17
|
-
* Deployed
|
|
17
|
+
* Deployed WITHOUT the platform JWT gate (verify_jwt = false in config.toml) so the
|
|
18
|
+
* browser's CORS preflight passes; security is enforced by the two layers above (the
|
|
19
|
+
* handler verifies the caller's JWT and admin role itself). See config.toml / deploy.js.
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.47.10";
|
package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* verified JWT, never trusted from the request body, so a client cannot check
|
|
7
7
|
* out on behalf of someone else.
|
|
8
8
|
*
|
|
9
|
-
* Deployed
|
|
9
|
+
* Deployed WITHOUT the platform JWT gate (verify_jwt = false in config.toml) so the
|
|
10
|
+
* browser's CORS preflight passes; the handler still authenticates the user via the
|
|
11
|
+
* verified JWT (getUser), so it stays secure.
|
|
10
12
|
*
|
|
11
13
|
* Secrets required:
|
|
12
14
|
* - STRIPE_SECRET_KEY: sk_test_... / sk_live_...
|
|
@@ -32,7 +34,11 @@ function trialDaysFor(price: Stripe.Price, product: Stripe.Product): number | nu
|
|
|
32
34
|
return meta ? Number(meta) : null;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
async function
|
|
37
|
+
async function getAuthUser(
|
|
38
|
+
req: Request,
|
|
39
|
+
supabaseUrl: string,
|
|
40
|
+
anonKey: string,
|
|
41
|
+
): Promise<{ id: string; email?: string } | null> {
|
|
36
42
|
const authHeader = req.headers.get("Authorization");
|
|
37
43
|
if (!authHeader?.startsWith("Bearer ")) return null;
|
|
38
44
|
const token = authHeader.replace("Bearer ", "");
|
|
@@ -41,19 +47,30 @@ async function getUid(req: Request, supabaseUrl: string, anonKey: string): Promi
|
|
|
41
47
|
});
|
|
42
48
|
const { data: { user }, error } = await client.auth.getUser(token);
|
|
43
49
|
if (error || !user) return null;
|
|
44
|
-
return user.id;
|
|
50
|
+
return { id: user.id, email: user.email ?? undefined };
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
// deno-lint-ignore no-explicit-any
|
|
48
|
-
async function getOrCreateCustomer(
|
|
54
|
+
async function getOrCreateCustomer(
|
|
55
|
+
stripe: Stripe,
|
|
56
|
+
admin: any,
|
|
57
|
+
uid: string,
|
|
58
|
+
email?: string,
|
|
59
|
+
): Promise<string> {
|
|
49
60
|
const { data } = await admin
|
|
50
61
|
.from(CUSTOMERS_TABLE)
|
|
51
62
|
.select("customer_id")
|
|
52
63
|
.eq("user_id", uid)
|
|
53
64
|
.maybeSingle();
|
|
54
65
|
const existing = data?.customer_id as string | undefined;
|
|
55
|
-
if (existing)
|
|
56
|
-
|
|
66
|
+
if (existing) {
|
|
67
|
+
// Keep the Stripe customer email in sync so Checkout pre-fills it. The
|
|
68
|
+
// payment is bound to the app user by uid (customer + metadata.supabaseUID),
|
|
69
|
+
// never by the typed email, so this is purely UX / billing hygiene.
|
|
70
|
+
if (email) await stripe.customers.update(existing, { email });
|
|
71
|
+
return existing;
|
|
72
|
+
}
|
|
73
|
+
const customer = await stripe.customers.create({ email, metadata: { supabaseUID: uid } });
|
|
57
74
|
await admin.from(CUSTOMERS_TABLE).upsert({ user_id: uid, customer_id: customer.id });
|
|
58
75
|
return customer.id;
|
|
59
76
|
}
|
|
@@ -74,10 +91,11 @@ Deno.serve(async (req: Request) => {
|
|
|
74
91
|
return Response.json({ error: "Server configuration error" }, { status: 500, headers: corsHeaders });
|
|
75
92
|
}
|
|
76
93
|
|
|
77
|
-
const
|
|
78
|
-
if (!
|
|
94
|
+
const user = await getAuthUser(req, supabaseUrl, anonKey);
|
|
95
|
+
if (!user) {
|
|
79
96
|
return Response.json({ error: "Sign in required" }, { status: 401, headers: corsHeaders });
|
|
80
97
|
}
|
|
98
|
+
const uid = user.id;
|
|
81
99
|
|
|
82
100
|
let body: { priceId?: string; successUrl?: string; cancelUrl?: string };
|
|
83
101
|
try {
|
|
@@ -95,7 +113,7 @@ Deno.serve(async (req: Request) => {
|
|
|
95
113
|
try {
|
|
96
114
|
const stripe = new Stripe(secretKey);
|
|
97
115
|
const admin = createClient(supabaseUrl, serviceRoleKey);
|
|
98
|
-
const customerId = await getOrCreateCustomer(stripe, admin, uid);
|
|
116
|
+
const customerId = await getOrCreateCustomer(stripe, admin, uid, user.email);
|
|
99
117
|
|
|
100
118
|
const price = await stripe.prices.retrieve(priceId, { expand: ["product"] });
|
|
101
119
|
const trialDays = trialDaysFor(price, price.product as Stripe.Product);
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* authenticated user and returns its URL. The user is identified by the
|
|
6
6
|
* verified JWT and the Stripe customer is looked up server-side.
|
|
7
7
|
*
|
|
8
|
-
* Deployed
|
|
8
|
+
* Deployed WITHOUT the platform JWT gate (verify_jwt = false in config.toml) so the
|
|
9
|
+
* browser's CORS preflight passes; the handler still authenticates the user via the
|
|
10
|
+
* verified JWT (getUser), so it stays secure.
|
|
9
11
|
*
|
|
10
12
|
* Secrets required:
|
|
11
13
|
* - STRIPE_SECRET_KEY
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* contract used by the Flutter client (mirrors the Firebase `listPrices`
|
|
6
6
|
* callable). The Stripe secret key never leaves the server.
|
|
7
7
|
*
|
|
8
|
-
* Deployed
|
|
9
|
-
*
|
|
8
|
+
* Deployed WITHOUT the platform JWT gate (verify_jwt = false in config.toml) so the
|
|
9
|
+
* browser's CORS preflight passes. This endpoint is public by design — it only
|
|
10
|
+
* returns the active prices (no user data) — so it does NOT authenticate the caller.
|
|
10
11
|
*
|
|
11
12
|
* Secrets required (set via `supabase secrets set`):
|
|
12
13
|
* - STRIPE_SECRET_KEY: sk_test_... / sk_live_...
|
package/lib/scaffold/catalog.js
CHANGED
|
@@ -93,6 +93,29 @@ function getVisibleFeatures({ audience = 'public', backend = null } = {}) {
|
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Modules shipped by `kasy new` Quick mode (the recommended path): EVERYTHING the
|
|
98
|
+
* backend supports, minus Facebook (needs an App ID + token we can't auto-generate;
|
|
99
|
+
* added later via `kasy add facebook`).
|
|
100
|
+
*
|
|
101
|
+
* Single source of truth — used by both new.js (the real wizard) and the anti-drift
|
|
102
|
+
* guard test (cli/test/generated-matches-kit.test.js). They MUST agree: if Quick
|
|
103
|
+
* silently drops a feature, the kit's router/main can't be kept verbatim and the
|
|
104
|
+
* generator falls back to the trimmed builder that historically lost /admin and the
|
|
105
|
+
* page transitions. Computing it in one place means the test exercises the exact set
|
|
106
|
+
* the user gets, so that regression can never ship unnoticed again.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} opts
|
|
109
|
+
* @param {string|null} opts.backend - filter by backend (availableIn), or null for all
|
|
110
|
+
* @param {'public'|'internal'} opts.audience
|
|
111
|
+
* @returns {string[]} feature ids
|
|
112
|
+
*/
|
|
113
|
+
function computeQuickModules({ backend = null, audience = 'public' } = {}) {
|
|
114
|
+
return getVisibleFeatures({ audience, backend })
|
|
115
|
+
.map((f) => f.id)
|
|
116
|
+
.filter((id) => id !== 'facebook');
|
|
117
|
+
}
|
|
118
|
+
|
|
96
119
|
// Flat list kept for backward-compatibility with code that uses feature ids as strings.
|
|
97
120
|
const AVAILABLE_FEATURES = FEATURE_CATALOG.map((f) => f.id);
|
|
98
121
|
|
|
@@ -255,4 +278,5 @@ module.exports = {
|
|
|
255
278
|
parseFeatureList,
|
|
256
279
|
getVisibleFeatures,
|
|
257
280
|
getBaseFeatures,
|
|
281
|
+
computeQuickModules,
|
|
258
282
|
};
|
|
@@ -369,6 +369,9 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
|
|
|
369
369
|
if (withWidget) {
|
|
370
370
|
lines.push(`import 'package:${pkg}/features/settings/ui/components/admin/admin_home_widgets.dart';`);
|
|
371
371
|
}
|
|
372
|
+
// Admin console — always shipped (settings/ui/components/admin) and always routable;
|
|
373
|
+
// access is gated inside AdminPage (admin role || debug). The settings UI links to it.
|
|
374
|
+
lines.push(`import 'package:${pkg}/features/settings/ui/components/admin/admin_page.dart';`);
|
|
372
375
|
if (withRevenuecat) {
|
|
373
376
|
lines.push(`import 'package:${pkg}/features/settings/ui/components/admin/admin_paywalls.dart';`);
|
|
374
377
|
}
|
|
@@ -534,6 +537,14 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
|
|
|
534
537
|
lines.push(` ),`);
|
|
535
538
|
lines.push(` ),`);
|
|
536
539
|
|
|
540
|
+
// Admin console (always routable so the settings 'Admin console' tile works in
|
|
541
|
+
// release too; access is gated inside AdminPage by admin role || debug).
|
|
542
|
+
lines.push(` GoRoute(`);
|
|
543
|
+
lines.push(` name: 'admin',`);
|
|
544
|
+
lines.push(` path: '/admin',`);
|
|
545
|
+
lines.push(` builder: (context, state) => const AdminPage(),`);
|
|
546
|
+
lines.push(` ),`);
|
|
547
|
+
|
|
537
548
|
// Send push notification (admin tool — always routable; the entry point in
|
|
538
549
|
// the settings admin sheet is itself debug-gated).
|
|
539
550
|
lines.push(` GoRoute(`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kasy-cli",
|
|
3
|
-
"version": "1.31.
|
|
3
|
+
"version": "1.31.11",
|
|
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",
|
|
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/backend-pubspec-local-reminders.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",
|
|
Binary file
|
|
Binary file
|
|
@@ -21,13 +21,27 @@ function stripeClient(): Stripe {
|
|
|
21
21
|
return new Stripe(stripeSecretKey.value());
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
async function getOrCreateCustomer(
|
|
24
|
+
async function getOrCreateCustomer(
|
|
25
|
+
stripe: Stripe,
|
|
26
|
+
uid: string,
|
|
27
|
+
email?: string,
|
|
28
|
+
): Promise<string> {
|
|
25
29
|
const db = admin.firestore();
|
|
26
30
|
const ref = db.collection(CUSTOMERS_COLLECTION).doc(uid);
|
|
27
31
|
const snap = await ref.get();
|
|
28
32
|
const existing = snap.data()?.customerId as string | undefined;
|
|
29
|
-
if (existing)
|
|
30
|
-
|
|
33
|
+
if (existing) {
|
|
34
|
+
// Keep the Stripe customer email in sync so Checkout pre-fills it and the
|
|
35
|
+
// receipts carry the right address. The payment is bound to the app user by
|
|
36
|
+
// UID (customer + metadata.firebaseUID), never by the typed email, so this
|
|
37
|
+
// is purely UX / billing hygiene.
|
|
38
|
+
if (email) await stripe.customers.update(existing, {email});
|
|
39
|
+
return existing;
|
|
40
|
+
}
|
|
41
|
+
const customer = await stripe.customers.create({
|
|
42
|
+
email,
|
|
43
|
+
metadata: {firebaseUID: uid},
|
|
44
|
+
});
|
|
31
45
|
await ref.set({customerId: customer.id, created_at: Timestamp.now()});
|
|
32
46
|
return customer.id;
|
|
33
47
|
}
|
|
@@ -86,9 +100,12 @@ export const createCheckoutSession = onCall(
|
|
|
86
100
|
if (!priceId) throw new HttpsError("invalid-argument", "priceId is required");
|
|
87
101
|
const successUrl = (request.data?.successUrl as string | undefined) ?? "";
|
|
88
102
|
const cancelUrl = (request.data?.cancelUrl as string | undefined) ?? successUrl;
|
|
103
|
+
// Pre-fill Checkout with the signed-in user's email (UX only; the user is
|
|
104
|
+
// identified by uid, so paying with a different email still updates them).
|
|
105
|
+
const email = request.auth?.token?.email as string | undefined;
|
|
89
106
|
|
|
90
107
|
const stripe = stripeClient();
|
|
91
|
-
const customerId = await getOrCreateCustomer(stripe, uid);
|
|
108
|
+
const customerId = await getOrCreateCustomer(stripe, uid, email);
|
|
92
109
|
|
|
93
110
|
const price = await stripe.prices.retrieve(priceId, {expand: ["product"]});
|
|
94
111
|
const trialDays = trialDaysFor(price, price.product as Stripe.Product);
|
|
@@ -28,6 +28,7 @@ import 'dart:ui' show ImageFilter;
|
|
|
28
28
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
29
29
|
import 'package:flutter/material.dart';
|
|
30
30
|
import 'package:flutter/services.dart' show SystemUiOverlayStyle;
|
|
31
|
+
import 'package:kasy_kit/core/chrome/chrome_visibility.dart';
|
|
31
32
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
32
33
|
import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
|
|
33
34
|
import 'package:kasy_kit/core/widgets/responsive_layout.dart';
|
|
@@ -182,6 +183,60 @@ class KasyFrostedChromeBackground extends StatelessWidget {
|
|
|
182
183
|
}
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
/// A soft top wash — the page [KasyColors.background] fading to transparent —
|
|
187
|
+
/// sized to the app bar's height. Place it behind the [KasyAppBar] in overlay
|
|
188
|
+
/// layouts: when the bar hides on scroll, content melts into the background at
|
|
189
|
+
/// the top instead of hard-cutting under the status bar. Mirrors the wash under
|
|
190
|
+
/// the bottom navigation bar.
|
|
191
|
+
///
|
|
192
|
+
/// It is invisible while the bar is shown (it sits directly behind it) and a
|
|
193
|
+
/// no-op on desktop (there is no app bar there), so it is always safe to add.
|
|
194
|
+
class KasyTopScrollFade extends StatelessWidget {
|
|
195
|
+
const KasyTopScrollFade({super.key});
|
|
196
|
+
|
|
197
|
+
/// Fade zone below the status-bar strip (mobile only).
|
|
198
|
+
static const double _contentFade = 40.0;
|
|
199
|
+
|
|
200
|
+
@override
|
|
201
|
+
Widget build(BuildContext context) {
|
|
202
|
+
final double topInset = MediaQuery.paddingOf(context).top;
|
|
203
|
+
// On web topInset = 0; enforce a 6 px minimum so the topmost pixels are
|
|
204
|
+
// 100 % opaque even without a status bar.
|
|
205
|
+
final double solidHeight = topInset < 6 ? 6.0 : topInset;
|
|
206
|
+
final double totalHeight = solidHeight + _contentFade;
|
|
207
|
+
final double ss = solidHeight / totalHeight; // solidStop
|
|
208
|
+
final Color bg = context.colors.background;
|
|
209
|
+
|
|
210
|
+
// Whole wash is intentionally faint — even the very top peaks at ~0.32, then
|
|
211
|
+
// melts to nothing across the fade zone. "Quase transparente, bem suave."
|
|
212
|
+
// Read from bottom → top: invisible → ghost → a soft hint at the very top.
|
|
213
|
+
double p(double t) => ss + (1 - ss) * t;
|
|
214
|
+
|
|
215
|
+
return IgnorePointer(
|
|
216
|
+
child: SizedBox(
|
|
217
|
+
height: totalHeight,
|
|
218
|
+
child: DecoratedBox(
|
|
219
|
+
decoration: BoxDecoration(
|
|
220
|
+
gradient: LinearGradient(
|
|
221
|
+
begin: Alignment.topCenter,
|
|
222
|
+
end: Alignment.bottomCenter,
|
|
223
|
+
colors: <Color>[
|
|
224
|
+
bg.withValues(alpha: 0.32),
|
|
225
|
+
bg.withValues(alpha: 0.32),
|
|
226
|
+
bg.withValues(alpha: 0.16),
|
|
227
|
+
bg.withValues(alpha: 0.06),
|
|
228
|
+
bg.withValues(alpha: 0.01),
|
|
229
|
+
bg.withValues(alpha: 0.0),
|
|
230
|
+
],
|
|
231
|
+
stops: <double>[0.0, ss, p(0.10), p(0.26), p(0.44), 1.0],
|
|
232
|
+
),
|
|
233
|
+
),
|
|
234
|
+
),
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
185
240
|
/// Layout preset for [KasyAppBar] (pick one; avoids composing many public pieces).
|
|
186
241
|
enum KasyAppBarStyle {
|
|
187
242
|
/// Back + centred title + light/dark theme toggle (dashboard sub-routes).
|
|
@@ -223,6 +278,13 @@ class KasyAppBar extends StatelessWidget {
|
|
|
223
278
|
/// where there is no status-bar inset to provide natural breathing room.
|
|
224
279
|
final double? topInset;
|
|
225
280
|
|
|
281
|
+
/// When true, the bar slides up and fades out as the user scrolls down and
|
|
282
|
+
/// returns on scroll up / at the top, in lock-step with the bottom menu (see
|
|
283
|
+
/// [KasyChromeVisibility]). Opt-in: only safe for pages that lay the bar over
|
|
284
|
+
/// full-height scroll content (the overlay pattern), so an empty gap never
|
|
285
|
+
/// appears where the bar was.
|
|
286
|
+
final bool hideOnScroll;
|
|
287
|
+
|
|
226
288
|
const KasyAppBar({
|
|
227
289
|
super.key,
|
|
228
290
|
required this.title,
|
|
@@ -234,6 +296,7 @@ class KasyAppBar extends StatelessWidget {
|
|
|
234
296
|
this.onThemeToggle,
|
|
235
297
|
this.toolbarHeight,
|
|
236
298
|
this.topInset,
|
|
299
|
+
this.hideOnScroll = false,
|
|
237
300
|
});
|
|
238
301
|
|
|
239
302
|
@override
|
|
@@ -312,11 +375,50 @@ class KasyAppBar extends StatelessWidget {
|
|
|
312
375
|
children: [SizedBox(height: inset), bar],
|
|
313
376
|
)
|
|
314
377
|
: bar;
|
|
315
|
-
|
|
378
|
+
final Widget chrome = KasyFrostedChromeBackground(
|
|
316
379
|
blurSigma: frostBlurSigma,
|
|
317
380
|
padForStatusBar: useSafeArea,
|
|
318
381
|
child: barContent,
|
|
319
382
|
);
|
|
383
|
+
|
|
384
|
+
Widget animatedChrome = chrome;
|
|
385
|
+
if (hideOnScroll) {
|
|
386
|
+
// Slide up + fade as the chrome collapses; the same notifier drives the
|
|
387
|
+
// bottom bar, so the two move together.
|
|
388
|
+
final double barHeight = kasyAppBarBodyTopOverlap(context);
|
|
389
|
+
animatedChrome = ValueListenableBuilder<double>(
|
|
390
|
+
valueListenable: KasyChromeVisibility.instance.reveal,
|
|
391
|
+
builder: (BuildContext context, double reveal, Widget? child) {
|
|
392
|
+
final double hidden = 1 - reveal;
|
|
393
|
+
return Transform.translate(
|
|
394
|
+
offset: Offset(0, -barHeight * hidden),
|
|
395
|
+
child: Opacity(opacity: reveal, child: child),
|
|
396
|
+
);
|
|
397
|
+
},
|
|
398
|
+
child: chrome,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// A top wash sits BEHIND the bar (and stays put even when the bar hides) so
|
|
403
|
+
// content melts into the background at the top — mobile only (tablet/desktop
|
|
404
|
+
// use the sidebar/web header instead). Skipped for modal / page-sheet bars.
|
|
405
|
+
final bool isMobile =
|
|
406
|
+
MediaQuery.sizeOf(context).width < DeviceType.medium.breakpoint;
|
|
407
|
+
final bool standardBar =
|
|
408
|
+
useSafeArea && topInset == null && toolbarHeight == null;
|
|
409
|
+
if (!standardBar || !isMobile) return animatedChrome;
|
|
410
|
+
return Stack(
|
|
411
|
+
clipBehavior: Clip.none,
|
|
412
|
+
children: <Widget>[
|
|
413
|
+
const Positioned(
|
|
414
|
+
top: 0,
|
|
415
|
+
left: 0,
|
|
416
|
+
right: 0,
|
|
417
|
+
child: KasyTopScrollFade(),
|
|
418
|
+
),
|
|
419
|
+
animatedChrome,
|
|
420
|
+
],
|
|
421
|
+
);
|
|
320
422
|
}
|
|
321
423
|
|
|
322
424
|
Widget _buildTrailing(BuildContext context, Color iconFg, Color orbFill) {
|
|
@@ -378,6 +480,10 @@ class KasyOverlayScaffold extends StatelessWidget {
|
|
|
378
480
|
final Future<void> Function()? onRefresh;
|
|
379
481
|
final double refreshIndicatorDisplacement;
|
|
380
482
|
|
|
483
|
+
/// Forwarded to [KasyAppBar.hideOnScroll]. Safe here because this scaffold is
|
|
484
|
+
/// the overlay pattern (bar floats over full-height scroll content).
|
|
485
|
+
final bool hideAppBarOnScroll;
|
|
486
|
+
|
|
381
487
|
const KasyOverlayScaffold({
|
|
382
488
|
super.key,
|
|
383
489
|
required this.title,
|
|
@@ -392,6 +498,7 @@ class KasyOverlayScaffold extends StatelessWidget {
|
|
|
392
498
|
this.backgroundColor,
|
|
393
499
|
this.onRefresh,
|
|
394
500
|
this.refreshIndicatorDisplacement = 48,
|
|
501
|
+
this.hideAppBarOnScroll = false,
|
|
395
502
|
});
|
|
396
503
|
|
|
397
504
|
@override
|
|
@@ -433,6 +540,7 @@ class KasyOverlayScaffold extends StatelessWidget {
|
|
|
433
540
|
onBack: onBack,
|
|
434
541
|
onThemeToggle: onThemeToggle,
|
|
435
542
|
trailing: trailing,
|
|
543
|
+
hideOnScroll: hideAppBarOnScroll,
|
|
436
544
|
),
|
|
437
545
|
),
|
|
438
546
|
],
|