From 97fb561555cfb631766c22bbc2cf04c03da3ade6 Mon Sep 17 00:00:00 2001 From: Egor Gorbachev <7gorbachevm@gmail.com> Date: Thu, 16 Nov 2023 20:03:50 +0700 Subject: [PATCH] User settings (#10) * User settings --- functions/db/user/create-or-update-user-db.ts | 2 + functions/user-settings.ts | 48 +++++++++ index.html | 2 +- src/api/api.ts | 12 +++ src/lib/array/range.ts | 3 + src/lib/mobx-form/form-has-error.test.ts | 13 ++- src/lib/mobx-form/form-has-error.ts | 6 +- src/lib/mobx-form/mobx-form.ts | 42 ++++++++ src/lib/telegram/cloud-storage.ts | 25 +++++ src/lib/telegram/use-main-button.tsx | 30 ++++-- src/screens/app.tsx | 8 ++ src/screens/deck-list/main-screen.tsx | 51 ++++++--- .../deck-review/deck-finished-modal.tsx} | 4 +- src/screens/deck-review/deck-finished.tsx | 6 +- .../user-settings/generate-time-range.tsx | 18 ++++ src/screens/user-settings/settings-row.tsx | 26 +++++ .../user-review-notification-settings.tsx | 89 +++++++++++++++ .../user-settings/user-settings-main.tsx | 26 +++++ src/screens/user-settings/user-settings.tsx | 41 +++++++ src/store/deck-list-store.ts | 7 ++ src/store/screen-store.ts | 5 + src/store/skeleton-loader-data.ts | 33 ++++++ src/store/user-settings-store-context.tsx | 19 ++++ src/store/user-settings-store.tsx | 102 ++++++++++++++++++ src/ui/radio-switcher.tsx | 81 ++++++++++++++ src/ui/select.tsx | 39 +++++++ 26 files changed, 703 insertions(+), 35 deletions(-) create mode 100644 functions/user-settings.ts create mode 100644 src/lib/array/range.ts create mode 100644 src/lib/telegram/cloud-storage.ts rename src/{ui/modal.tsx => screens/deck-review/deck-finished-modal.tsx} (89%) create mode 100644 src/screens/user-settings/generate-time-range.tsx create mode 100644 src/screens/user-settings/settings-row.tsx create mode 100644 src/screens/user-settings/user-review-notification-settings.tsx create mode 100644 src/screens/user-settings/user-settings-main.tsx create mode 100644 src/screens/user-settings/user-settings.tsx create mode 100644 src/store/skeleton-loader-data.ts create mode 100644 src/store/user-settings-store-context.tsx create mode 100644 src/store/user-settings-store.tsx create mode 100644 src/ui/radio-switcher.tsx create mode 100644 src/ui/select.tsx diff --git a/functions/db/user/create-or-update-user-db.ts b/functions/db/user/create-or-update-user-db.ts index 8664f386..3c3f2263 100644 --- a/functions/db/user/create-or-update-user-db.ts +++ b/functions/db/user/create-or-update-user-db.ts @@ -10,6 +10,8 @@ export const userDbSchema = z.object({ last_name: z.string().optional().nullable(), language_code: z.string().optional().nullable(), username: z.string().optional().nullable(), + is_remind_enabled: z.boolean(), + last_reminded_date: z.string().nullable(), }); export type UserDbType = z.infer; diff --git a/functions/user-settings.ts b/functions/user-settings.ts new file mode 100644 index 00000000..0f605a49 --- /dev/null +++ b/functions/user-settings.ts @@ -0,0 +1,48 @@ +import { handleError } from "./lib/handle-error/handle-error.ts"; +import { getUser } from "./services/get-user.ts"; +import { createAuthFailedResponse } from "./lib/json-response/create-auth-failed-response.ts"; +import { createBadRequestResponse } from "./lib/json-response/create-bad-request-response.ts"; +import { envSchema } from "./env/env-schema.ts"; +import { z } from "zod"; +import { getDatabase } from "./db/get-database.ts"; +import { DatabaseException } from "./db/database-exception.ts"; +import { createJsonResponse } from "./lib/json-response/create-json-response.ts"; +import { Database } from "./db/databaseTypes.ts"; + +const requestSchema = z.object({ + isRemindNotifyEnabled: z.boolean(), + remindNotificationTime: z.string(), +}); + +export type UserSettingsRequest = z.infer; +export type UserSettingsResponse = null; + +type UpdateUserSettingsDatabaseType = Partial< + Database["public"]["Tables"]["user"]["Update"] +>; + +export const onRequestPut = handleError(async ({ request, env }) => { + const user = await getUser(request, env); + if (!user) return createAuthFailedResponse(); + + const input = requestSchema.safeParse(await request.json()); + if (!input.success) { + return createBadRequestResponse(); + } + + const envSafe = envSchema.parse(env); + + const updateBody: UpdateUserSettingsDatabaseType = { + is_remind_enabled: input.data.isRemindNotifyEnabled, + last_reminded_date: input.data.remindNotificationTime, + }; + + const db = getDatabase(envSafe); + const { error } = await db.from("user").update(updateBody).eq("id", user.id); + + if (error) { + throw new DatabaseException(error); + } + + return createJsonResponse(null, 200); +}); diff --git a/index.html b/index.html index d2cf6ca1..2ce03f53 100644 --- a/index.html +++ b/index.html @@ -14,7 +14,7 @@ payload: { client: { javascript: { - code_version: '1.0.0', + code_version: '1.0.1', } }, } diff --git a/src/api/api.ts b/src/api/api.ts index 8fe1db1c..c99f5184 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -15,6 +15,10 @@ import { } from "../../functions/upsert-deck.ts"; import { GetSharedDeckResponse } from "../../functions/get-shared-deck.ts"; import { AddCardRequest, AddCardResponse } from "../../functions/add-card.ts"; +import { + UserSettingsRequest, + UserSettingsResponse, +} from "../../functions/user-settings.ts"; export const healthRequest = () => { return request("/health"); @@ -36,6 +40,14 @@ export const addDeckToMineRequest = (body: AddDeckToMineRequest) => { ); }; +export const userSettingsRequest = (body: UserSettingsRequest) => { + return request( + "/user-settings", + "PUT", + body, + ); +}; + export const reviewCardsRequest = (body: ReviewCardsRequest) => { return request( "/review-cards", diff --git a/src/lib/array/range.ts b/src/lib/array/range.ts new file mode 100644 index 00000000..d3b4af62 --- /dev/null +++ b/src/lib/array/range.ts @@ -0,0 +1,3 @@ +export const range = (n: number) => { + return Array.from({ length: n }, (x, i) => i); +}; diff --git a/src/lib/mobx-form/form-has-error.test.ts b/src/lib/mobx-form/form-has-error.test.ts index 3f16b86f..baf209be 100644 --- a/src/lib/mobx-form/form-has-error.test.ts +++ b/src/lib/mobx-form/form-has-error.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { TextField } from "./mobx-form.ts"; +import { BooleanField, TextField } from "./mobx-form.ts"; import { isFormEmpty, isFormTouched, isFormValid } from "./form-has-error.ts"; import { validators } from "./validator.ts"; @@ -51,6 +51,17 @@ test("is form dirty", () => { expect(isFormTouched(f)).toBeTruthy(); }); +test("is boolean form dirty", () => { + const f = { + a: new BooleanField(false), + }; + + expect(isFormTouched(f)).toBeFalsy(); + + f.a.toggle(); + expect(isFormTouched(f)).toBeTruthy(); +}); + test("is form empty", () => { const f = { a: new TextField("a", validators.required()), diff --git a/src/lib/mobx-form/form-has-error.ts b/src/lib/mobx-form/form-has-error.ts index e2cf86a8..ade2159f 100644 --- a/src/lib/mobx-form/form-has-error.ts +++ b/src/lib/mobx-form/form-has-error.ts @@ -1,14 +1,14 @@ -import { TextField } from "./mobx-form.ts"; +import { BooleanField, TextField } from "./mobx-form.ts"; type Form = Record; const walkAndCheck = ( - check: (field: TextField) => boolean, + check: (field: TextField | BooleanField) => boolean, iterateArray: "some" | "every", ) => { return (form: Form) => { return Object.values(form)[iterateArray]((value) => { - if (value instanceof TextField) { + if (value instanceof TextField || value instanceof BooleanField) { return check(value); } if (Array.isArray(value)) { diff --git a/src/lib/mobx-form/mobx-form.ts b/src/lib/mobx-form/mobx-form.ts index b79a7d24..a3de7067 100644 --- a/src/lib/mobx-form/mobx-form.ts +++ b/src/lib/mobx-form/mobx-form.ts @@ -37,3 +37,45 @@ export class TextField { }; } } + +export class BooleanField { + isTouched = false; + + constructor( + public value: boolean, + public validate?: (value: any) => string | undefined, + ) { + makeAutoObservable(this, { validate: false }, { autoBind: true }); + } + + setValue(value: boolean) { + this.value = value; + this.isTouched = true; + } + + toggle() { + this.setValue(!this.value); + } + + get error() { + return this.validate?.(this.value); + } + + touch() { + this.isTouched = true; + } + + unTouch() { + this.isTouched = false; + } + + get props() { + return { + value: this.value, + toggle: this.toggle, + onBlur: this.touch, + error: this.error, + isTouched: this.isTouched, + }; + } +} diff --git a/src/lib/telegram/cloud-storage.ts b/src/lib/telegram/cloud-storage.ts new file mode 100644 index 00000000..0e2d2e96 --- /dev/null +++ b/src/lib/telegram/cloud-storage.ts @@ -0,0 +1,25 @@ +import WebApp from "@twa-dev/sdk"; + +export const getCloudValue = (key: string): Promise => { + return new Promise((resolve, reject) => { + WebApp.CloudStorage.getItem(key, (err, value) => { + if (err != null) { + return reject(err); + } else { + return resolve(value); + } + }); + }); +}; + +export const setCloudValue = (key: string, value: string) => { + return new Promise((resolve, reject) => { + WebApp.CloudStorage.setItem(key, value, (err, result) => { + if (err != null) { + return reject(err); + } else { + return resolve(result); + } + }); + }); +}; diff --git a/src/lib/telegram/use-main-button.tsx b/src/lib/telegram/use-main-button.tsx index 4bf98734..fd4d504a 100644 --- a/src/lib/telegram/use-main-button.tsx +++ b/src/lib/telegram/use-main-button.tsx @@ -1,27 +1,37 @@ import { useMount } from "../react/use-mount.ts"; import WebApp from "@twa-dev/sdk"; import { useHotkeys } from "react-hotkeys-hook"; +import { autorun } from "mobx"; export const useMainButton = ( text: string, onClick: () => void, condition?: () => boolean, ) => { + let hideMainButton: () => void; + useMount(() => { - if (condition !== undefined) { - if (!condition()) { - return; + const stopAutoRun = autorun(() => { + if (condition !== undefined) { + if (!condition()) { + return; + } } - } - WebApp.MainButton.show(); - WebApp.MainButton.setText(text); - WebApp.MainButton.onClick(onClick); + WebApp.MainButton.show(); + WebApp.MainButton.setText(text); + WebApp.MainButton.onClick(onClick); + + hideMainButton = () => { + WebApp.MainButton.hide(); + WebApp.MainButton.offClick(onClick); + WebApp.MainButton.hideProgress(); + }; + }); return () => { - WebApp.MainButton.hide(); - WebApp.MainButton.offClick(onClick); - WebApp.MainButton.hideProgress(); + stopAutoRun(); + hideMainButton?.(); }; }); diff --git a/src/screens/app.tsx b/src/screens/app.tsx index d4070155..b918e1e3 100644 --- a/src/screens/app.tsx +++ b/src/screens/app.tsx @@ -7,6 +7,9 @@ import { DeckFormScreen } from "./deck-form/deck-form-screen.tsx"; import { DeckFormStoreProvider } from "../store/deck-form-store-context.tsx"; import { QuickAddCardForm } from "./deck-form/quick-add-card-form.tsx"; import { VersionWarning } from "./shared/version-warning.tsx"; +import React from "react"; +import { UserSettingsStoreProvider } from "../store/user-settings-store-context.tsx"; +import { UserSettingsMain } from "./user-settings/user-settings-main.tsx"; export const App = observer(() => { return ( @@ -24,6 +27,11 @@ export const App = observer(() => { )} {screenStore.screen === Screen.CardQuickAddForm && } + {screenStore.screen === Screen.UserSettings && ( + + + + )} ); }); diff --git a/src/screens/deck-list/main-screen.tsx b/src/screens/deck-list/main-screen.tsx index b7663157..692f1bb6 100644 --- a/src/screens/deck-list/main-screen.tsx +++ b/src/screens/deck-list/main-screen.tsx @@ -13,6 +13,7 @@ import { DeckLoading } from "./deck-loading.tsx"; import WebApp from "@twa-dev/sdk"; import { assert } from "../../lib/typescript/assert.ts"; import { ListHeader } from "../../ui/list-header.tsx"; +import { range } from "../../lib/array/range.ts"; export const MainScreen = observer(() => { useMount(() => { @@ -37,7 +38,14 @@ export const MainScreen = observer(() => { } return ( -
+
{ })} > {deckListStore.myInfo?.state === "pending" && - [1, 2, 3].map((i) => )} + range(deckListStore.skeletonLoaderData.myDecksCount).map((i) => ( + + ))} {deckListStore.myInfo?.state === "fulfilled" ? deckListStore.myDecks.map((deck) => { return ; @@ -120,25 +130,36 @@ export const MainScreen = observer(() => { ) : null} {deckListStore.myInfo?.state === "pending" && - [1, 2, 3].map((i) => )} + range(deckListStore.skeletonLoaderData.publicCount).map((i) => ( + + ))}
-
- -
+ WebApp.openTelegramLink(channelLink); + }} + > + Telegram channel + +
+
+
); diff --git a/src/ui/modal.tsx b/src/screens/deck-review/deck-finished-modal.tsx similarity index 89% rename from src/ui/modal.tsx rename to src/screens/deck-review/deck-finished-modal.tsx index e5fd01e4..20de8a00 100644 --- a/src/ui/modal.tsx +++ b/src/screens/deck-review/deck-finished-modal.tsx @@ -1,14 +1,14 @@ import React, { ReactNode } from "react"; import { motion } from "framer-motion"; import { css } from "@emotion/css"; -import { theme } from "./theme.tsx"; +import { theme } from "../../ui/theme.tsx"; type Props = { children: ReactNode; marginTop?: string; }; -export const Modal = (props: Props) => { +export const DeckFinishedModal = (props: Props) => { const { children } = props; const marginTop = props.marginTop || "200px"; diff --git a/src/screens/deck-review/deck-finished.tsx b/src/screens/deck-review/deck-finished.tsx index d4ba51b5..4c45eada 100644 --- a/src/screens/deck-review/deck-finished.tsx +++ b/src/screens/deck-review/deck-finished.tsx @@ -1,6 +1,6 @@ import React from "react"; import { observer } from "mobx-react-lite"; -import { Modal } from "../../ui/modal.tsx"; +import { DeckFinishedModal } from "./deck-finished-modal.tsx"; import { css } from "@emotion/css"; import { useReviewStore } from "../../store/review-store-context.tsx"; import { random } from "../../lib/array/random.ts"; @@ -41,7 +41,7 @@ export const DeckFinished = observer(() => { useTelegramProgress(() => reviewStore.isReviewSending); return ( - +
{

{random(encouragingMessages)} 😊

-
+ ); }); diff --git a/src/screens/user-settings/generate-time-range.tsx b/src/screens/user-settings/generate-time-range.tsx new file mode 100644 index 00000000..827b8f72 --- /dev/null +++ b/src/screens/user-settings/generate-time-range.tsx @@ -0,0 +1,18 @@ +export const formatTime = (hours: number, minutes: number) => { + return `${hours.toString().padStart(2, "0")}:${minutes + .toString() + .padStart(2, "0")}`; +}; + +export const generateTimeRange = () => { + const options: string[] = []; + const MINUTE_STEP = 30; + + for (let i = 0; i < 24; i++) { + for (let j = 0; j < 60; j += MINUTE_STEP) { + options.push(formatTime(i, j)); + } + } + + return options; +}; diff --git a/src/screens/user-settings/settings-row.tsx b/src/screens/user-settings/settings-row.tsx new file mode 100644 index 00000000..a7d8eb08 --- /dev/null +++ b/src/screens/user-settings/settings-row.tsx @@ -0,0 +1,26 @@ +import { observer } from "mobx-react-lite"; +import React, { ReactNode } from "react"; +import { css } from "@emotion/css"; +import { theme } from "../../ui/theme.tsx"; + +type Props = { children: ReactNode; onClick?: () => void }; + +export const SettingsRow = observer((props: Props) => { + return ( + + ); +}); diff --git a/src/screens/user-settings/user-review-notification-settings.tsx b/src/screens/user-settings/user-review-notification-settings.tsx new file mode 100644 index 00000000..6dc51ea1 --- /dev/null +++ b/src/screens/user-settings/user-review-notification-settings.tsx @@ -0,0 +1,89 @@ +import { observer } from "mobx-react-lite"; +import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; +import { ListHeader } from "../../ui/list-header.tsx"; +import { Hint } from "../../ui/hint.tsx"; +import React from "react"; +import { useUserSettingsStore } from "../../store/user-settings-store-context.tsx"; +import { css } from "@emotion/css"; +import { theme } from "../../ui/theme.tsx"; +import { RadioSwitcher } from "../../ui/radio-switcher.tsx"; +import { Select } from "../../ui/select.tsx"; +import { SettingsRow } from "./settings-row.tsx"; +import { generateTimeRange } from "./generate-time-range.tsx"; +import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; +import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; + +export const timeRanges = generateTimeRange(); + +export const UserReviewNotificationSettings = observer(() => { + const userSettingsStore = useUserSettingsStore(); + + useBackButton(() => { + userSettingsStore.goToMain(); + }); + + useMainButton( + "Save", + () => userSettingsStore.submit(), + () => userSettingsStore.isSaveVisible, + ); + + useTelegramProgress(() => userSettingsStore.isSending); + + if (!userSettingsStore.form) { + return null; + } + + const { isRemindNotifyEnabled, time } = userSettingsStore.form; + + return ( +
+ + +
+ + Notifications + + + + + {isRemindNotifyEnabled.value && ( + + Time +
+ +
+ + ); +}; diff --git a/src/ui/select.tsx b/src/ui/select.tsx new file mode 100644 index 00000000..69504cd5 --- /dev/null +++ b/src/ui/select.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { theme } from "./theme.tsx"; +import { css } from "@emotion/css"; + +type Option = { + label: string; + value: string; +}; + +type Props = { + value: string; + onChange: (newValue: string) => void; + options: Option[]; +}; + +export const Select = ({ value, onChange, options }: Props) => { + return ( + + ); +};