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.", + ); +};