diff --git a/src/lib/mobx-request/request-store.test.ts b/src/lib/mobx-request/request-store.test.ts index 03738d61..e118739f 100644 --- a/src/lib/mobx-request/request-store.test.ts +++ b/src/lib/mobx-request/request-store.test.ts @@ -33,3 +33,23 @@ test("request - cache", async () => { await when(() => request2.result.status === "success"); expect(fn2).toBeCalledTimes(2); }); + +test("request - loading - default", async () => { + const fn = () => new Promise((resolve) => setTimeout(resolve, 300)); + const request = new RequestStore(fn); + request.execute(); + expect(request.result.status).toBe("loading"); + await when(() => request.result.status === "success"); + request.execute(); + expect(request.result.status).toBe("loading"); +}); + +test("request - loading - swr", async () => { + const fn = () => new Promise((resolve) => setTimeout(resolve, 300)); + const request = new RequestStore(fn, { staleWhileRevalidate: true }); + request.execute(); + expect(request.result.status).toBe("loading"); + await when(() => request.result.status === "success"); + request.execute(); + expect(request.result.status).toBe("success"); +}); diff --git a/src/lib/mobx-request/request-store.ts b/src/lib/mobx-request/request-store.ts index 00f929da..c9bff22c 100644 --- a/src/lib/mobx-request/request-store.ts +++ b/src/lib/mobx-request/request-store.ts @@ -10,6 +10,7 @@ type ExecuteResult = SuccessResult | ErrorResult; type Options = { cacheId?: string; + staleWhileRevalidate?: boolean; }; const cacheStorage = new Map(); @@ -37,7 +38,12 @@ export class RequestStore { return this.result as unknown as ExecuteResult; } - this.result = { data: null, status: "loading" }; + if ( + !this.options?.staleWhileRevalidate || + this.result.status !== "success" + ) { + this.result = { data: null, status: "loading" }; + } try { const data = await this.fetchFn(...args); diff --git a/src/lib/telegram/haptics.ts b/src/lib/telegram/haptics.ts index cfe5c117..7ad8e20c 100644 --- a/src/lib/telegram/haptics.ts +++ b/src/lib/telegram/haptics.ts @@ -2,14 +2,18 @@ import WebApp from "@twa-dev/sdk"; const isIos = WebApp.platform === "ios"; -export const hapticNotification = (type: "error" | "success" | "warning") => { +export type HapticNotificationType = "error" | "success" | "warning"; + +export const hapticNotification = (type: HapticNotificationType) => { if (!isIos) { return; } WebApp.HapticFeedback.notificationOccurred(type); }; -export const hapticImpact = (type: "light" | "medium" | "heavy") => { +export type HapticImpactType = "light" | "medium" | "heavy"; + +export const hapticImpact = (type: HapticImpactType) => { if (!isIos) { return; } diff --git a/src/lib/telegram/use-main-button.tsx b/src/lib/telegram/use-main-button.tsx index 3953ec1a..948023e9 100644 --- a/src/lib/telegram/use-main-button.tsx +++ b/src/lib/telegram/use-main-button.tsx @@ -6,22 +6,31 @@ import { autorun } from "mobx"; // Track visible state to avoid flickering let isVisible = false; +const hide = () => { + if (WebApp.platform !== "ios" && WebApp.platform !== "android") { + WebApp.MainButton.hide(); + isVisible = false; + return; + } + + // Avoid flickering of the Telegram main button + isVisible = false; + setTimeout(() => { + if (isVisible) { + return; + } + WebApp.MainButton.hide(); + isVisible = false; + }, 100); +}; + export const useMainButton = ( text: string | (() => string), onClick: () => void, condition?: () => boolean, ) => { const hideMainButton = () => { - // Avoid flickering of the Telegram main button - isVisible = false; - setTimeout(() => { - if (isVisible) { - return; - } - WebApp.MainButton.hide(); - isVisible = false; - }, 300); - + hide(); WebApp.MainButton.offClick(onClick); WebApp.MainButton.hideProgress(); }; diff --git a/src/screens/ai-mass-creation/store/ai-mass-creation-store.test.ts b/src/screens/ai-mass-creation/store/ai-mass-creation-store.test.ts index b5399e21..b2ed6097 100644 --- a/src/screens/ai-mass-creation/store/ai-mass-creation-store.test.ts +++ b/src/screens/ai-mass-creation/store/ai-mass-creation-store.test.ts @@ -20,9 +20,10 @@ vi.mock("../../../translations/t.ts", () => { }; }); -vi.mock("../../shared/snackbar.tsx", () => { +vi.mock("../../shared/snackbar/snackbar.tsx", () => { return { - showSnackBar: vi.fn(), + notifyError: vi.fn(), + notifySuccess: vi.fn(), }; }); diff --git a/src/screens/ai-mass-creation/store/ai-mass-creation-store.ts b/src/screens/ai-mass-creation/store/ai-mass-creation-store.ts index 8352acae..694092de 100644 --- a/src/screens/ai-mass-creation/store/ai-mass-creation-store.ts +++ b/src/screens/ai-mass-creation/store/ai-mass-creation-store.ts @@ -17,7 +17,7 @@ import { import { RequestStore } from "../../../lib/mobx-request/request-store.ts"; import { screenStore } from "../../../store/screen-store.ts"; import { assert } from "../../../lib/typescript/assert.ts"; -import { notifySuccess } from "../../shared/snackbar.tsx"; +import { notifySuccess } from "../../shared/snackbar/snackbar.tsx"; import { deckListStore } from "../../../store/deck-list-store.ts"; import { showConfirm } from "../../../lib/telegram/show-confirm.ts"; @@ -228,7 +228,7 @@ export class AiMassCreationStore { cards: this.massCreationForm.cards.value, }); - if (result.status !== "success") { + if (result.status === "error") { throw new Error("Failed to add cards"); } diff --git a/src/screens/app.tsx b/src/screens/app.tsx index 825efc15..d907c15d 100644 --- a/src/screens/app.tsx +++ b/src/screens/app.tsx @@ -35,7 +35,8 @@ import { isRunningWithinTelegram } from "../lib/telegram/is-running-within-teleg import { FreezeCardsScreenLazy } from "./freeze-cards/freeze-cards-screen-lazy.tsx"; import { AiMassCreationScreen } from "./ai-mass-creation/ai-mass-creation-screen.tsx"; import { AiMassCreationStoreProvider } from "./ai-mass-creation/store/ai-mass-creation-store-provider.tsx"; -import { SnackbarProviderWrapper } from "./shared/snackbar.tsx"; + +import { SnackbarProviderWrapper } from "./shared/snackbar/snackbar-provider-wrapper.tsx"; export const App = observer(() => { useRestoreFullScreenExpand(); diff --git a/src/screens/component-catalog/snackbar-story.tsx b/src/screens/component-catalog/snackbar-story.tsx index d870ad39..979b7021 100644 --- a/src/screens/component-catalog/snackbar-story.tsx +++ b/src/screens/component-catalog/snackbar-story.tsx @@ -1,9 +1,6 @@ import React from "react"; -import { - notifyError, - notifySuccess, - SnackbarProviderWrapper, -} from "../shared/snackbar.tsx"; +import { notifyError, notifySuccess } from "../shared/snackbar/snackbar.tsx"; +import { SnackbarProviderWrapper } from "../shared/snackbar/snackbar-provider-wrapper.tsx"; export const SnackbarStory = () => { return ( @@ -13,7 +10,7 @@ export const SnackbarStory = () => { Show success snackbar - diff --git a/src/screens/deck-form/store/deck-form-store.test.ts b/src/screens/deck-form/store/deck-form-store.test.ts index 81421cbd..bf2b4517 100644 --- a/src/screens/deck-form/store/deck-form-store.test.ts +++ b/src/screens/deck-form/store/deck-form-store.test.ts @@ -163,6 +163,13 @@ vi.mock("../../../store/user-store.ts", () => { }; }); +vi.mock("../../shared/snackbar/snackbar.tsx", () => { + return { + notifyError: vi.fn(), + notifySuccess: vi.fn(), + }; +}); + describe("deck form store", () => { afterEach(() => { vi.clearAllMocks(); diff --git a/src/screens/deck-form/store/deck-form-store.ts b/src/screens/deck-form/store/deck-form-store.ts index 5ef5fd23..abddb011 100644 --- a/src/screens/deck-form/store/deck-form-store.ts +++ b/src/screens/deck-form/store/deck-form-store.ts @@ -9,7 +9,7 @@ import { TextField, validators, } from "mobx-form-lite"; -import { action, makeAutoObservable } from "mobx"; +import { action, makeAutoObservable, runInAction } from "mobx"; import { assert } from "../../../lib/typescript/assert.ts"; import { upsertDeckRequest } from "../../../api/api.ts"; import { screenStore } from "../../../store/screen-store.ts"; @@ -33,6 +33,8 @@ import { } from "./card-form-store-interface.ts"; import { UpsertDeckRequest } from "../../../../functions/upsert-deck.ts"; import { UnwrapArray } from "../../../lib/typescript/unwrap-array.ts"; +import { RequestStore } from "../../../lib/mobx-request/request-store.ts"; +import { notifyError } from "../../shared/snackbar/snackbar.tsx"; export type CardAnswerFormType = { id: string; @@ -171,7 +173,7 @@ export class DeckFormStore implements CardFormStoreInterface { cardFormIndex?: number; cardFormType?: "new" | "edit"; form?: DeckFormType; - isSending = false; + upsertDeckRequest = new RequestStore(upsertDeckRequest); cardInnerScreen = new TextField(null); deckInnerScreen?: "cardList" | "speakingCards"; cardFilter = { @@ -184,6 +186,10 @@ export class DeckFormStore implements CardFormStoreInterface { makeAutoObservable(this, {}, { autoBind: true }); } + get isSending() { + return this.upsertDeckRequest.isLoading; + } + get deckFormScreen() { if (this.cardFormIndex !== undefined) { return "cardForm"; @@ -364,7 +370,7 @@ export class DeckFormStore implements CardFormStoreInterface { onSaveCard() { const isEdit = this.cardForm?.id; - this.onDeckSave().then( + this.onDeckSave( action(() => { if (isEdit) { return; @@ -474,22 +480,20 @@ export class DeckFormStore implements CardFormStoreInterface { deckListStore.isFullScreenLoaderVisible = true; - this.onDeckSave() - .then( - action(() => { - this.deckInnerScreen = "cardList"; - this.cardFormIndex = undefined; - this.cardFormType = undefined; - }), - ) - .finally( - action(() => { - deckListStore.isFullScreenLoaderVisible = false; - }), - ); + this.onDeckSave( + action(() => { + this.deckInnerScreen = "cardList"; + this.cardFormIndex = undefined; + this.cardFormType = undefined; + }), + ).finally( + action(() => { + deckListStore.isFullScreenLoaderVisible = false; + }), + ); } - onDeckSave() { + async onDeckSave(onSuccess?: () => void) { assert(this.form, "onDeckSave: form is empty"); if (this.form.cards.length === 0) { @@ -508,8 +512,6 @@ export class DeckFormStore implements CardFormStoreInterface { return Promise.reject(); } - this.isSending = true; - // Avoid sending huge collections on every save // Only new and touched cards are sent to the server const newCards = this.form.cards.filter((card) => !card.id); @@ -518,7 +520,7 @@ export class DeckFormStore implements CardFormStoreInterface { ); const cardsToSend = newCards.concat(touchedCards).map(cardFormToApi); - return upsertDeckRequest({ + const result = await this.upsertDeckRequest.execute({ id: this.form.id, title: this.form.title.value, description: this.form.description.value, @@ -527,20 +529,22 @@ export class DeckFormStore implements CardFormStoreInterface { speakField: this.form.speakingCardsField.value, folderId: this.form.folderId, cardsToRemoveIds: this.form.cardsToRemoveIds, - }) - .then( - action(({ deck, folders, cardsToReview }) => { - this.form = createUpdateForm(deck.id, deck, () => this.cardForm); - deckListStore.replaceDeck(deck, true); - deckListStore.updateFolders(folders); - deckListStore.updateCardsToReview(cardsToReview); - }), - ) - .finally( - action(() => { - this.isSending = false; - }), - ); + }); + + if (result.status === "error") { + notifyError({ e: result.error, info: "Error saving deck" }); + return; + } + + const { deck, folders, cardsToReview } = result.data; + + runInAction(() => { + this.form = createUpdateForm(deck.id, deck, () => this.cardForm); + deckListStore.replaceDeck(deck, true); + deckListStore.updateFolders(folders); + deckListStore.updateCardsToReview(cardsToReview); + onSuccess?.(); + }); } quitCardForm() { diff --git a/src/screens/deck-form/store/quick-add-card-form-store.ts b/src/screens/deck-form/store/quick-add-card-form-store.ts index 398db419..9d20bfe3 100644 --- a/src/screens/deck-form/store/quick-add-card-form-store.ts +++ b/src/screens/deck-form/store/quick-add-card-form-store.ts @@ -4,7 +4,7 @@ import { createAnswerTypeField, createCardSideField, } from "./deck-form-store.ts"; -import { action, makeAutoObservable } from "mobx"; +import { makeAutoObservable } from "mobx"; import { formTouchAll, isFormDirty, @@ -24,6 +24,8 @@ import { CardInnerScreenType, } from "./card-form-store-interface.ts"; import { DeckSpeakFieldEnum } from "../../../../functions/db/deck/decks-with-cards-schema.ts"; +import { RequestStore } from "../../../lib/mobx-request/request-store.ts"; +import { notifyError, notifySuccess } from "../../shared/snackbar/snackbar.tsx"; export class QuickAddCardFormStore implements CardFormStoreInterface { cardForm: CardFormType = { @@ -34,7 +36,7 @@ export class QuickAddCardFormStore implements CardFormStoreInterface { options: null, answers: createAnswerListField([], () => this.cardForm), }; - isSending = false; + addCardRequest = new RequestStore(addCardRequest); cardInnerScreen = new TextField(null); constructor( @@ -46,7 +48,11 @@ export class QuickAddCardFormStore implements CardFormStoreInterface { makeAutoObservable(this, {}, { autoBind: true }); } - onSaveCard() { + get isSending() { + return this.addCardRequest.isLoading; + } + + async onSaveCard() { if (!isFormValid(this.cardForm)) { formTouchAll(this.cardForm); return; @@ -55,8 +61,6 @@ export class QuickAddCardFormStore implements CardFormStoreInterface { const screen = screenStore.screen; assert(screen.type === "cardQuickAddForm"); - this.isSending = true; - const body: AddCardRequest = { deckId: screen.deckId, card: { @@ -72,16 +76,15 @@ export class QuickAddCardFormStore implements CardFormStoreInterface { }, }; - return addCardRequest(body) - .then(() => { - screenStore.back(); - deckListStore.load(); - }) - .finally( - action(() => { - this.isSending = false; - }), - ); + const result = await this.addCardRequest.execute(body); + if (result.status === "error") { + notifyError({ e: result.error, info: "Error adding quick card" }); + return; + } + + screenStore.back(); + deckListStore.load(); + notifySuccess(t("card_added")); } async onBackCard() { diff --git a/src/screens/deck-list/main-screen.tsx b/src/screens/deck-list/main-screen.tsx index 08fcaeb1..77ec0255 100644 --- a/src/screens/deck-list/main-screen.tsx +++ b/src/screens/deck-list/main-screen.tsx @@ -40,7 +40,7 @@ export const MainScreen = observer(() => { } /> - {deckListStore.isMyInfoLoading && + {deckListStore.myInfoRequest.isLoading && range(deckListStore.skeletonLoaderData.myDecksCount).map((i) => ( ))} @@ -154,7 +154,7 @@ export const MainScreen = observer(() => { ) : null} - {deckListStore.isMyInfoLoading && + {deckListStore.myInfoRequest.isLoading && range(deckListStore.skeletonLoaderData.publicCount).map((i) => ( ))} diff --git a/src/screens/deck-review/deck-finished.tsx b/src/screens/deck-review/deck-finished.tsx index 7f56caff..1e8fdc8b 100644 --- a/src/screens/deck-review/deck-finished.tsx +++ b/src/screens/deck-review/deck-finished.tsx @@ -29,7 +29,7 @@ export const DeckFinished = observer((props: Props) => { useMainButton(t("go_back"), () => { screenStore.go({ type: "main" }); }); - useTelegramProgress(() => reviewStore.isReviewSending); + useTelegramProgress(() => reviewStore.reviewCardsRequest.isLoading); return ( diff --git a/src/screens/deck-review/deck-preview.tsx b/src/screens/deck-review/deck-preview.tsx index fd1a225e..f6413643 100644 --- a/src/screens/deck-review/deck-preview.tsx +++ b/src/screens/deck-review/deck-preview.tsx @@ -27,7 +27,7 @@ export const DeckPreview = observer(() => { screenStore.back(); }); - useTelegramProgress(() => deckListStore.isDeckCardsLoading); + useTelegramProgress(() => deckListStore.deckWithCardsRequest.isLoading); useScrollToTopOnMount(); useMainButton( @@ -78,7 +78,7 @@ export const DeckPreview = observer(() => {
- {!deckListStore.isDeckCardsLoading && ( + {!deckListStore.deckWithCardsRequest.isLoading && (
{ }; }); +vi.mock("../../shared/snackbar/snackbar.tsx", () => { + return { + notifyError: vi.fn(), + notifySuccess: vi.fn(), + }; +}); + const cardToSnapshot = (card: CardUnderReviewStore) => ({ back: card.back, deckName: card.deckName, @@ -311,7 +318,7 @@ describe("card form store", () => { reviewStore.open(); reviewStore.changeState(CardState.Never); - await when(() => !reviewStore.isSendingInProgress); + await when(() => !reviewStore.reviewCardsRequestInProgress.isLoading); expect(reviewStore.sentResult).toEqual({ neverIds: [5], @@ -334,7 +341,7 @@ describe("card form store", () => { reviewStore.changeState(CardState.Remember); reviewStore.open(); reviewStore.changeState(CardState.Remember); - await when(() => !reviewStore.isSendingInProgress); + await when(() => !reviewStore.reviewCardsRequestInProgress.isLoading); expect(reviewCardsReviewMock).toHaveBeenCalledTimes(2); expect(reviewStore.sentResult).toEqual({ @@ -345,7 +352,7 @@ describe("card form store", () => { reviewStore.open(); reviewStore.changeState(CardState.Remember); - await when(() => !reviewStore.isSendingInProgress); + await when(() => !reviewStore.reviewCardsRequestInProgress.isLoading); expect(reviewCardsReviewMock).toHaveBeenCalledTimes(2); expect(reviewStore.cardsToSend).toEqual([{ id: 9, outcome: "correct" }]); diff --git a/src/screens/deck-review/store/review-store.ts b/src/screens/deck-review/store/review-store.ts index aa84139a..e20bb1af 100644 --- a/src/screens/deck-review/store/review-store.ts +++ b/src/screens/deck-review/store/review-store.ts @@ -1,5 +1,5 @@ import { CardState, CardUnderReviewStore } from "./card-under-review-store.ts"; -import { action, makeAutoObservable } from "mobx"; +import { makeAutoObservable, runInAction } from "mobx"; import { assert } from "../../../lib/typescript/assert.ts"; import { reviewCardsRequest } from "../../../api/api.ts"; import { ReviewOutcome } from "../../../../functions/services/review-card.ts"; @@ -11,6 +11,8 @@ import { } from "../../../lib/telegram/haptics.ts"; import { showConfirm } from "../../../lib/telegram/show-confirm.ts"; import { t } from "../../../translations/t.ts"; +import { RequestStore } from "../../../lib/mobx-request/request-store.ts"; +import { notifyError } from "../../shared/snackbar/snackbar.tsx"; // Don't wait until the user has finished reviewing all the cards to send the progress const cardProgressSend = 3; @@ -32,10 +34,10 @@ export class ReviewStore { rememberIds: [], neverIds: [], }; - isSendingInProgress = false; initialCardCount?: number; - isReviewSending = false; + reviewCardsRequest = new RequestStore(reviewCardsRequest); + reviewCardsRequestInProgress = new RequestStore(reviewCardsRequest); isStudyAnyway = false; constructor() { @@ -216,46 +218,46 @@ export class ReviewStore { this.sendProgress(); } - private sendProgress() { - if (this.isReviewSending || this.isSendingInProgress) { + private async sendProgress() { + if ( + this.reviewCardsRequest.isLoading || + this.reviewCardsRequestInProgress.isLoading + ) { return; } const cardsToSendInProgress = this.cardsToSend.filter( (card) => card.outcome === "correct" || card.outcome === "never", ); - if (cardsToSendInProgress.length >= cardProgressSend) { - this.isSendingInProgress = true; - - reviewCardsRequest({ - cards: cardsToSendInProgress, - isStudyAnyway: this.isStudyAnyway, - }) - .then( - action(() => { - this.sentResult.rememberIds.push( - ...cardsToSendInProgress - .filter((sentCard) => sentCard.outcome === "correct") - .map((sentCard) => sentCard.id), - ); - this.sentResult.neverIds.push( - ...cardsToSendInProgress - .filter((sentCard) => sentCard.outcome === "never") - .map((sentCard) => sentCard.id), - ); - }), - ) - .catch( - action(() => { - console.error("Unable to send card progress"); - }), - ) - .finally( - action(() => { - this.isSendingInProgress = false; - }), - ); + + const shouldSendInProgress = + cardsToSendInProgress.length >= cardProgressSend; + if (!shouldSendInProgress) { + return; + } + + const result = await this.reviewCardsRequestInProgress.execute({ + cards: cardsToSendInProgress, + isStudyAnyway: this.isStudyAnyway, + }); + + if (result.status === "error") { + notifyError({ e: result.error, info: "Error sending review progress" }); + return; } + + runInAction(() => { + this.sentResult.rememberIds.push( + ...cardsToSendInProgress + .filter((sentCard) => sentCard.outcome === "correct") + .map((sentCard) => sentCard.id), + ); + this.sentResult.neverIds.push( + ...cardsToSendInProgress + .filter((sentCard) => sentCard.outcome === "never") + .map((sentCard) => sentCard.id), + ); + }); } get isFinished() { @@ -313,17 +315,15 @@ export class ReviewStore { return; } - this.isReviewSending = true; - - return reviewCardsRequest({ + const result = await this.reviewCardsRequest.execute({ cards: this.cardsToSend, isStudyAnyway: this.isStudyAnyway, - }).finally( - action(() => { - onReviewSuccess?.(); - hapticNotification("success"); - this.isReviewSending = false; - }), - ); + }); + if (result.status === "error") { + notifyError({ e: result.error, info: "Error submitting review" }); + return; + } + onReviewSuccess?.(); + hapticNotification("success"); } } diff --git a/src/screens/folder-form/folder-form.tsx b/src/screens/folder-form/folder-form.tsx index f8ebba98..f82e8a38 100644 --- a/src/screens/folder-form/folder-form.tsx +++ b/src/screens/folder-form/folder-form.tsx @@ -37,7 +37,7 @@ export const FolderForm = observer(() => { folderStore.onBack(); }); - useTelegramProgress(() => folderStore.isSending); + useTelegramProgress(() => folderStore.folderUpsertRequest.isLoading); if (!folderForm) { return null; diff --git a/src/screens/folder-form/store/folder-form-store.ts b/src/screens/folder-form/store/folder-form-store.ts index ffbfc728..9c2e8c03 100644 --- a/src/screens/folder-form/store/folder-form-store.ts +++ b/src/screens/folder-form/store/folder-form-store.ts @@ -8,13 +8,15 @@ import { validators, } from "mobx-form-lite"; import { t } from "../../../translations/t.ts"; -import { action, makeAutoObservable } from "mobx"; +import { makeAutoObservable } from "mobx"; import { screenStore } from "../../../store/screen-store.ts"; import { assert } from "../../../lib/typescript/assert.ts"; import { decksMineRequest, folderUpsertRequest } from "../../../api/api.ts"; import { deckListStore } from "../../../store/deck-list-store.ts"; import { showConfirm } from "../../../lib/telegram/show-confirm.ts"; import { RequestStore } from "../../../lib/mobx-request/request-store.ts"; +import { notifyError } from "../../shared/snackbar/snackbar.tsx"; +import { hapticNotification } from "../../../lib/telegram/haptics.ts"; const createFolderTitleField = (title: string) => { return new TextField(title, { @@ -40,7 +42,7 @@ type FolderForm = { export class FolderFormStore { folderForm?: FolderForm; - isSending = false; + folderUpsertRequest = new RequestStore(folderUpsertRequest); decksMineRequest = new RequestStore(() => decksMineRequest().then((response) => response.decks), ); @@ -107,7 +109,7 @@ export class FolderFormStore { }); } - onFolderSave() { + async onFolderSave() { if (!this.folderForm) { return; } @@ -118,24 +120,24 @@ export class FolderFormStore { const screen = screenStore.screen; assert(screen.type === "folderForm"); - this.isSending = true; - - folderUpsertRequest({ + const result = await this.folderUpsertRequest.execute({ id: screen.folderId, title: this.folderForm.title.value, description: this.folderForm.description.value, deckIds: this.folderForm.decks.value.map((deck) => deck.id), - }) - .then(({ folders, folder }) => { - deckListStore.updateFolders(folders); - assert(this.folderForm); - formUnTouchAll(this.folderForm); - screenStore.go({ type: "folderPreview", folderId: folder.id }); - }) - .finally( - action(() => { - this.isSending = false; - }), - ); + }); + + if (result.status === "error") { + const info = `Error saving folder ${screen.folderId ?? ""}`; + notifyError({ e: result.error, info: info }); + return; + } + + const { folders, folder } = result.data; + deckListStore.updateFolders(folders); + assert(this.folderForm); + formUnTouchAll(this.folderForm); + screenStore.go({ type: "folderPreview", folderId: folder.id }); + hapticNotification("success"); } } diff --git a/src/screens/folder-review/folder-preview.tsx b/src/screens/folder-review/folder-preview.tsx index 1632069a..c582146a 100644 --- a/src/screens/folder-review/folder-preview.tsx +++ b/src/screens/folder-review/folder-preview.tsx @@ -80,7 +80,7 @@ export const FolderPreview = observer(() => {
- {!deckListStore.isCatalogFolderLoading && ( + {!deckListStore.getFolderWithDecksCards.isLoading && (
{ store.freeze, () => store.isFreezeButtonVisible, ); - useTelegramProgress(() => store.isLoading); + useTelegramProgress(() => store.cardsFreezeRequest.isLoading); return ( diff --git a/src/screens/freeze-cards/freeze-cards-store.ts b/src/screens/freeze-cards/store/freeze-cards-store.ts similarity index 60% rename from src/screens/freeze-cards/freeze-cards-store.ts rename to src/screens/freeze-cards/store/freeze-cards-store.ts index 3133fc9a..2510188c 100644 --- a/src/screens/freeze-cards/freeze-cards-store.ts +++ b/src/screens/freeze-cards/store/freeze-cards-store.ts @@ -1,17 +1,16 @@ import { formTouchAll, isFormValid, TextField } from "mobx-form-lite"; -import { action, makeAutoObservable, runInAction } from "mobx"; -import { cardsFreezeRequest } from "../../api/api.ts"; -import { assert } from "../../lib/typescript/assert.ts"; -import { reportHandledError } from "../../lib/rollbar/rollbar.tsx"; -import { showAlert } from "../../lib/telegram/show-alert.ts"; -import { screenStore } from "../../store/screen-store.ts"; -import { hapticImpact } from "../../lib/telegram/haptics.ts"; -import { showConfirm } from "../../lib/telegram/show-confirm.ts"; -import { t } from "../../translations/t.ts"; -import { formatFrozenCards } from "./translations.ts"; +import { makeAutoObservable } from "mobx"; +import { cardsFreezeRequest } from "../../../api/api.ts"; +import { assert } from "../../../lib/typescript/assert.ts"; +import { screenStore } from "../../../store/screen-store.ts"; +import { showConfirm } from "../../../lib/telegram/show-confirm.ts"; +import { t } from "../../../translations/t.ts"; +import { formatFrozenCards } from "../translations.ts"; +import { RequestStore } from "../../../lib/mobx-request/request-store.ts"; +import { notifyError, notifySuccess } from "../../shared/snackbar/snackbar.tsx"; export class FreezeCardsStore { - isLoading = false; + cardsFreezeRequest = new RequestStore(cardsFreezeRequest); form = { freezeCardSelect: new TextField(null, { onChangeCallback: (value) => { @@ -78,24 +77,17 @@ export class FreezeCardsStore { return; } - runInAction(() => { - this.isLoading = true; - }); assert(this.freezeDays !== null, "freezeDays is null"); - cardsFreezeRequest({ days: this.freezeDays }) - .then(({ frozenCards }) => { - screenStore.go({ type: "main" }); - hapticImpact("heavy"); - showAlert(formatFrozenCards(frozenCards)); - }) - .catch((error) => { - reportHandledError("Failed to freeze cards", error); - showAlert(t("freeze_error")); - }) - .finally( - action(() => { - this.isLoading = false; - }), - ); + const result = await this.cardsFreezeRequest.execute({ + days: this.freezeDays, + }); + if (result.status === "error") { + notifyError({ info: "Error freezing cards", e: result.error }); + return; + } + + const { frozenCards } = result.data; + screenStore.go({ type: "main" }); + notifySuccess(formatFrozenCards(frozenCards)); } } diff --git a/src/screens/freeze-cards/translations.ts b/src/screens/freeze-cards/translations.ts index 644ce206..903aef2d 100644 --- a/src/screens/freeze-cards/translations.ts +++ b/src/screens/freeze-cards/translations.ts @@ -6,7 +6,7 @@ export const formatFrozenCards = (cards: number) => { case "en": { return cards === 1 ? `1 card has been frozen` - : `${cards} have been frozen`; + : `${cards} cards have been frozen`; } case "ru": { const rules = new Intl.PluralRules("ru-RU"); diff --git a/src/screens/plans/format-paid-until.test.ts b/src/screens/plans/format-paid-until.test.ts index 51470905..9d8fcecc 100644 --- a/src/screens/plans/format-paid-until.test.ts +++ b/src/screens/plans/format-paid-until.test.ts @@ -5,7 +5,7 @@ describe("formatPaidUntil", () => { it("returns a formatted date when a valid ISO date string is provided", () => { vi.stubGlobal("navigator", { language: "en-US" }); const input = "2023-04-01"; - const expected = "April 1, 2023"; // Adjust the expected result based on your locale + const expected = "April 1, 2023"; expect(formatPaidUntil(input)).toBe(expected); }); }); diff --git a/src/screens/plans/store/plans-screen-store.ts b/src/screens/plans/store/plans-screen-store.ts index 6c1eb8d4..dbe3111e 100644 --- a/src/screens/plans/store/plans-screen-store.ts +++ b/src/screens/plans/store/plans-screen-store.ts @@ -4,8 +4,7 @@ import { getBuyText } from "../translations.ts"; import { assert } from "../../../lib/typescript/assert.ts"; import WebApp from "@twa-dev/sdk"; import { RequestStore } from "../../../lib/mobx-request/request-store.ts"; -import { notifyError } from "../../shared/snackbar.tsx"; -import { t } from "../../../translations/t.ts"; +import { notifyError } from "../../shared/snackbar/snackbar.tsx"; export class PlansScreenStore { plansRequest = new RequestStore(allPlansRequest); @@ -51,12 +50,9 @@ export class PlansScreenStore { assert(this.selectedPlanId !== null); const result = await this.createOrderRequest.execute(this.selectedPlanId); - if (result.status !== "success") { - notifyError(t("error_try_again"), { - info: "Order creation failed", - e: result.error, - plan: this.selectedPlanId, - }); + if (result.status === "error") { + const info = `Order creation failed. Plan: ${this.selectedPlanId}`; + notifyError({ info: info, e: result.error }); return; } diff --git a/src/screens/share-deck/store/share-deck-form-store.ts b/src/screens/share-deck/store/share-deck-form-store.ts index d70b897f..7f03c9f1 100644 --- a/src/screens/share-deck/store/share-deck-form-store.ts +++ b/src/screens/share-deck/store/share-deck-form-store.ts @@ -11,7 +11,7 @@ import { import { DeckAccessType } from "../../../../functions/db/custom-types.ts"; import { persistableField } from "../../../lib/mobx-form-lite-persistable/persistable-field.ts"; import { RequestStore } from "../../../lib/mobx-request/request-store.ts"; -import { notifyError } from "../../shared/snackbar.tsx"; +import { notifyError } from "../../shared/snackbar/snackbar.tsx"; const getRequestFiltersForScreen = () => { const screen = screenStore.screen; @@ -90,8 +90,8 @@ export class ShareDeckFormStore { : null, }); - if (result.status !== "success") { - notifyError(t("error_try_again"), { + if (result.status === "error") { + notifyError({ info: "Error sharing deck or folder", e: result.error, deckId, diff --git a/src/screens/shared/snackbar.tsx b/src/screens/shared/snackbar.tsx deleted file mode 100644 index 7eecbed4..00000000 --- a/src/screens/shared/snackbar.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { - closeSnackbar, - enqueueSnackbar, - type SnackbarKey, - SnackbarProvider, -} from "notistack"; -import { css, cx } from "@emotion/css"; -import { reset } from "../../ui/reset.ts"; -import { theme } from "../../ui/theme.tsx"; -import { reportHandledError } from "../../lib/rollbar/rollbar.tsx"; -import { userStore } from "../../store/user-store.ts"; - -const ClearSnackbar = (props: { snackbarId: SnackbarKey }) => { - const { snackbarId } = props; - return ( - - ); -}; - -const sharedStyles = { - borderRadius: theme.borderRadius, - backgroundColor: "rgba(56, 56, 56, 0.85)", - boxShadow: theme.boxShadow, -}; - -export const notifySuccess = (message: string) => { - enqueueSnackbar(message, { - variant: "success", - action: (snackbarId) => , - style: sharedStyles, - autoHideDuration: 3000, - }); -}; - -export const notifyError = (message: string, report?: any) => { - enqueueSnackbar(message, { - variant: "error", - action: (snackbarId) => , - style: sharedStyles, - autoHideDuration: 4000, - }); - - if (report) { - report.userId = userStore.user?.id; - reportHandledError(message, report); - } -}; - -export const SnackbarProviderWrapper = () => { - return ( - - -
- ), - error: ( -
- -
- ), - }} - /> - ); -}; diff --git a/src/screens/shared/snackbar/clear-snackbar.tsx b/src/screens/shared/snackbar/clear-snackbar.tsx new file mode 100644 index 00000000..ee270e8a --- /dev/null +++ b/src/screens/shared/snackbar/clear-snackbar.tsx @@ -0,0 +1,25 @@ +import { closeSnackbar, SnackbarKey } from "notistack"; +import { css, cx } from "@emotion/css"; +import { reset } from "../../../ui/reset.ts"; +import { theme } from "../../../ui/theme.tsx"; + +type Props = { snackbarId: SnackbarKey }; + +export const ClearSnackbar = (props: Props) => { + const { snackbarId } = props; + return ( + + ); +}; diff --git a/src/screens/shared/snackbar/snackbar-provider-wrapper.tsx b/src/screens/shared/snackbar/snackbar-provider-wrapper.tsx new file mode 100644 index 00000000..205141c0 --- /dev/null +++ b/src/screens/shared/snackbar/snackbar-provider-wrapper.tsx @@ -0,0 +1,36 @@ +import { SnackbarProvider } from "notistack"; +import { css, cx } from "@emotion/css"; +import { theme } from "../../../ui/theme.tsx"; + +export const SnackbarProviderWrapper = () => { + return ( + + +
+ ), + error: ( +
+ +
+ ), + }} + /> + ); +}; diff --git a/src/screens/shared/snackbar/snackbar.tsx b/src/screens/shared/snackbar/snackbar.tsx new file mode 100644 index 00000000..51814d77 --- /dev/null +++ b/src/screens/shared/snackbar/snackbar.tsx @@ -0,0 +1,64 @@ +import { enqueueSnackbar } from "notistack"; +import { css } from "@emotion/css"; +import { theme } from "../../../ui/theme.tsx"; +import { reportHandledError } from "../../../lib/rollbar/rollbar.tsx"; +import { userStore } from "../../../store/user-store.ts"; +import { hapticNotification } from "../../../lib/telegram/haptics.ts"; +import { t } from "../../../translations/t.ts"; +import { ClearSnackbar } from "./clear-snackbar.tsx"; + +const sharedStyles = { + borderRadius: theme.borderRadius, + backgroundColor: "rgba(56, 56, 56, 0.85)", + boxShadow: theme.boxShadow, +}; + +const defaultDuration = 3000; + +type NotifyErrorOptions = { + message?: string; + duration?: number; +}; + +type NotifySuccessOptions = { + duration?: number; +}; + +export const notifySuccess = ( + message: string, + options?: NotifySuccessOptions, +) => { + const duration = options?.duration || defaultDuration; + enqueueSnackbar(message, { + variant: "success", + action: (snackbarId) => , + style: sharedStyles, + autoHideDuration: duration, + }); + hapticNotification("success"); +}; + +export const notifyError = (report?: any, options?: NotifyErrorOptions) => { + const message = options?.message || t("error_solving"); + const duration = options?.duration || defaultDuration; + + enqueueSnackbar( +
+
{t("error")}
+
{message}
+
, + { + variant: "error", + action: (snackbarId) => , + style: sharedStyles, + autoHideDuration: duration, + }, + ); + + hapticNotification("error"); + + if (report) { + report.userId = userStore.user?.id; + reportHandledError(message, report); + } +}; diff --git a/src/screens/user-settings/store/user-settings-store.tsx b/src/screens/user-settings/store/user-settings-store.tsx index aa54efdc..e31b0316 100644 --- a/src/screens/user-settings/store/user-settings-store.tsx +++ b/src/screens/user-settings/store/user-settings-store.tsx @@ -11,9 +11,8 @@ import { formatTime } from "../generate-time-range.tsx"; import { userSettingsRequest } from "../../../api/api.ts"; import { UserSettingsRequest } from "../../../../functions/user-settings.ts"; import { userStore } from "../../../store/user-store.ts"; -import { hapticNotification } from "../../../lib/telegram/haptics.ts"; import { RequestStore } from "../../../lib/mobx-request/request-store.ts"; -import { notifyError, notifySuccess } from "../../shared/snackbar.tsx"; +import { notifyError, notifySuccess } from "../../shared/snackbar/snackbar.tsx"; import { t } from "../../../translations/t.ts"; const DEFAULT_TIME = "12:00"; @@ -79,14 +78,10 @@ export class UserSettingsStore { const result = await this.userSettingsRequest.execute(body); if (result.status === "error") { - notifyError(t("error_try_again"), { - e: result.error, - info: "Error updating user settings", - }); + notifyError({ e: result.error, info: "Error updating user settings" }); return; } - hapticNotification("success"); notifySuccess(t("user_settings_updated")); userStore.updateSettings({ is_remind_enabled: body.isRemindNotifyEnabled, diff --git a/src/store/deck-list-store.test.ts b/src/store/deck-list-store.test.ts index 98a9586e..f60396f6 100644 --- a/src/store/deck-list-store.test.ts +++ b/src/store/deck-list-store.test.ts @@ -25,6 +25,8 @@ vi.mock("../screens/shared/notify-payment.ts", () => { vi.mock("../api/api.ts", () => { return { + deckWithCardsRequest: () => {}, + getFolderWithDecksCards: () => {}, reviewCardsRequest: () => {}, myInfoRequest: (): Promise => { return Promise.resolve({ @@ -177,6 +179,13 @@ vi.mock("../translations/t.ts", () => { }; }); +vi.mock("../screens/shared/snackbar/snackbar.tsx", () => { + return { + notifySuccess: () => {}, + notifyError: () => {}, + }; +}); + describe("deck list store", () => { afterEach(() => { vi.clearAllMocks(); diff --git a/src/store/deck-list-store.ts b/src/store/deck-list-store.ts index dd920db4..881efe04 100644 --- a/src/store/deck-list-store.ts +++ b/src/store/deck-list-store.ts @@ -1,4 +1,4 @@ -import { action, makeAutoObservable, when } from "mobx"; +import { action, makeAutoObservable, runInAction, when } from "mobx"; import { addDeckToMineRequest, addFolderToMineRequest, @@ -37,6 +37,8 @@ import { notifyPaymentFailed, notifyPaymentSuccess, } from "../screens/shared/notify-payment.ts"; +import { RequestStore } from "../lib/mobx-request/request-store.ts"; +import { notifyError } from "../screens/shared/snackbar/snackbar.tsx"; export enum StartParamType { RepeatAll = "repeat_all", @@ -72,8 +74,10 @@ export type DeckListItem = { const collapsedDecksLimit = 3; export class DeckListStore { - myInfo?: Omit; - isMyInfoLoading = false; + myInfo?: MyInfoResponse; + myInfoRequest = new RequestStore(myInfoRequest, { + staleWhileRevalidate: true, + }); isFullScreenLoaderVisible = false; isSharedDeckLoaded = false; @@ -82,12 +86,12 @@ export class DeckListStore { skeletonLoaderData = { publicCount: 3, myDecksCount: 3 }; - isDeckCardsLoading = false; + deckWithCardsRequest = new RequestStore(deckWithCardsRequest); isMyDecksExpanded = new BooleanToggle(false); catalogFolder?: FolderWithDecksWithCards; - isCatalogFolderLoading = false; + getFolderWithDecksCards = new RequestStore(getFolderWithDecksCards); constructor() { makeAutoObservable( @@ -98,7 +102,10 @@ export class DeckListStore { } get isCatalogItemLoading() { - return this.isDeckCardsLoading || this.isCatalogFolderLoading; + return ( + this.deckWithCardsRequest.isLoading || + this.getFolderWithDecksCards.isLoading + ); } loadFirstTime(startParam?: string) { @@ -106,24 +113,15 @@ export class DeckListStore { this.handleStartParam(startParam); } - load() { - if (!this.myInfo) { - // Stale-while-revalidate approach - this.isMyInfoLoading = true; + async load() { + const result = await this.myInfoRequest.execute(); + if (result.status === "error") { + return; } - - myInfoRequest() - .then( - action((result) => { - this.myInfo = result; - userStore.setUser(result.user, result.plans); - }), - ) - .finally( - action(() => { - this.isMyInfoLoading = false; - }), - ); + runInAction(() => { + this.myInfo = result.data; + }); + userStore.setUser(result.data.user, result.data.plans); } async onDuplicateDeck(deckId: number) { @@ -276,7 +274,7 @@ export class DeckListStore { screenStore.go({ type: "folderPreview", folderId: folder.id }); } - openFolderFromCatalog(folderWithoutDecks: CatalogFolderDbType) { + async openFolderFromCatalog(folderWithoutDecks: CatalogFolderDbType) { assert(this.myInfo); if ( this.myInfo.folders.find( @@ -290,28 +288,25 @@ export class DeckListStore { return; } - this.isCatalogFolderLoading = true; this.catalogFolder = { ...folderWithoutDecks, decks: [], }; screenStore.go({ type: "folderPreview", folderId: this.catalogFolder.id }); - getFolderWithDecksCards(folderWithoutDecks.id) - .then( - action(({ folder }) => { - this.catalogFolder = folder; - }), - ) - .catch((e) => { - reportHandledError("Error while retrieving folder", e, { - folderId: folderWithoutDecks.id, - }); - }) - .finally( - action(() => { - this.isCatalogFolderLoading = false; - }), - ); + + const result = await this.getFolderWithDecksCards.execute( + folderWithoutDecks.id, + ); + if (result.status === "error") { + notifyError({ + e: result.error, + info: `Error while retrieving folder: ${folderWithoutDecks.id}`, + }); + return; + } + + const { folder } = result.data; + this.catalogFolder = folder; } get canReview() { @@ -375,7 +370,7 @@ export class DeckListStore { return deck.author_id === userStore.myId || userStore.isAdmin; } - openDeckFromCatalog(deck: DeckWithCardsDbType, isMine: boolean) { + async openDeckFromCatalog(deck: DeckWithCardsDbType, isMine: boolean) { assert(this.myInfo); if (isMine) { screenStore.go({ type: "deckMine", deckId: deck.id }); @@ -386,16 +381,12 @@ export class DeckListStore { } screenStore.go({ type: "deckPublic", deckId: deck.id }); - this.isDeckCardsLoading = true; - deckWithCardsRequest(deck.id) - .then((deckWithCards) => { - this.replaceDeck(deckWithCards); - }) - .finally( - action(() => { - this.isDeckCardsLoading = false; - }), - ); + const result = await this.deckWithCardsRequest.execute(deck.id); + if (result.status === "error") { + notifyError({ e: result.error, info: `Error opening deck: ${deck.id}` }); + return; + } + this.replaceDeck(result.data); } goDeckById(deckId: number, backScreen?: RouteType) { diff --git a/src/translations/t.ts b/src/translations/t.ts index f3872b8d..dc555cec 100644 --- a/src/translations/t.ts +++ b/src/translations/t.ts @@ -27,6 +27,7 @@ const en = { wysiwyg_clear_formatting: "Clear formatting", my_decks: "My decks", formatting: "Formatting", + card_added: "Card has been added", choose_what_to_create: "Choose what to create", deck: "Deck", deck_description: "A collection of cards", @@ -205,7 +206,6 @@ const en = { validate_under_100: "Please enter a number less than 100", freeze_confirm_freeze: "Are you sure you want to freeze your cards? This action can't be undone.", - freeze_error: "Failed to freeze cards. Please try again later", freeze_title: "Freeze cards", how: "How it works", freeze_how_title: `When you freeze cards, they will be postponed for the selected number of days, allowing you to take a break. Use it on holidays or whenever you need a rest.`, @@ -223,7 +223,8 @@ const en = { ui_loading: "Loading...", is_on: "On", is_off: "Off", - error_try_again: "An error occurred. We're solving the issue", + error_solving: "We're solving the issue", + error: "Error", user_settings_updated: "Settings have been updated", ai_cards_generate: "Generate cards", ai_cards_title: "Generate cards with AI", @@ -248,6 +249,7 @@ const en = { type Translation = typeof en; const ru: Translation = { + error: "Ошибка", user_settings_updated: "Настройки обновлены", is_on: "Включено", is_off: "Выключено", @@ -349,6 +351,7 @@ const ru: Translation = { category_History: "История", save: "Сохранить", add_card: "Добавить карточку", + card_added: "Карточка была добавлена", card_front_title: "Лицевая сторона", card_back_title: "Обратная сторона", card_front_side_hint: "Вопрос или подсказка", @@ -453,7 +456,6 @@ const ru: Translation = { freeze_confirm_freeze: "Вы уверены, что хотите заморозить карточки? Это действие нельзя отменить.", freeze_title: "Заморозить карточки", - freeze_error: "Не удалось заморозить карточки. Пожалуйста, попробуйте позже", freeze_for_or_manual: "или введите вручную", freeze_how_title: `Когда вы замораживаете карточки, они будут отложены на выбранное количество дней, позволяя вам отдохнуть. Используйте это во время отпуска или когда вам нужен перерыв.`, freeze_rule_2: @@ -465,7 +467,7 @@ const ru: Translation = { validate_under_100: "Пожалуйста, введите число меньше 100", freeze_hint: "Отложите изучение карточек", ui_loading: "Загрузка...", - error_try_again: "Произошла ошибка. Мы решаем проблему", + error_solving: "Мы решаем проблему", ai_cards_added: "Карточки добавлены", quit_without_saving: "Выйти без сохранения?", ai_cards_validation_key_required: "API ключ обязателен", @@ -695,8 +697,6 @@ const es: Translation = { validate_positive: "Por favor, introduce un número positivo", freeze_confirm_freeze: "¿Estás seguro de que quieres congelar tus tarjetas? Esta acción no se puede deshacer.", - freeze_error: - "No se pudieron congelar las tarjetas. Por favor, inténtalo de nuevo más tarde", freeze_for: "Congelar durante", freeze_for_or_manual: "o escribe manualmente", how: "Cómo funciona", @@ -711,7 +711,7 @@ const es: Translation = { freeze_rule_3: "La congelación de tarjetas no se puede deshacer.", freeze_title: "Congelar tarjetas", freeze_hint: "Posponer el estudio de las tarjetas", - error_try_again: "Ocurrió un error. Estamos resolviendo el problema", + error_solving: "Estamos resolviendo el problema", ai_cards_title: "Creación de tarjetas con IA", ai_cards_confirm_delete: "¿Eliminar esta tarjeta?", ai_cards_by_ai: "Tarjetas generadas", @@ -728,14 +728,18 @@ const es: Translation = { ai_cards_prompt_back: "Dorso de la tarjeta", ai_cards_api_keys: "Clave de API", understood: "Entendido", + card_added: "Tarjeta añadida", quit_without_saving: "¿Salir sin guardar?", ai_cards_validation_key_required: "Se requiere una clave de API", ai_cards_added: "Tarjetas añadidas", + error: "Error", }; const ptBr: Translation = { + error: "Erro", user_settings_updated: "Configurações atualizadas", - error_try_again: "Ocorreu um erro. Estamos resolvendo o problema", + error_solving: "Estamos resolvendo o problema", + card_added: "Cartão adicionado", is_on: "Ligado", is_off: "Desligado", folder_form_no_decks: "Não há baralhos na pasta", @@ -951,8 +955,6 @@ const ptBr: Translation = { how: "Como funciona", freeze_for: "Congelar por", freeze_for_or_manual: "ou digite manualmente", - freeze_error: - "Não foi possível congelar os cartões. Por favor, tente novamente mais tarde", freeze_confirm_freeze: "Tem certeza de que deseja congelar seus cartões? Esta ação não pode ser desfeita.", validate_positive: "Por favor, insira um número positivo",