kasy-cli 1.31.12 → 1.31.13
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/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +2 -0
- package/lib/scaffold/backends/supabase/edge-functions/delete-user-account/index.ts +23 -0
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +8 -1
- package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +22 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +2 -0
- package/package.json +2 -2
- package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +37 -0
- package/templates/firebase/lib/components/kasy_app_bar.dart +4 -21
- package/templates/firebase/lib/components/kasy_dialog.dart +48 -30
- package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +13 -1
- package/templates/firebase/lib/i18n/en.i18n.json +3 -2
- package/templates/firebase/lib/i18n/es.i18n.json +3 -2
- package/templates/firebase/lib/i18n/pt.i18n.json +3 -2
package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart
CHANGED
|
@@ -37,6 +37,7 @@ class StripeBackendApi {
|
|
|
37
37
|
required String priceId,
|
|
38
38
|
String? successUrl,
|
|
39
39
|
String? cancelUrl,
|
|
40
|
+
String? locale,
|
|
40
41
|
}) async {
|
|
41
42
|
final Response res = await _client.post(
|
|
42
43
|
'/stripe/checkout-session',
|
|
@@ -44,6 +45,7 @@ class StripeBackendApi {
|
|
|
44
45
|
'priceId': priceId,
|
|
45
46
|
if (successUrl != null) 'successUrl': successUrl,
|
|
46
47
|
if (cancelUrl != null) 'cancelUrl': cancelUrl,
|
|
48
|
+
if (locale != null) 'locale': locale,
|
|
47
49
|
},
|
|
48
50
|
);
|
|
49
51
|
return (res.data as Map)['url'] as String;
|
|
@@ -71,6 +71,29 @@ Deno.serve(async (req: Request) => {
|
|
|
71
71
|
|
|
72
72
|
const supabaseAdmin = createClient(supabaseUrl, serviceRoleKey);
|
|
73
73
|
|
|
74
|
+
// Best-effort Stripe teardown BEFORE deletion: deleting the Stripe customer
|
|
75
|
+
// cancels all of its subscriptions, so billing stops for the deleted account.
|
|
76
|
+
// The DB rows (stripe_customers, subscriptions, ...) are removed by ON DELETE
|
|
77
|
+
// CASCADE, but that does not reach Stripe. Guarded by the secret so a
|
|
78
|
+
// non-Stripe app simply skips it.
|
|
79
|
+
const stripeKey = Deno.env.get("STRIPE_SECRET_KEY");
|
|
80
|
+
if (stripeKey) {
|
|
81
|
+
try {
|
|
82
|
+
const { data: cust } = await supabaseAdmin
|
|
83
|
+
.from("stripe_customers")
|
|
84
|
+
.select("customer_id")
|
|
85
|
+
.eq("user_id", user.id)
|
|
86
|
+
.maybeSingle();
|
|
87
|
+
const customerId = cust?.customer_id as string | undefined;
|
|
88
|
+
if (customerId) {
|
|
89
|
+
const { default: Stripe } = await import("npm:stripe@18");
|
|
90
|
+
await new Stripe(stripeKey).customers.del(customerId);
|
|
91
|
+
}
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.error("[delete-user-account] Stripe cleanup failed:", e);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
try {
|
|
75
98
|
const { error: deleteError } = await supabaseAdmin.auth.admin.deleteUser(user.id);
|
|
76
99
|
|
package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts
CHANGED
|
@@ -97,7 +97,7 @@ Deno.serve(async (req: Request) => {
|
|
|
97
97
|
}
|
|
98
98
|
const uid = user.id;
|
|
99
99
|
|
|
100
|
-
let body: { priceId?: string; successUrl?: string; cancelUrl?: string };
|
|
100
|
+
let body: { priceId?: string; successUrl?: string; cancelUrl?: string; locale?: string };
|
|
101
101
|
try {
|
|
102
102
|
body = await req.json();
|
|
103
103
|
} catch {
|
|
@@ -109,10 +109,17 @@ Deno.serve(async (req: Request) => {
|
|
|
109
109
|
}
|
|
110
110
|
const successUrl = body.successUrl ?? "";
|
|
111
111
|
const cancelUrl = body.cancelUrl ?? successUrl;
|
|
112
|
+
const locale = body.locale?.substring(0, 2).toLowerCase();
|
|
112
113
|
|
|
113
114
|
try {
|
|
114
115
|
const stripe = new Stripe(secretKey);
|
|
115
116
|
const admin = createClient(supabaseUrl, serviceRoleKey);
|
|
117
|
+
// Persist the app language on the user so server-side notifications are sent
|
|
118
|
+
// in the right language (on web there is no registered device to read it
|
|
119
|
+
// from). Mirrors the Firebase checkout behavior.
|
|
120
|
+
if (locale) {
|
|
121
|
+
await admin.from("users").update({ locale }).eq("id", uid);
|
|
122
|
+
}
|
|
116
123
|
const customerId = await getOrCreateCustomer(stripe, admin, uid, user.email);
|
|
117
124
|
|
|
118
125
|
const price = await stripe.prices.retrieve(priceId, { expand: ["product"] });
|
package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart
CHANGED
|
@@ -211,6 +211,18 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
|
|
|
211
211
|
|
|
212
212
|
@override
|
|
213
213
|
Future<Credentials> signinWithGoogle() async {
|
|
214
|
+
if (kIsWeb) {
|
|
215
|
+
// google_sign_in's imperative authenticate() is UNSUPPORTED on web (the v7
|
|
216
|
+
// web plugin throws UnimplementedError). Use Supabase's OAuth redirect flow:
|
|
217
|
+
// it sends the user to Google and back; the session arrives on return and is
|
|
218
|
+
// picked up by the auth state listener. Requires your web origin in Supabase
|
|
219
|
+
// Auth -> URL Configuration (Site URL / Redirect URLs).
|
|
220
|
+
await client.auth.signInWithOAuth(
|
|
221
|
+
OAuthProvider.google,
|
|
222
|
+
redirectTo: Uri.base.origin,
|
|
223
|
+
);
|
|
224
|
+
return Credentials(id: '', token: '');
|
|
225
|
+
}
|
|
214
226
|
final googleSignIn = GoogleSignIn.instance;
|
|
215
227
|
// Web: clientId = Web Client ID (no serverClientId needed)
|
|
216
228
|
// Native iOS: clientId = iOS Client ID, serverClientId = Web Client ID
|
|
@@ -310,6 +322,16 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
|
|
|
310
322
|
|
|
311
323
|
@override
|
|
312
324
|
Future<Credentials> signupFromAnonymousWithGoogle() async {
|
|
325
|
+
if (kIsWeb) {
|
|
326
|
+
// Imperative GoogleSignIn.authenticate() is UNSUPPORTED on web. Use Supabase's
|
|
327
|
+
// OAuth redirect; the session arrives on return via the auth listener. (On web
|
|
328
|
+
// this signs in with Google instead of linking the anonymous user.)
|
|
329
|
+
await client.auth.signInWithOAuth(
|
|
330
|
+
OAuthProvider.google,
|
|
331
|
+
redirectTo: Uri.base.origin,
|
|
332
|
+
);
|
|
333
|
+
return Credentials(id: '', token: '');
|
|
334
|
+
}
|
|
313
335
|
final scopes = ['email'];
|
|
314
336
|
final googleSignIn = GoogleSignIn.instance;
|
|
315
337
|
|
package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart
CHANGED
|
@@ -29,6 +29,7 @@ class StripeBackendApi {
|
|
|
29
29
|
required String priceId,
|
|
30
30
|
String? successUrl,
|
|
31
31
|
String? cancelUrl,
|
|
32
|
+
String? locale,
|
|
32
33
|
}) async {
|
|
33
34
|
final res = await _client.functions.invoke(
|
|
34
35
|
'stripe-create-checkout-session',
|
|
@@ -36,6 +37,7 @@ class StripeBackendApi {
|
|
|
36
37
|
'priceId': priceId,
|
|
37
38
|
if (successUrl != null) 'successUrl': successUrl,
|
|
38
39
|
if (cancelUrl != null) 'cancelUrl': cancelUrl,
|
|
40
|
+
if (locale != null) 'locale': locale,
|
|
39
41
|
},
|
|
40
42
|
);
|
|
41
43
|
return (res.data as Map)['url'] as String;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kasy-cli",
|
|
3
|
-
"version": "1.31.
|
|
3
|
+
"version": "1.31.13",
|
|
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/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",
|
|
35
35
|
"start": "node ./bin/kasy.js",
|
|
36
36
|
"setup": "node ./bin/kasy.js setup",
|
|
37
37
|
"doctor": "node ./bin/kasy.js doctor",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {error} from "firebase-functions/logger";
|
|
2
2
|
import {onCall, onRequest, HttpsError} from "firebase-functions/v2/https";
|
|
3
|
+
import {onDocumentDeleted} from "firebase-functions/v2/firestore";
|
|
3
4
|
import {defineSecret, defineString} from "firebase-functions/params";
|
|
4
5
|
import * as admin from "firebase-admin";
|
|
5
6
|
import {Timestamp} from "firebase-admin/firestore";
|
|
@@ -255,3 +256,39 @@ export const stripeWebhook = onRequest(
|
|
|
255
256
|
}
|
|
256
257
|
},
|
|
257
258
|
);
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// onUserDeletedCleanupStripe — tears down Stripe state when an account is
|
|
262
|
+
// deleted. deleteUserAccount removes the users/{uid} doc; this trigger fires on
|
|
263
|
+
// that deletion and (a) deletes the Stripe customer, which immediately cancels
|
|
264
|
+
// all of its subscriptions so billing stops, and (b) removes the local
|
|
265
|
+
// uid -> customer mapping so no orphan stripe_customers doc is left behind.
|
|
266
|
+
// Lives in the Stripe module so a non-Stripe app never deploys it.
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
export const onUserDeletedCleanupStripe = onDocumentDeleted(
|
|
269
|
+
{document: "users/{userId}", secrets: [stripeSecretKey]},
|
|
270
|
+
async (event) => {
|
|
271
|
+
const uid = event.params.userId;
|
|
272
|
+
const custRef = admin
|
|
273
|
+
.firestore()
|
|
274
|
+
.collection(CUSTOMERS_COLLECTION)
|
|
275
|
+
.doc(uid);
|
|
276
|
+
try {
|
|
277
|
+
const snap = await custRef.get();
|
|
278
|
+
const customerId = snap.data()?.customerId as string | undefined;
|
|
279
|
+
if (customerId) {
|
|
280
|
+
// Deleting the Stripe customer cancels all of its subscriptions, so
|
|
281
|
+
// billing stops for the deleted account in a single call.
|
|
282
|
+
await stripeClient().customers.del(customerId);
|
|
283
|
+
}
|
|
284
|
+
} catch (e) {
|
|
285
|
+
console.log(`[stripe-cleanup] could not delete Stripe customer for ${uid}: ${e}`);
|
|
286
|
+
}
|
|
287
|
+
// Always drop the local mapping so no orphan remains.
|
|
288
|
+
try {
|
|
289
|
+
await custRef.delete();
|
|
290
|
+
} catch (e) {
|
|
291
|
+
console.log(`[stripe-cleanup] could not delete stripe_customers/${uid}: ${e}`);
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
);
|
|
@@ -23,8 +23,6 @@
|
|
|
23
23
|
|
|
24
24
|
library;
|
|
25
25
|
|
|
26
|
-
import 'dart:ui' show ImageFilter;
|
|
27
|
-
|
|
28
26
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
29
27
|
import 'package:flutter/material.dart';
|
|
30
28
|
import 'package:flutter/services.dart' show SystemUiOverlayStyle;
|
|
@@ -111,17 +109,13 @@ Color kasyChromeOrbFillColor(BuildContext context) {
|
|
|
111
109
|
class KasyFrostedChromeBackground extends StatelessWidget {
|
|
112
110
|
final Widget child;
|
|
113
111
|
|
|
114
|
-
///
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
/// Insets content below the notch but keeps tint/blur covering the status-bar
|
|
118
|
-
/// strip so scroll never shows cards behind clock/battery icons.
|
|
112
|
+
/// Insets content below the notch but keeps the solid fill covering the
|
|
113
|
+
/// status-bar strip so scroll never shows cards behind clock/battery icons.
|
|
119
114
|
final bool padForStatusBar;
|
|
120
115
|
|
|
121
116
|
const KasyFrostedChromeBackground({
|
|
122
117
|
super.key,
|
|
123
118
|
required this.child,
|
|
124
|
-
this.blurSigma = 14,
|
|
125
119
|
this.padForStatusBar = true,
|
|
126
120
|
});
|
|
127
121
|
|
|
@@ -165,16 +159,8 @@ class KasyFrostedChromeBackground extends StatelessWidget {
|
|
|
165
159
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
|
166
160
|
value: overlayStyle,
|
|
167
161
|
child: DecoratedBox(
|
|
168
|
-
decoration: BoxDecoration(boxShadow: chromeShadow),
|
|
169
|
-
child:
|
|
170
|
-
child: BackdropFilter(
|
|
171
|
-
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
|
|
172
|
-
child: DecoratedBox(
|
|
173
|
-
decoration: BoxDecoration(color: tint),
|
|
174
|
-
child: content,
|
|
175
|
-
),
|
|
176
|
-
),
|
|
177
|
-
),
|
|
162
|
+
decoration: BoxDecoration(color: tint, boxShadow: chromeShadow),
|
|
163
|
+
child: content,
|
|
178
164
|
),
|
|
179
165
|
);
|
|
180
166
|
}
|
|
@@ -253,7 +239,6 @@ class KasyAppBar extends StatelessWidget {
|
|
|
253
239
|
/// same tap target semantics as the built-in orbs.
|
|
254
240
|
final Widget? trailing;
|
|
255
241
|
final bool useSafeArea;
|
|
256
|
-
final double frostBlurSigma;
|
|
257
242
|
|
|
258
243
|
/// When set, called instead of [ThemeProvider.toggle] for the theme orb
|
|
259
244
|
/// ([KasyAppBarStyle.subpage], [KasyAppBarStyle.rootTab]).
|
|
@@ -282,7 +267,6 @@ class KasyAppBar extends StatelessWidget {
|
|
|
282
267
|
this.onBack,
|
|
283
268
|
this.trailing,
|
|
284
269
|
this.useSafeArea = true,
|
|
285
|
-
this.frostBlurSigma = 14,
|
|
286
270
|
this.onThemeToggle,
|
|
287
271
|
this.toolbarHeight,
|
|
288
272
|
this.topInset,
|
|
@@ -366,7 +350,6 @@ class KasyAppBar extends StatelessWidget {
|
|
|
366
350
|
)
|
|
367
351
|
: bar;
|
|
368
352
|
final Widget chrome = KasyFrostedChromeBackground(
|
|
369
|
-
blurSigma: frostBlurSigma,
|
|
370
353
|
padForStatusBar: useSafeArea,
|
|
371
354
|
child: barContent,
|
|
372
355
|
);
|
|
@@ -506,6 +506,7 @@ Future<void> showKasyConfirmDialog(
|
|
|
506
506
|
required String confirmLabel,
|
|
507
507
|
VoidCallback? onCancel,
|
|
508
508
|
VoidCallback? onConfirm,
|
|
509
|
+
Future<void> Function()? onConfirmAsync,
|
|
509
510
|
bool destructive = false,
|
|
510
511
|
bool barrierDismissible = false,
|
|
511
512
|
IconData? leadingIcon,
|
|
@@ -517,37 +518,54 @@ Future<void> showKasyConfirmDialog(
|
|
|
517
518
|
return showKasyBlurDialog<void>(
|
|
518
519
|
context: context,
|
|
519
520
|
barrierDismissible: barrierDismissible,
|
|
520
|
-
builder: (dialogCtx)
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
521
|
+
builder: (dialogCtx) {
|
|
522
|
+
var isLoading = false;
|
|
523
|
+
return StatefulBuilder(
|
|
524
|
+
builder: (ctx, setState) => KasyDialog(
|
|
525
|
+
leadingIcon: leadingIcon ?? (destructive ? KasyIcons.trash : null),
|
|
526
|
+
iconTone: destructive ? KasyDialogIconTone.danger : iconTone,
|
|
527
|
+
title: title,
|
|
528
|
+
titleCentered: leadingIcon == null && !destructive,
|
|
529
|
+
message: message,
|
|
530
|
+
showCloseButton: false,
|
|
531
|
+
actionsAxis: Axis.horizontal,
|
|
532
|
+
actions: [
|
|
533
|
+
KasyButton(
|
|
534
|
+
label: cancelLabel,
|
|
535
|
+
variant: KasyButtonVariant.outline,
|
|
536
|
+
expand: true,
|
|
537
|
+
onPressed: isLoading
|
|
538
|
+
? null
|
|
539
|
+
: () {
|
|
540
|
+
Navigator.of(dialogCtx).pop();
|
|
541
|
+
onCancel?.call();
|
|
542
|
+
},
|
|
543
|
+
),
|
|
544
|
+
KasyButton(
|
|
545
|
+
label: confirmLabel,
|
|
546
|
+
variant: destructive
|
|
547
|
+
? KasyButtonVariant.destructive
|
|
548
|
+
: KasyButtonVariant.primary,
|
|
549
|
+
expand: true,
|
|
550
|
+
isLoading: isLoading,
|
|
551
|
+
onPressed: isLoading
|
|
552
|
+
? null
|
|
553
|
+
: () {
|
|
554
|
+
if (onConfirmAsync != null) {
|
|
555
|
+
setState(() => isLoading = true);
|
|
556
|
+
onConfirmAsync().whenComplete(() {
|
|
557
|
+
if (dialogCtx.mounted) Navigator.of(dialogCtx).pop();
|
|
558
|
+
});
|
|
559
|
+
} else {
|
|
560
|
+
Navigator.of(dialogCtx).pop();
|
|
561
|
+
onConfirm?.call();
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
),
|
|
565
|
+
],
|
|
548
566
|
),
|
|
549
|
-
|
|
550
|
-
|
|
567
|
+
);
|
|
568
|
+
},
|
|
551
569
|
);
|
|
552
570
|
}
|
|
553
571
|
|
|
@@ -21,7 +21,19 @@ class DeleteUserButton extends ConsumerWidget {
|
|
|
21
21
|
cancelLabel: t.settings.delete_account.cancel,
|
|
22
22
|
confirmLabel: t.settings.delete_account.confirm,
|
|
23
23
|
destructive: true,
|
|
24
|
-
|
|
24
|
+
onConfirmAsync: () async {
|
|
25
|
+
try {
|
|
26
|
+
await ref.read(userStateNotifierProvider.notifier).deleteAccount();
|
|
27
|
+
} catch (_) {
|
|
28
|
+
if (context.mounted) {
|
|
29
|
+
showKasyToast(
|
|
30
|
+
context,
|
|
31
|
+
title: t.settings.delete_account.error,
|
|
32
|
+
tone: KasyToastTone.danger,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
25
37
|
),
|
|
26
38
|
);
|
|
27
39
|
}
|
|
@@ -502,9 +502,10 @@
|
|
|
502
502
|
"delete_account": {
|
|
503
503
|
"button": "I want to delete my account",
|
|
504
504
|
"title": "Delete your account?",
|
|
505
|
-
"content": "Warning: this
|
|
505
|
+
"content": "Warning: this is permanent. You will lose any active subscription, and creating a new account later (even with the same email) will not restore it.",
|
|
506
506
|
"cancel": "Cancel",
|
|
507
|
-
"confirm": "Yes, delete"
|
|
507
|
+
"confirm": "Yes, delete",
|
|
508
|
+
"error": "Something went wrong. Please try again."
|
|
508
509
|
},
|
|
509
510
|
"admin": {
|
|
510
511
|
"update_bottom_sheet": "Update bottom sheet",
|
|
@@ -502,9 +502,10 @@
|
|
|
502
502
|
"delete_account": {
|
|
503
503
|
"button": "Quiero eliminar mi cuenta",
|
|
504
504
|
"title": "¿Quieres eliminar tu cuenta?",
|
|
505
|
-
"content": "Advertencia: esta acción es
|
|
505
|
+
"content": "Advertencia: esta acción es permanente. Perderás cualquier suscripción activa, y crear una cuenta nueva más tarde (incluso con el mismo correo) no la recuperará.",
|
|
506
506
|
"cancel": "Cancelar",
|
|
507
|
-
"confirm": "Sí, eliminar"
|
|
507
|
+
"confirm": "Sí, eliminar",
|
|
508
|
+
"error": "Algo salió mal. Por favor, inténtalo de nuevo."
|
|
508
509
|
},
|
|
509
510
|
"admin": {
|
|
510
511
|
"update_bottom_sheet": "Actualizar bottom sheet",
|
|
@@ -502,9 +502,10 @@
|
|
|
502
502
|
"delete_account": {
|
|
503
503
|
"button": "Quero excluir minha conta",
|
|
504
504
|
"title": "Quer excluir sua conta?",
|
|
505
|
-
"content": "Atenção: esta ação é
|
|
505
|
+
"content": "Atenção: esta ação é permanente. Você perde qualquer assinatura ativa, e criar uma nova conta depois (mesmo com o mesmo e-mail) não recupera ela.",
|
|
506
506
|
"cancel": "Cancelar",
|
|
507
|
-
"confirm": "Sim, excluir"
|
|
507
|
+
"confirm": "Sim, excluir",
|
|
508
|
+
"error": "Algo deu errado. Por favor, tente novamente."
|
|
508
509
|
},
|
|
509
510
|
"admin": {
|
|
510
511
|
"update_bottom_sheet": "Atualizar bottom sheet",
|