diff --git a/package-lock.json b/package-lock.json
index 6583639b..c7a3d4fc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"@twa-dev/sdk": "^7.0.0",
"@xelene/tgui": "^2.0.4",
"autosize": "^6.0.1",
+ "canvas-confetti": "^1.9.2",
"colord": "^2.9.3",
"dompurify": "^3.0.9",
"framer-motion": "^10.16.4",
@@ -41,6 +42,7 @@
"@cloudflare/workers-types": "^4.20231002.0",
"@peculiar/webcrypto": "^1.4.3",
"@types/autosize": "^4.0.3",
+ "@types/canvas-confetti": "^1.6.4",
"@types/dompurify": "^3.0.5",
"@types/luxon": "^3.4.0",
"@types/node": "^20.8.0",
@@ -1816,6 +1818,12 @@
"@babel/types": "^7.20.7"
}
},
+ "node_modules/@types/canvas-confetti": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.6.4.tgz",
+ "integrity": "sha512-fNyZ/Fdw/Y92X0vv7B+BD6ysHL4xVU5dJcgzgxLdGbn8O3PezZNIJpml44lKM0nsGur+o/6+NZbZeNTt00U1uA==",
+ "dev": true
+ },
"node_modules/@types/chai": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.6.tgz",
@@ -2798,6 +2806,15 @@
}
]
},
+ "node_modules/canvas-confetti": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.2.tgz",
+ "integrity": "sha512-6Xi7aHHzKwxZsem4mCKoqP6YwUG3HamaHHAlz1hTNQPCqXhARFpSXnkC9TWlahHY5CG6hSL5XexNjxK8irVErg==",
+ "funding": {
+ "type": "donate",
+ "url": "https://www.paypal.me/kirilvatev"
+ }
+ },
"node_modules/capnp-ts": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/capnp-ts/-/capnp-ts-0.7.0.tgz",
diff --git a/package.json b/package.json
index 24d1bd86..0069b700 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"@twa-dev/sdk": "^7.0.0",
"@xelene/tgui": "^2.0.4",
"autosize": "^6.0.1",
+ "canvas-confetti": "^1.9.2",
"colord": "^2.9.3",
"dompurify": "^3.0.9",
"framer-motion": "^10.16.4",
@@ -60,6 +61,7 @@
"@cloudflare/workers-types": "^4.20231002.0",
"@peculiar/webcrypto": "^1.4.3",
"@types/autosize": "^4.0.3",
+ "@types/canvas-confetti": "^1.6.4",
"@types/dompurify": "^3.0.5",
"@types/luxon": "^3.4.0",
"@types/node": "^20.8.0",
diff --git a/public/privacy-policy.html b/public/privacy-policy.html
new file mode 100644
index 00000000..bf429af0
--- /dev/null
+++ b/public/privacy-policy.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Document
+
+
+TOS
+
+
diff --git a/public/terms-of-service.html b/public/terms-of-service.html
new file mode 100644
index 00000000..bf429af0
--- /dev/null
+++ b/public/terms-of-service.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Document
+
+
+TOS
+
+
diff --git a/src/api/api.ts b/src/api/api.ts
index b8be0e7e..526e8142 100644
--- a/src/api/api.ts
+++ b/src/api/api.ts
@@ -41,9 +41,9 @@ import {
CreateOrderRequest,
CreateOrderResponse,
} from "../../functions/order-plan.ts";
-import { MyPlansResponse } from "../../functions/my-plans.ts";
import { DuplicateFolderResponse } from "../../functions/duplicate-folder.ts";
import { MyStatisticsResponse } from "../../functions/my-statistics.ts";
+import { AllPlansResponse } from "../../functions/plans.ts";
export const healthRequest = () => {
return request("/health");
@@ -165,8 +165,8 @@ export const createOrderRequest = (planId: number) => {
);
};
-export const myPlansRequest = () => {
- return request("/my-plans");
+export const allPlansRequest = () => {
+ return request("/plans");
};
export const myStatisticsRequest = () => {
diff --git a/src/screens/deck-list/main-screen.tsx b/src/screens/deck-list/main-screen.tsx
index c2bfae68..ab692a64 100644
--- a/src/screens/deck-list/main-screen.tsx
+++ b/src/screens/deck-list/main-screen.tsx
@@ -2,7 +2,9 @@ import React, { Fragment } from "react";
import { observer } from "mobx-react-lite";
import { css, cx } from "@emotion/css";
import { PublicDeck } from "./public-deck.tsx";
-import { DeckRowWithCardsToReview } from "../shared/deck-row-with-cards-to-review/deck-row-with-cards-to-review.tsx";
+import {
+ DeckRowWithCardsToReview
+} from "../shared/deck-row-with-cards-to-review/deck-row-with-cards-to-review.tsx";
import { deckListStore } from "../../store/deck-list-store.ts";
import { useMount } from "../../lib/react/use-mount.ts";
import { Hint } from "../../ui/hint.tsx";
diff --git a/src/screens/plans/plan-item.tsx b/src/screens/plans/plan-item.tsx
new file mode 100644
index 00000000..c9ff8347
--- /dev/null
+++ b/src/screens/plans/plan-item.tsx
@@ -0,0 +1,70 @@
+import { css, cx } from "@emotion/css";
+import { theme } from "../../ui/theme.tsx";
+import { Flex } from "../../ui/flex.tsx";
+import React from "react";
+import { HorizontalDivider } from "../../ui/horizontal-divider.tsx";
+
+type Props = {
+ title: string;
+ description?: string[];
+ onClick?: () => void;
+ isSelected?: boolean;
+ paidUntil?: string;
+};
+
+export const PlanItem = (props: Props) => {
+ const { title, description, onClick, isSelected, paidUntil } = props;
+
+ return (
+
+
+ {title}
+
+ {description && (
+
+ {description.map((item, i) => (
+ - {item}
+ ))}
+
+ )}
+ {paidUntil && (
+ <>
+
+
+ Paid until: {paidUntil}
+
+ >
+ )}
+
+ );
+};
diff --git a/src/screens/plans/plans-screen.tsx b/src/screens/plans/plans-screen.tsx
index b00011c7..c753ef4d 100644
--- a/src/screens/plans/plans-screen.tsx
+++ b/src/screens/plans/plans-screen.tsx
@@ -2,51 +2,93 @@ import { observer } from "mobx-react-lite";
import { Screen } from "../shared/screen.tsx";
import { useBackButton } from "../../lib/telegram/use-back-button.tsx";
import { screenStore } from "../../store/screen-store.ts";
-import { t } from "../../translations/t.ts";
import { Flex } from "../../ui/flex.tsx";
-import React from "react";
-import { Choice } from "../../ui/choice.tsx";
-import { SelectedPlan } from "./selected-plan.tsx";
+import React, { useState } from "react";
+import { PlanItem } from "./plan-item.tsx";
+import { useMainButton } from "../../lib/telegram/use-main-button.tsx";
+import { Hint } from "../../ui/hint.tsx";
+import { useMount } from "../../lib/react/use-mount.ts";
+import { FullScreenLoader } from "../../ui/full-screen-loader.tsx";
+import { getPlanDescription, getPlanFullTile } from "./translations.ts";
+import { PlansScreenStore } from "./store/plans-screen-store.ts";
+import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx";
+import { userStore } from "../../store/user-store.ts";
+import { DateTime } from "luxon";
+import { ExternalLink } from "../../ui/external-link.tsx";
export const PlansScreen = observer(() => {
+ const [store] = useState(() => new PlansScreenStore());
useBackButton(() => {
screenStore.back();
});
+ useMount(() => {
+ store.load();
+ });
+
+ useMainButton(
+ () => store.buyText,
+ () => {
+ store.createOrder();
+ },
+ () => store.isBuyButtonVisible,
+ );
+
+ useTelegramProgress(() => store.isCreatingOrder);
+
+ if (store.plansRequest?.state === "pending") {
+ return ;
+ }
+
return (
-
-
-
-
- {
- screenStore.go({ type: "deckForm" });
- }}
- />
- {
- screenStore.go({ type: "folderForm" });
- }}
- />
+
+ {store.plans.map((plan) => {
+ const paidPlan = userStore.plans?.find(
+ (p) => p.plan_id === plan.id,
+ );
+ const paidUntil = paidPlan?.until_date
+ ? DateTime.fromISO(paidPlan.until_date).toLocaleString(
+ DateTime.DATE_FULL,
+ )
+ : undefined;
+
+ return (
+ {
+ if (!paidUntil) {
+ store.selectPlan(plan.id);
+ }
+ }}
+ />
+ );
+ })}
+
+ By purchasing MemoCard you agree to the{" "}
+
+ Terms of Service
+ {" "}
+ and{" "}
+
+ Privacy Policy
+
+ .
+
);
diff --git a/src/screens/plans/selected-plan.tsx b/src/screens/plans/selected-plan.tsx
deleted file mode 100644
index 3cd45ebd..00000000
--- a/src/screens/plans/selected-plan.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import React, { ReactNode } from "react";
-import { css } from "@emotion/css";
-import { theme } from "../../ui/theme.tsx";
-
-export const SelectedPlan = (props: { children: ReactNode }) => {
- return (
-
- {props.children}
-
- Current plan
-
-
- );
-};
diff --git a/src/screens/plans/store/plans-screen-store.ts b/src/screens/plans/store/plans-screen-store.ts
new file mode 100644
index 00000000..2aad3a87
--- /dev/null
+++ b/src/screens/plans/store/plans-screen-store.ts
@@ -0,0 +1,66 @@
+import { action, makeAutoObservable } from "mobx";
+import { AllPlansResponse } from "../../../../functions/plans.ts";
+import {
+ fromPromise,
+ IPromiseBasedObservable,
+} from "../../../lib/mobx-from-promise/from-promise.ts";
+import { allPlansRequest, createOrderRequest } from "../../../api/api.ts";
+import { getBuyText } from "../translations.ts";
+import { assert } from "../../../lib/typescript/assert.ts";
+import WebApp from "@twa-dev/sdk";
+
+export class PlansScreenStore {
+ plansRequest?: IPromiseBasedObservable;
+ isCreatingOrder = false;
+ selectedPlanId: number | null = null;
+
+ constructor() {
+ makeAutoObservable(this, {}, { autoBind: true });
+ }
+
+ load() {
+ this.plansRequest = fromPromise(allPlansRequest());
+ }
+
+ get plans() {
+ return this.plansRequest?.state === "fulfilled"
+ ? this.plansRequest.value.plans
+ : [];
+ }
+
+ get selectedPlan() {
+ return this.plans.find((plan) => plan.id === this.selectedPlanId);
+ }
+
+ get isBuyButtonVisible() {
+ return this.selectedPlanId !== null;
+ }
+
+ selectPlan(planId: number) {
+ this.selectedPlanId = planId;
+ }
+
+ get buyText() {
+ const selectedPlan = this.selectedPlan;
+ if (!selectedPlan) {
+ return "";
+ }
+
+ return getBuyText(selectedPlan);
+ }
+
+ createOrder() {
+ assert(this.selectedPlanId !== null);
+
+ this.isCreatingOrder = true;
+ createOrderRequest(this.selectedPlanId)
+ .then((response) => {
+ WebApp.openTelegramLink(response.payLink);
+ })
+ .finally(
+ action(() => {
+ this.isCreatingOrder = false;
+ }),
+ );
+ }
+}
diff --git a/src/screens/plans/translations.ts b/src/screens/plans/translations.ts
new file mode 100644
index 00000000..2c576a41
--- /dev/null
+++ b/src/screens/plans/translations.ts
@@ -0,0 +1,44 @@
+import { PlanDb } from "../../../functions/db/plan/schema.ts";
+
+export const getPlanTitle = (plan: PlanDb) => {
+ switch (plan.type) {
+ case "plus":
+ return `Plus`;
+ case "pro":
+ return `Pro`;
+ case "deck_producer":
+ return "";
+ default:
+ return plan.type satisfies never;
+ }
+};
+
+export const getPlanDescription = (plan: PlanDb) => {
+ switch (plan.type) {
+ case "plus":
+ return ["Duplicate folder, deck", "Priority support"];
+ case "pro":
+ return [
+ "Duplicate folder, deck",
+ "One time deck and folder links",
+ "Deck and folder access duration",
+ "High priority support",
+ ];
+ case "deck_producer":
+ return [];
+ default:
+ return plan.type satisfies never;
+ }
+};
+
+export const getPlanFullTile = (plan: PlanDb) => {
+ return `${getPlanTitle(plan)} (${formatPlanPrice(plan)}/mo.)`;
+};
+
+export const formatPlanPrice = (plan: PlanDb) => {
+ return `$${plan.price}`;
+};
+
+export const getBuyText = (plan: PlanDb) => {
+ return `Buy "${getPlanTitle(plan)}" for ${formatPlanPrice(plan)}`;
+};
diff --git a/src/screens/user-settings/user-settings-lazy.tsx b/src/screens/user-settings/user-settings-lazy.tsx
index 88c0672f..2ac5d649 100644
--- a/src/screens/user-settings/user-settings-lazy.tsx
+++ b/src/screens/user-settings/user-settings-lazy.tsx
@@ -8,8 +8,8 @@ const UserSettingsStoreProvider = lazy(() =>
);
const UserSettingsMain = lazy(() =>
- import("./user-settings-main.tsx").then((module) => ({
- default: module.UserSettingsMain,
+ import("./user-settings-screen.tsx").then((module) => ({
+ default: module.UserSettingsScreen,
})),
);
diff --git a/src/screens/user-settings/user-settings-main.tsx b/src/screens/user-settings/user-settings-screen.tsx
similarity index 86%
rename from src/screens/user-settings/user-settings-main.tsx
rename to src/screens/user-settings/user-settings-screen.tsx
index 136e6a04..c69c676b 100644
--- a/src/screens/user-settings/user-settings-main.tsx
+++ b/src/screens/user-settings/user-settings-screen.tsx
@@ -1,7 +1,7 @@
import { observer } from "mobx-react-lite";
import { useUserSettingsStore } from "./store/user-settings-store-context.tsx";
import { deckListStore } from "../../store/deck-list-store.ts";
-import React from "react";
+import React, { useState } from "react";
import { useMount } from "../../lib/react/use-mount.ts";
import { generateTimeRange } from "./generate-time-range.tsx";
import { useMainButton } from "../../lib/telegram/use-main-button.tsx";
@@ -21,9 +21,9 @@ import { links } from "../shared/links.ts";
export const timeRanges = generateTimeRange();
-export const UserSettingsMain = observer(() => {
+export const UserSettingsScreen = observer(() => {
const userSettingsStore = useUserSettingsStore();
- // const [plansClickedTimes, setPlansClickedTimes] = useState(0);
+ const [plansClickedTimes, setPlansClickedTimes] = useState(0);
useMount(() => {
userSettingsStore.load();
});
@@ -116,17 +116,17 @@ export const UserSettingsMain = observer(() => {
{t("settings_support_hint")}
- {/* {*/}
- {/* setPlansClickedTimes((value) => value + 1);*/}
- {/* if (plansClickedTimes >= 5) {*/}
- {/* screenStore.go({ type: "plans" });*/}
- {/* }*/}
- {/* }}*/}
- {/*>*/}
- {/* Plans*/}
- {/**/}
- {/*Payment plan settings*/}
+ {
+ setPlansClickedTimes((value) => value + 1);
+ if (plansClickedTimes >= 5) {
+ screenStore.go({ type: "plans" });
+ }
+ }}
+ >
+ Plans
+
+ Payment plan settings
);
});
diff --git a/src/store/deck-list-store.test.ts b/src/store/deck-list-store.test.ts
index 965b3945..a149e73c 100644
--- a/src/store/deck-list-store.test.ts
+++ b/src/store/deck-list-store.test.ts
@@ -16,6 +16,13 @@ vi.mock("../lib/telegram/storage-adapter.ts", () => {
};
});
+vi.mock('../ui/notify-payment.ts', () => {
+ return {
+ notifyPaymentFailed: () => {},
+ notifyPaymentSuccess: () => {},
+ };
+})
+
vi.mock("../api/api.ts", () => {
return {
reviewCardsRequest: () => {},
diff --git a/src/store/deck-list-store.ts b/src/store/deck-list-store.ts
index e4ebbbb6..f27785e7 100644
--- a/src/store/deck-list-store.ts
+++ b/src/store/deck-list-store.ts
@@ -23,15 +23,21 @@ import { assert } from "../lib/typescript/assert.ts";
import { ReviewStore } from "../screens/deck-review/store/review-store.ts";
import { reportHandledError } from "../lib/rollbar/rollbar.tsx";
import { BooleanToggle } from "mobx-form-lite";
-import { type UserFoldersDbType } from "../../functions/db/folder/get-many-folders-with-decks-db.tsx";
+import { type UserFoldersDbType } from "../../functions/db/folder/get-many-folders-with-decks-db.ts";
import { userStore } from "./user-store.ts";
import { showConfirm } from "../lib/telegram/show-confirm.ts";
import { t } from "../translations/t.ts";
import { canDuplicateDeckOrFolder } from "../../shared/access/can-duplicate-deck-or-folder.ts";
import { hapticImpact } from "../lib/telegram/haptics.ts";
+import {
+ notifyPaymentFailed,
+ notifyPaymentSuccess,
+} from "../ui/notify-payment.ts";
export enum StartParamType {
RepeatAll = "repeat_all",
+ WalletPaymentSuccessful = "wp_success",
+ WalletPaymentFailed = "wp_fail",
}
export type DeckCardDbTypeWithType = DeckCardDbType & {
@@ -164,6 +170,10 @@ export class DeckListStore {
this.isReviewAllLoaded = true;
}),
);
+ } else if (startParam === StartParamType.WalletPaymentSuccessful) {
+ notifyPaymentSuccess();
+ } else if (startParam === StartParamType.WalletPaymentFailed) {
+ notifyPaymentFailed();
} else {
if (this.isSharedDeckLoaded) {
return;
diff --git a/src/ui/choice.tsx b/src/ui/choice.tsx
index e0382409..1b3a6a42 100644
--- a/src/ui/choice.tsx
+++ b/src/ui/choice.tsx
@@ -7,40 +7,32 @@ type Props = {
icon: string;
title: string;
description?: string;
- outline?: boolean;
- onClick?: () => void;
+ onClick: () => void;
};
export const Choice = (props: Props) => {
- const { icon, title, description, onClick, outline } = props;
+ const { icon, title, description, onClick } = props;
return (
diff --git a/src/ui/external-link.tsx b/src/ui/external-link.tsx
new file mode 100644
index 00000000..7938eedc
--- /dev/null
+++ b/src/ui/external-link.tsx
@@ -0,0 +1,21 @@
+import { ReactNode } from "react";
+import { css } from "@emotion/css";
+import WebApp from "@twa-dev/sdk";
+import { theme } from "./theme.tsx";
+
+export const ExternalLink = (props: { href: string; children: ReactNode }) => {
+ const { href, children } = props;
+ return (
+ {
+ WebApp.openLink(href);
+ }}
+ className={css({
+ color: theme.buttonColor,
+ cursor: "pointer",
+ })}
+ >
+ {children}
+
+ );
+};
diff --git a/src/ui/flex.tsx b/src/ui/flex.tsx
index ad0c5592..5f80ba64 100644
--- a/src/ui/flex.tsx
+++ b/src/ui/flex.tsx
@@ -17,6 +17,7 @@ type Props = {
pl?: CSSProperties["paddingLeft"];
pr?: CSSProperties["paddingRight"];
className?: string;
+ fullWidth?: boolean;
};
export const Flex = (props: Props) => {
@@ -27,6 +28,7 @@ export const Flex = (props: Props) => {
className={cx(
css({
display: "flex",
+ width: props.fullWidth ? "100%" : undefined,
flexDirection: props.direction,
justifyContent: props.justifyContent,
alignItems: props.alignItems,
diff --git a/src/ui/horizontal-divider.tsx b/src/ui/horizontal-divider.tsx
index ccf9d622..e8ae9de0 100644
--- a/src/ui/horizontal-divider.tsx
+++ b/src/ui/horizontal-divider.tsx
@@ -2,14 +2,19 @@ import { css } from "@emotion/css";
import { theme } from "./theme.tsx";
import React from "react";
-export const HorizontalDivider = () => {
+type Props = {
+ color?: string;
+};
+
+export const HorizontalDivider = (props: Props) => {
+ const color = props.color || theme.dividerColor;
return (
);
diff --git a/src/ui/notify-payment.ts b/src/ui/notify-payment.ts
new file mode 100644
index 00000000..729502f5
--- /dev/null
+++ b/src/ui/notify-payment.ts
@@ -0,0 +1,17 @@
+import confetti from "canvas-confetti";
+import { showAlert } from "../lib/telegram/show-alert.ts";
+
+export const notifyPaymentSuccess = () => {
+ confetti({
+ particleCount: 100,
+ spread: 70,
+ origin: { y: 0.6 },
+ });
+ showAlert("Payment is successful. Enjoy additional features 😊");
+};
+
+export const notifyPaymentFailed = () => {
+ showAlert(
+ "Payment failed. We're aware of the issue and working on it. Please contact support via Settings > Support.",
+ );
+};