From 5e9acffa09457dccc4232672ac29cf71623fc985 Mon Sep 17 00:00:00 2001 From: Hewston Fox Date: Sun, 22 Mar 2026 04:08:56 +0200 Subject: [PATCH] feat: add settings menu --- .oxfmtrc.json | 2 +- .oxlintrc.json | 2 +- index.html | 4 +- package.json | 5 +- plugins/i18nextSortPlugin.ts | 68 +++ plugins/i18nextTypesPlugin.ts | 97 +++ pnpm-lock.yaml | 93 ++- public/honey.svg | 551 ++++++++++++++++++ public/locales/de.json | 54 ++ public/locales/en.d.ts | 12 - public/locales/en.json | 62 +- public/locales/es.json | 54 ++ public/locales/fr.json | 54 ++ public/locales/id.json | 54 ++ public/locales/it.json | 54 ++ public/locales/nl.json | 54 ++ public/locales/pl.json | 54 ++ public/locales/pt.json | 54 ++ public/locales/ru.d.ts | 12 - public/locales/ru.json | 62 +- public/locales/tr.json | 54 ++ public/vite.svg | 1 - src/audio/AudioContext.tsx | 110 ++++ src/audio/assets/click.mp3 | Bin 0 -> 1906 bytes src/audio/index.ts | 3 + src/audio/sounds.ts | 15 + src/components/atoms/Button/Button.module.css | 2 + src/components/atoms/Button/Button.tsx | 4 + .../atoms/Pagination/Pagination.module.css | 74 +++ .../atoms/Pagination/Pagination.tsx | 107 ++++ .../atoms/Pagination/assets/back.svg | 3 + .../atoms/Pagination/assets/start.svg | 3 + .../atoms/Pagination/icons/BackIcon.tsx | 23 + .../atoms/Pagination/icons/StartIcon.tsx | 23 + src/components/atoms/Pagination/index.ts | 1 + .../atoms/TabSelector/TabSelector.tsx | 10 +- .../form/NumberInput/NumberInput.tsx | 10 +- .../form/SwitchInput/SwitchInput.tsx | 15 +- src/components/lift/LiftContext.tsx | 57 ++ src/components/lift/LiftLayer.module.css | 8 + src/components/lift/LiftLayer.tsx | 16 + src/components/lift/Liftable.tsx | 121 ++++ src/components/lift/index.ts | 3 + src/components/modals/Modal/Modal.module.css | 32 +- src/components/modals/Modal/Modal.tsx | 85 ++- .../BlueSectionSurface.module.css | 2 - src/helpers/dom.ts | 8 + src/i18n/assets/br.svg | 42 ++ src/i18n/assets/de.svg | 13 + src/i18n/assets/es.svg | 288 +++++++++ src/i18n/assets/fr.svg | 13 + src/i18n/assets/gb.svg | 27 + src/i18n/assets/id.svg | 12 + src/i18n/assets/it.svg | 13 + src/i18n/assets/nl.svg | 13 + src/i18n/assets/pl.svg | 12 + src/i18n/assets/ru.svg | 13 + src/i18n/assets/tr.svg | 13 + src/i18n/index.d.ts | 4 +- src/i18n/index.ts | 7 +- src/i18n/languages.ts | 31 + src/i18n/resources.d.ts | 23 + src/i18n/useLanguages.ts | 20 + src/main.tsx | 8 +- src/routes/-/RootLayout/RootLayout.tsx | 2 + .../components/Header/Header.module.css | 2 +- .../components/Profile/Profile.module.css | 39 ++ .../Header/components/Profile/Profile.tsx | 35 +- .../Header/components/Settings/Settings.tsx | 133 +++-- .../components/AccountModal/AccountModal.tsx | 46 ++ .../Settings/components/AccountModal/index.ts | 1 + .../Settings/components/FAQModal/FAQModal.tsx | 43 ++ .../Settings/components/FAQModal/index.ts | 1 + .../LanguageModal/LanguageModal.module.css | 48 ++ .../LanguageModal/LanguageModal.tsx | 72 +++ .../components/LanguageModal/index.ts | 1 + .../SettingsModal/SettingsModal.module.css | 51 ++ .../SettingsModal/SettingsModal.tsx | 118 ++++ .../components/SettingsModal/index.ts | 2 + .../components/SupportModal/SupportModal.tsx | 33 ++ .../Settings/components/SupportModal/index.ts | 1 + .../TransactionsHistoryModal.module.css | 6 + .../TransactionsHistoryModal.tsx | 83 +++ .../TransactionsHistoryModal/index.ts | 1 + .../components/Settings/settingsMachine.ts | 65 +++ .../components/Navigation/Navigation.tsx | 5 +- src/routes/game/-/GameRoute.tsx | 28 +- src/tg/index.ts | 59 +- vite.config.ts | 9 +- 89 files changed, 3412 insertions(+), 216 deletions(-) create mode 100644 plugins/i18nextSortPlugin.ts create mode 100644 plugins/i18nextTypesPlugin.ts create mode 100644 public/honey.svg create mode 100644 public/locales/de.json delete mode 100644 public/locales/en.d.ts create mode 100644 public/locales/es.json create mode 100644 public/locales/fr.json create mode 100644 public/locales/id.json create mode 100644 public/locales/it.json create mode 100644 public/locales/nl.json create mode 100644 public/locales/pl.json create mode 100644 public/locales/pt.json delete mode 100644 public/locales/ru.d.ts create mode 100644 public/locales/tr.json delete mode 100644 public/vite.svg create mode 100644 src/audio/AudioContext.tsx create mode 100644 src/audio/assets/click.mp3 create mode 100644 src/audio/index.ts create mode 100644 src/audio/sounds.ts create mode 100644 src/components/atoms/Pagination/Pagination.module.css create mode 100644 src/components/atoms/Pagination/Pagination.tsx create mode 100644 src/components/atoms/Pagination/assets/back.svg create mode 100644 src/components/atoms/Pagination/assets/start.svg create mode 100644 src/components/atoms/Pagination/icons/BackIcon.tsx create mode 100644 src/components/atoms/Pagination/icons/StartIcon.tsx create mode 100644 src/components/atoms/Pagination/index.ts create mode 100644 src/components/lift/LiftContext.tsx create mode 100644 src/components/lift/LiftLayer.module.css create mode 100644 src/components/lift/LiftLayer.tsx create mode 100644 src/components/lift/Liftable.tsx create mode 100644 src/components/lift/index.ts create mode 100644 src/helpers/dom.ts create mode 100644 src/i18n/assets/br.svg create mode 100644 src/i18n/assets/de.svg create mode 100644 src/i18n/assets/es.svg create mode 100644 src/i18n/assets/fr.svg create mode 100644 src/i18n/assets/gb.svg create mode 100644 src/i18n/assets/id.svg create mode 100644 src/i18n/assets/it.svg create mode 100644 src/i18n/assets/nl.svg create mode 100644 src/i18n/assets/pl.svg create mode 100644 src/i18n/assets/ru.svg create mode 100644 src/i18n/assets/tr.svg create mode 100644 src/i18n/languages.ts create mode 100644 src/i18n/resources.d.ts create mode 100644 src/i18n/useLanguages.ts create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/AccountModal/AccountModal.tsx create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/AccountModal/index.ts create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/FAQModal/FAQModal.tsx create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/FAQModal/index.ts create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/LanguageModal.module.css create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/LanguageModal.tsx create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/index.ts create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/SettingsModal.module.css create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/SettingsModal.tsx create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/index.ts create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/SupportModal/SupportModal.tsx create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/SupportModal/index.ts create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/TransactionsHistoryModal/TransactionsHistoryModal.module.css create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/TransactionsHistoryModal/TransactionsHistoryModal.tsx create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/components/TransactionsHistoryModal/index.ts create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/settingsMachine.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json index ce081e8..4fde987 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,4 +1,4 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": ["**/routeTree.gen.ts"] + "ignorePatterns": ["**/*.gen.ts", "src/i18n/resources.d.ts", "public"] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 10c812a..b113495 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "ignorePatterns": ["**/routeTree.gen.ts"], + "ignorePatterns": ["**/*.gen.ts", "src/i18n/resources.d.ts"], "plugins": ["react", "react-perf", "import", "jsx-a11y", "promise"], "rules": { "eqeqeq": ["error", "smart"], diff --git a/index.html b/index.html index c1ed1b0..b929bcf 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - honey-fe + Honey
diff --git a/package.json b/package.json index 1fda5f3..14c701d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@tanstack/react-router": "^1.166.3", "@tanstack/react-router-devtools": "^1.166.3", "@tma.js/sdk-react": "^3.0.16", + "@xstate/react": "^6.1.0", "arktype": "^2.2.0", "axios": "^1.13.6", "clsx": "^2.1.1", @@ -32,10 +33,10 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^16.5.6", - "tailwindcss": "^4.2.1" + "tailwindcss": "^4.2.1", + "xstate": "^5.28.0" }, "devDependencies": { - "@i18next-selector/vite-plugin": "^0.0.18", "@tanstack/router-plugin": "^1.166.3", "@types/node": "^24.10.1", "@types/react": "^19.2.7", diff --git a/plugins/i18nextSortPlugin.ts b/plugins/i18nextSortPlugin.ts new file mode 100644 index 0000000..a9b5e12 --- /dev/null +++ b/plugins/i18nextSortPlugin.ts @@ -0,0 +1,68 @@ +import { readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { normalizePath } from "vite"; +import type { Plugin, ResolvedConfig } from "vite"; + +interface Options { + sourceDir: string; +} + +type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; +type JsonObject = { [key: string]: JsonValue }; + +function deepSortKeys(obj: JsonObject): JsonObject { + const sorted: JsonObject = {}; + for (const key of Object.keys(obj).sort()) { + const value = obj[key]; + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + sorted[key] = deepSortKeys(value as JsonObject); + } else { + sorted[key] = value; + } + } + return sorted; +} + +function sortFile(filePath: string): void { + const raw = readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as JsonObject; + const sorted = deepSortKeys(parsed); + const output = JSON.stringify(sorted, null, "\t") + "\n"; + + if (raw !== output) { + writeFileSync(filePath, output, "utf-8"); + } +} + +function sortAllFiles(sourceDir: string): void { + const files = readdirSync(sourceDir).filter((f) => f.endsWith(".json")); + for (const file of files) { + sortFile(join(sourceDir, file)); + } +} + +export function i18nextSortPlugin(options: Options): Plugin { + let resolvedSourceDir: string; + + return { + name: "i18next-sort", + + configResolved(config: ResolvedConfig) { + resolvedSourceDir = normalizePath(resolve(config.root, options.sourceDir)); + }, + + buildStart() { + sortAllFiles(resolvedSourceDir); + }, + + configureServer(server) { + server.watcher.add(resolvedSourceDir); + + server.watcher.on("change", (file: string) => { + if (file.startsWith(resolvedSourceDir) && file.endsWith(".json")) { + sortFile(file); + } + }); + }, + }; +} diff --git a/plugins/i18nextTypesPlugin.ts b/plugins/i18nextTypesPlugin.ts new file mode 100644 index 0000000..6d646bd --- /dev/null +++ b/plugins/i18nextTypesPlugin.ts @@ -0,0 +1,97 @@ +import { readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { normalizePath } from "vite"; +import type { Plugin, ResolvedConfig } from "vite"; + +interface Options { + sourceDir: string; + destination: string; +} + +type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; +type JsonObject = { [key: string]: JsonValue }; + +function flattenKeys(obj: JsonObject, prefix: string, result: Map>): void { + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + flattenKeys(value as JsonObject, fullKey, result); + } else if (typeof value === "string") { + if (!result.has(fullKey)) { + result.set(fullKey, new Set()); + } + result.get(fullKey)!.add(value); + } + } +} + +function generateTypes(sourceDir: string, destination: string): void { + const files = readdirSync(sourceDir).filter((f) => f.endsWith(".json")); + + const keyValues = new Map>(); + + for (const file of files) { + const content = JSON.parse(readFileSync(join(sourceDir, file), "utf-8")) as JsonObject; + + flattenKeys(content, "", keyValues); + } + + const sortedKeys = Array.from(keyValues.keys()).sort(); + + const lines: string[] = [ + "// Auto-generated by i18nextTypesPlugin — do not edit manually", + "declare const resources: {", + ]; + + for (const key of sortedKeys) { + const values = Array.from(keyValues.get(key)!); + const union = values.map((v) => JSON.stringify(v)).join(" | "); + lines.push(` ${JSON.stringify(key)}: ${union};`); + } + + lines.push("};"); + lines.push("export default resources;"); + lines.push(""); + + writeFileSync(destination, lines.join("\n"), "utf-8"); +} + +export function i18nextTypesPlugin(options: Options): Plugin { + let resolvedSourceDir: string; + let resolvedDestination: string; + + return { + name: "i18next-types", + + configResolved(config: ResolvedConfig) { + resolvedSourceDir = normalizePath(resolve(config.root, options.sourceDir)); + resolvedDestination = normalizePath(resolve(config.root, options.destination)); + }, + + buildStart() { + generateTypes(resolvedSourceDir, resolvedDestination); + }, + + configureServer(server) { + server.watcher.add(resolvedSourceDir); + + server.watcher.on("change", (file: string) => { + if (file.startsWith(resolvedSourceDir) && file.endsWith(".json")) { + generateTypes(resolvedSourceDir, resolvedDestination); + } + }); + + server.watcher.on("add", (file: string) => { + if (file.startsWith(resolvedSourceDir) && file.endsWith(".json")) { + generateTypes(resolvedSourceDir, resolvedDestination); + } + }); + + server.watcher.on("unlink", (file: string) => { + if (file.startsWith(resolvedSourceDir) && file.endsWith(".json")) { + generateTypes(resolvedSourceDir, resolvedDestination); + } + }); + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f3dc35..fbc749c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@tma.js/sdk-react': specifier: ^3.0.16 version: 3.0.16(@types/react@19.2.14)(react@19.2.4)(typescript@5.9.3) + '@xstate/react': + specifier: ^6.1.0 + version: 6.1.0(@types/react@19.2.14)(react@19.2.4)(xstate@5.28.0) arktype: specifier: ^2.2.0 version: 2.2.0 @@ -65,10 +68,10 @@ importers: tailwindcss: specifier: ^4.2.1 version: 4.2.1 + xstate: + specifier: ^5.28.0 + version: 5.28.0 devDependencies: - '@i18next-selector/vite-plugin': - specifier: ^0.0.18 - version: 0.0.18(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/router-plugin': specifier: ^1.166.3 version: 1.166.3(@tanstack/react-router@1.166.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(tsx@4.21.0)(yaml@2.8.2)) @@ -363,11 +366,6 @@ packages: cpu: [x64] os: [win32] - '@i18next-selector/vite-plugin@0.0.18': - resolution: {integrity: sha512-jCdVJdaYDqa3dE8LCscCa7OzCN7UvUP+FDgSuc3L3mkPRlmhiXwNpCSWNUpSgGfubGBcpIE9vexHaLcS2KU0Bg==} - peerDependencies: - vite: 6 - 7 - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1222,18 +1220,6 @@ packages: '@tma.js/types@1.0.2': resolution: {integrity: sha512-qs4mi+U1xZmMQBdMhWAo1X4YqUJ/ae0y28s+GNCpQq58bsJo0h8rvyVOB1RwPvXogIY9+yribbZe6z3AIJmsAQ==} - '@traversable/json@0.0.26': - resolution: {integrity: sha512-oXKX0eNxbbHGLjLV27nTuV0uyR6uSoWi0BF+FYJu4jXRcSsWqCHOqNjIb2x/0usKd70rnKLGyHxurlTBTpQVOw==} - peerDependencies: - '@traversable/registry': ^0.0.25 - fast-check: ^3 - peerDependenciesMeta: - fast-check: - optional: true - - '@traversable/registry@0.0.25': - resolution: {integrity: sha512-idu2DhzoHOeqO+FZSpnDTgrFQWZL+poyxO9KozHeW7KdVqecrtYwR10vCVB/dItVdMBVZbavbNWO6PgUYN1KLg==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1254,6 +1240,15 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 + '@xstate/react@6.1.0': + resolution: {integrity: sha512-ep9F0jGTI63B/jE8GHdMpUqtuz7yRebNaKv8EMUaiSi29NOglywc2X2YSOV/ygbIK+LtmgZ0q9anoEA2iBSEOw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + xstate: ^5.28.0 + peerDependenciesMeta: + xstate: + optional: true + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -1279,9 +1274,6 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - arkregex@0.0.5: resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==} @@ -1619,10 +1611,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2155,6 +2143,15 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + use-isomorphic-layout-effect@1.2.1: + resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -2240,6 +2237,9 @@ packages: utf-8-validate: optional: true + xstate@5.28.0: + resolution: {integrity: sha512-Iaqq6ZrUzqeUtA3hC5LQKZfR8ZLzEFTImMHJM3jWEdVvXWdKvvVLXZEiNQWm3SCA9ZbEou/n5rcsna1wb9t28A==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2454,15 +2454,6 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@i18next-selector/vite-plugin@0.0.18(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - '@traversable/json': 0.0.26(@traversable/registry@0.0.25) - '@traversable/registry': 0.0.25 - js-yaml: 4.1.1 - vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(tsx@4.21.0)(yaml@2.8.2) - transitivePeerDependencies: - - fast-check - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3129,12 +3120,6 @@ snapshots: '@tma.js/types@1.0.2': {} - '@traversable/json@0.0.26(@traversable/registry@0.0.25)': - dependencies: - '@traversable/registry': 0.0.25 - - '@traversable/registry@0.0.25': {} - '@types/estree@1.0.8': {} '@types/node@24.12.0': @@ -3157,6 +3142,16 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@xstate/react@6.1.0(@types/react@19.2.14)(react@19.2.4)(xstate@5.28.0)': + dependencies: + react: 19.2.4 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + xstate: 5.28.0 + transitivePeerDependencies: + - '@types/react' + acorn@8.16.0: {} ansi-escapes@7.3.0: @@ -3174,8 +3169,6 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 - argparse@2.0.1: {} - arkregex@0.0.5: dependencies: '@ark/util': 0.56.0 @@ -3502,10 +3495,6 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - jsesc@3.1.0: {} json5@2.2.3: {} @@ -4002,6 +3991,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + use-sync-external-store@1.6.0(react@19.2.4): dependencies: react: 19.2.4 @@ -4050,6 +4045,8 @@ snapshots: ws@8.19.0: {} + xstate@5.28.0: {} + yallist@3.1.1: {} yaml@2.8.2: {} diff --git a/public/honey.svg b/public/honey.svg new file mode 100644 index 0000000..abc6a6c --- /dev/null +++ b/public/honey.svg @@ -0,0 +1,551 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/locales/de.json b/public/locales/de.json new file mode 100644 index 0000000..959e03b --- /dev/null +++ b/public/locales/de.json @@ -0,0 +1,54 @@ +{ + "accountInfo": { + "paymentBalance": "Einzahlungsguthaben", + "registrationDate": "Registrierungsdatum", + "withdrawalBalance": "Auszahlungsguthaben", + "yourId": "Ihre ID" + }, + "actionModal": { + "close": "Schließen" + }, + "common": { + "off": "aus", + "on": "an" + }, + "nav": { + "apiary": "Bienenhaus", + "cashdesk": "Kasse", + "earnings": "Einnahmen", + "game": "Spiel", + "menu": "Menü", + "roulette": "Roulette", + "shop": "Shop", + "tasks": "Aufgaben" + }, + "operationType": { + "deposit": "Einzahlung", + "greeting": "Willkommensbonus", + "referral": "Empfehlungsbonus", + "withdrawal": "Auszahlung" + }, + "pagination": { + "of": "von" + }, + "support": { + "action": "Support kontaktieren", + "text": "Wenn Sie Fragen zum Spiel haben, wenden Sie sich bitte an unseren Support." + }, + "settings": { + "accountInfo": "Kontoinformationen", + "back": "Zurück", + "faq": "FAQ", + "language": "Sprache", + "sound": "Ton", + "support": "Support", + "transactionHistory": "Transaktionsverlauf" + }, + "transactionHistory": { + "date": "Datum", + "operationType": "Vorgangsart", + "sum": "Summe", + "title": "Transaktionsverlauf", + "yourTransactions": "Ihre Transaktionen" + } +} diff --git a/public/locales/en.d.ts b/public/locales/en.d.ts deleted file mode 100644 index f736aed..0000000 --- a/public/locales/en.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -export declare const resources: { - hello: "Hello World!"; - "actionModal.close": "Close"; - "nav.shop": "Shop"; - "nav.apiary": "Apiary"; - "nav.game": "Game"; - "nav.cashdesk": "Cashdesk"; - "nav.menu": "Menu"; - "nav.roulette": "Roulette"; - "nav.tasks": "Tasks"; - "nav.earnings": "Earnings"; -}; diff --git a/public/locales/en.json b/public/locales/en.json index e16142f..1cc61cd 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -1,12 +1,54 @@ { - "hello": "Hello World!", - "actionModal.close": "Close", - "nav.shop": "Shop", - "nav.apiary": "Apiary", - "nav.game": "Game", - "nav.cashdesk": "Cashdesk", - "nav.menu": "Menu", - "nav.roulette": "Roulette", - "nav.tasks": "Tasks", - "nav.earnings": "Earnings" + "accountInfo": { + "paymentBalance": "Payment balance", + "registrationDate": "Registration date", + "withdrawalBalance": "Withdrawal balance", + "yourId": "Your ID" + }, + "actionModal": { + "close": "Close" + }, + "common": { + "off": "off", + "on": "on" + }, + "nav": { + "apiary": "Apiary", + "cashdesk": "Cashdesk", + "earnings": "Earnings", + "game": "Game", + "menu": "Menu", + "roulette": "Roulette", + "shop": "Shop", + "tasks": "Tasks" + }, + "operationType": { + "deposit": "Deposit", + "greeting": "Greeting bonus", + "referral": "Referral bonus", + "withdrawal": "Withdrawal" + }, + "pagination": { + "of": "of" + }, + "support": { + "action": "Contact support", + "text": "If you have any questions related to the game, please contact our support team." + }, + "settings": { + "accountInfo": "Account information", + "back": "Back", + "faq": "FAQ", + "language": "Language", + "sound": "Sound", + "support": "Support", + "transactionHistory": "Transaction History" + }, + "transactionHistory": { + "date": "Date", + "operationType": "Operation type", + "sum": "Sum", + "title": "Transaction History", + "yourTransactions": "Your Transactions" + } } diff --git a/public/locales/es.json b/public/locales/es.json new file mode 100644 index 0000000..f091a87 --- /dev/null +++ b/public/locales/es.json @@ -0,0 +1,54 @@ +{ + "accountInfo": { + "paymentBalance": "Saldo de pagos", + "registrationDate": "Fecha de registro", + "withdrawalBalance": "Saldo de retiros", + "yourId": "Tu ID" + }, + "actionModal": { + "close": "Cerrar" + }, + "common": { + "off": "no", + "on": "sí" + }, + "nav": { + "apiary": "Apiario", + "cashdesk": "Caja", + "earnings": "Ganancias", + "game": "Juego", + "menu": "Menú", + "roulette": "Ruleta", + "shop": "Tienda", + "tasks": "Tareas" + }, + "operationType": { + "deposit": "Depósito", + "greeting": "Bono de bienvenida", + "referral": "Bono de referido", + "withdrawal": "Retiro" + }, + "pagination": { + "of": "de" + }, + "support": { + "action": "Contactar soporte", + "text": "Si tienes alguna pregunta relacionada con el juego, contacta con nuestro equipo de soporte." + }, + "settings": { + "accountInfo": "Información de la cuenta", + "back": "Volver", + "faq": "FAQ", + "language": "Idioma", + "sound": "Sonido", + "support": "Soporte", + "transactionHistory": "Historial de transacciones" + }, + "transactionHistory": { + "date": "Fecha", + "operationType": "Tipo de operación", + "sum": "Suma", + "title": "Historial de transacciones", + "yourTransactions": "Tus transacciones" + } +} diff --git a/public/locales/fr.json b/public/locales/fr.json new file mode 100644 index 0000000..4f758ca --- /dev/null +++ b/public/locales/fr.json @@ -0,0 +1,54 @@ +{ + "accountInfo": { + "paymentBalance": "Solde des paiements", + "registrationDate": "Date d'inscription", + "withdrawalBalance": "Solde des retraits", + "yourId": "Votre ID" + }, + "actionModal": { + "close": "Fermer" + }, + "common": { + "off": "non", + "on": "oui" + }, + "nav": { + "apiary": "Rucher", + "cashdesk": "Caisse", + "earnings": "Gains", + "game": "Jeu", + "menu": "Menu", + "roulette": "Roulette", + "shop": "Boutique", + "tasks": "Tâches" + }, + "operationType": { + "deposit": "Dépôt", + "greeting": "Bonus de bienvenue", + "referral": "Bonus de parrainage", + "withdrawal": "Retrait" + }, + "pagination": { + "of": "sur" + }, + "support": { + "action": "Contacter le support", + "text": "Si vous avez des questions liées au jeu, veuillez contacter notre équipe de support." + }, + "settings": { + "accountInfo": "Informations du compte", + "back": "Retour", + "faq": "FAQ", + "language": "Langue", + "sound": "Son", + "support": "Support", + "transactionHistory": "Historique des transactions" + }, + "transactionHistory": { + "date": "Date", + "operationType": "Type d'opération", + "sum": "Somme", + "title": "Historique des transactions", + "yourTransactions": "Vos transactions" + } +} diff --git a/public/locales/id.json b/public/locales/id.json new file mode 100644 index 0000000..456df1e --- /dev/null +++ b/public/locales/id.json @@ -0,0 +1,54 @@ +{ + "accountInfo": { + "paymentBalance": "Saldo pembayaran", + "registrationDate": "Tanggal pendaftaran", + "withdrawalBalance": "Saldo penarikan", + "yourId": "ID Anda" + }, + "actionModal": { + "close": "Tutup" + }, + "common": { + "off": "mati", + "on": "nyala" + }, + "nav": { + "apiary": "Peternakan Lebah", + "cashdesk": "Kasir", + "earnings": "Penghasilan", + "game": "Permainan", + "menu": "Menu", + "roulette": "Roulette", + "shop": "Toko", + "tasks": "Tugas" + }, + "operationType": { + "deposit": "Setoran", + "greeting": "Bonus sambutan", + "referral": "Bonus referral", + "withdrawal": "Penarikan" + }, + "pagination": { + "of": "dari" + }, + "support": { + "action": "Hubungi dukungan", + "text": "Jika Anda memiliki pertanyaan terkait permainan, silakan hubungi tim dukungan kami." + }, + "settings": { + "accountInfo": "Informasi akun", + "back": "Kembali", + "faq": "FAQ", + "language": "Bahasa", + "sound": "Suara", + "support": "Dukungan", + "transactionHistory": "Riwayat transaksi" + }, + "transactionHistory": { + "date": "Tanggal", + "operationType": "Jenis operasi", + "sum": "Jumlah", + "title": "Riwayat transaksi", + "yourTransactions": "Transaksi Anda" + } +} diff --git a/public/locales/it.json b/public/locales/it.json new file mode 100644 index 0000000..780d35c --- /dev/null +++ b/public/locales/it.json @@ -0,0 +1,54 @@ +{ + "accountInfo": { + "paymentBalance": "Saldo pagamenti", + "registrationDate": "Data di registrazione", + "withdrawalBalance": "Saldo prelievi", + "yourId": "Il tuo ID" + }, + "actionModal": { + "close": "Chiudi" + }, + "common": { + "off": "no", + "on": "sì" + }, + "nav": { + "apiary": "Apiario", + "cashdesk": "Cassa", + "earnings": "Guadagni", + "game": "Gioco", + "menu": "Menu", + "roulette": "Roulette", + "shop": "Negozio", + "tasks": "Compiti" + }, + "operationType": { + "deposit": "Deposito", + "greeting": "Bonus di benvenuto", + "referral": "Bonus referral", + "withdrawal": "Prelievo" + }, + "pagination": { + "of": "di" + }, + "support": { + "action": "Contatta il supporto", + "text": "Se hai domande relative al gioco, contatta il nostro team di supporto." + }, + "settings": { + "accountInfo": "Informazioni account", + "back": "Indietro", + "faq": "FAQ", + "language": "Lingua", + "sound": "Suono", + "support": "Supporto", + "transactionHistory": "Cronologia transazioni" + }, + "transactionHistory": { + "date": "Data", + "operationType": "Tipo di operazione", + "sum": "Somma", + "title": "Cronologia transazioni", + "yourTransactions": "Le tue transazioni" + } +} diff --git a/public/locales/nl.json b/public/locales/nl.json new file mode 100644 index 0000000..68b881c --- /dev/null +++ b/public/locales/nl.json @@ -0,0 +1,54 @@ +{ + "accountInfo": { + "paymentBalance": "Stortingssaldo", + "registrationDate": "Registratiedatum", + "withdrawalBalance": "Opnamesaldo", + "yourId": "Uw ID" + }, + "actionModal": { + "close": "Sluiten" + }, + "common": { + "off": "uit", + "on": "aan" + }, + "nav": { + "apiary": "Bijenstal", + "cashdesk": "Kassa", + "earnings": "Inkomsten", + "game": "Spel", + "menu": "Menu", + "roulette": "Roulette", + "shop": "Winkel", + "tasks": "Taken" + }, + "operationType": { + "deposit": "Storting", + "greeting": "Welkomstbonus", + "referral": "Verwijzingsbonus", + "withdrawal": "Opname" + }, + "pagination": { + "of": "van" + }, + "support": { + "action": "Contact opnemen", + "text": "Als u vragen heeft over het spel, neem dan contact op met ons ondersteuningsteam." + }, + "settings": { + "accountInfo": "Accountinformatie", + "back": "Terug", + "faq": "FAQ", + "language": "Taal", + "sound": "Geluid", + "support": "Ondersteuning", + "transactionHistory": "Transactiegeschiedenis" + }, + "transactionHistory": { + "date": "Datum", + "operationType": "Bewerkingstype", + "sum": "Som", + "title": "Transactiegeschiedenis", + "yourTransactions": "Uw transacties" + } +} diff --git a/public/locales/pl.json b/public/locales/pl.json new file mode 100644 index 0000000..525e7d2 --- /dev/null +++ b/public/locales/pl.json @@ -0,0 +1,54 @@ +{ + "accountInfo": { + "paymentBalance": "Saldo wpłat", + "registrationDate": "Data rejestracji", + "withdrawalBalance": "Saldo wypłat", + "yourId": "Twoje ID" + }, + "actionModal": { + "close": "Zamknij" + }, + "common": { + "off": "wył", + "on": "wł" + }, + "nav": { + "apiary": "Pasieka", + "cashdesk": "Kasa", + "earnings": "Zarobki", + "game": "Gra", + "menu": "Menu", + "roulette": "Ruletka", + "shop": "Sklep", + "tasks": "Zadania" + }, + "operationType": { + "deposit": "Wpłata", + "greeting": "Bonus powitalny", + "referral": "Bonus polecający", + "withdrawal": "Wypłata" + }, + "pagination": { + "of": "z" + }, + "support": { + "action": "Skontaktuj się z pomocą", + "text": "Jeśli masz pytania dotyczące gry, skontaktuj się z naszym zespołem wsparcia." + }, + "settings": { + "accountInfo": "Informacje o koncie", + "back": "Wstecz", + "faq": "FAQ", + "language": "Język", + "sound": "Dźwięk", + "support": "Wsparcie", + "transactionHistory": "Historia transakcji" + }, + "transactionHistory": { + "date": "Data", + "operationType": "Typ operacji", + "sum": "Suma", + "title": "Historia transakcji", + "yourTransactions": "Twoje transakcje" + } +} diff --git a/public/locales/pt.json b/public/locales/pt.json new file mode 100644 index 0000000..f104a12 --- /dev/null +++ b/public/locales/pt.json @@ -0,0 +1,54 @@ +{ + "accountInfo": { + "paymentBalance": "Saldo de pagamentos", + "registrationDate": "Data de registro", + "withdrawalBalance": "Saldo de saques", + "yourId": "Seu ID" + }, + "actionModal": { + "close": "Fechar" + }, + "common": { + "off": "não", + "on": "sim" + }, + "nav": { + "apiary": "Apiário", + "cashdesk": "Caixa", + "earnings": "Ganhos", + "game": "Jogo", + "menu": "Menu", + "roulette": "Roleta", + "shop": "Loja", + "tasks": "Tarefas" + }, + "operationType": { + "deposit": "Depósito", + "greeting": "Bônus de boas-vindas", + "referral": "Bônus de indicação", + "withdrawal": "Saque" + }, + "pagination": { + "of": "de" + }, + "support": { + "action": "Contactar suporte", + "text": "Se tiver dúvidas relacionadas ao jogo, entre em contato com nossa equipe de suporte." + }, + "settings": { + "accountInfo": "Informações da conta", + "back": "Voltar", + "faq": "FAQ", + "language": "Idioma", + "sound": "Som", + "support": "Suporte", + "transactionHistory": "Histórico de transações" + }, + "transactionHistory": { + "date": "Data", + "operationType": "Tipo de operação", + "sum": "Soma", + "title": "Histórico de transações", + "yourTransactions": "Suas transações" + } +} diff --git a/public/locales/ru.d.ts b/public/locales/ru.d.ts deleted file mode 100644 index f24b1fa..0000000 --- a/public/locales/ru.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -export declare const resources: { - hello: "Привет мир"; - "actionModal.close": "Закрыть"; - "nav.shop": "Магазин"; - "nav.apiary": "Пасека"; - "nav.game": "Игра"; - "nav.cashdesk": "Касса"; - "nav.menu": "Меню"; - "nav.roulette": "Рулетка"; - "nav.tasks": "Задания"; - "nav.earnings": "Заработок"; -}; diff --git a/public/locales/ru.json b/public/locales/ru.json index 11d0099..02eeeda 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -1,12 +1,54 @@ { - "hello": "Привет мир", - "actionModal.close": "Закрыть", - "nav.shop": "Магазин", - "nav.apiary": "Пасека", - "nav.game": "Игра", - "nav.cashdesk": "Касса", - "nav.menu": "Меню", - "nav.roulette": "Рулетка", - "nav.tasks": "Задания", - "nav.earnings": "Заработок" + "accountInfo": { + "paymentBalance": "Баланс пополнений", + "registrationDate": "Дата регистрации", + "withdrawalBalance": "Баланс выводов", + "yourId": "Ваш ID" + }, + "actionModal": { + "close": "Закрыть" + }, + "common": { + "off": "выкл", + "on": "вкл" + }, + "nav": { + "apiary": "Пасека", + "cashdesk": "Касса", + "earnings": "Заработок", + "game": "Игра", + "menu": "Меню", + "roulette": "Рулетка", + "shop": "Магазин", + "tasks": "Задания" + }, + "operationType": { + "deposit": "Пополнение", + "greeting": "Приветственный бонус", + "referral": "Реферальный бонус", + "withdrawal": "Вывод" + }, + "pagination": { + "of": "из" + }, + "support": { + "action": "Связаться с поддержкой", + "text": "Если у вас возникли вопросы, связанные с игрой — обратитесь в нашу службу поддержки." + }, + "settings": { + "accountInfo": "Информация об аккаунте", + "back": "Назад", + "faq": "ЧаВо", + "language": "Язык", + "sound": "Звук", + "support": "Поддержка", + "transactionHistory": "История транзакций" + }, + "transactionHistory": { + "date": "Дата", + "operationType": "Тип операции", + "sum": "Сумма", + "title": "История транзакций", + "yourTransactions": "Ваши транзакции" + } } diff --git a/public/locales/tr.json b/public/locales/tr.json new file mode 100644 index 0000000..65ac6d0 --- /dev/null +++ b/public/locales/tr.json @@ -0,0 +1,54 @@ +{ + "accountInfo": { + "paymentBalance": "Ödeme bakiyesi", + "registrationDate": "Kayıt tarihi", + "withdrawalBalance": "Çekim bakiyesi", + "yourId": "Kimliğiniz" + }, + "actionModal": { + "close": "Kapat" + }, + "common": { + "off": "kapalı", + "on": "açık" + }, + "nav": { + "apiary": "Arılık", + "cashdesk": "Kasa", + "earnings": "Kazançlar", + "game": "Oyun", + "menu": "Menü", + "roulette": "Rulet", + "shop": "Mağaza", + "tasks": "Görevler" + }, + "operationType": { + "deposit": "Yatırım", + "greeting": "Hoş geldin bonusu", + "referral": "Referans bonusu", + "withdrawal": "Çekim" + }, + "pagination": { + "of": "/" + }, + "support": { + "action": "Destek ile iletişime geç", + "text": "Oyunla ilgili sorularınız varsa lütfen destek ekibimizle iletişime geçin." + }, + "settings": { + "accountInfo": "Hesap bilgileri", + "back": "Geri", + "faq": "SSS", + "language": "Dil", + "sound": "Ses", + "support": "Destek", + "transactionHistory": "İşlem Geçmişi" + }, + "transactionHistory": { + "date": "Tarih", + "operationType": "İşlem türü", + "sum": "Toplam", + "title": "İşlem Geçmişi", + "yourTransactions": "İşlemleriniz" + } +} diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index ee9fada..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/audio/AudioContext.tsx b/src/audio/AudioContext.tsx new file mode 100644 index 0000000..4ff667c --- /dev/null +++ b/src/audio/AudioContext.tsx @@ -0,0 +1,110 @@ +import { type ReactNode, type RefObject, useMemo } from "react"; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import tg, { STORAGE_KEYS } from "@/tg"; +import type { SoundKey } from "./sounds"; +import { SOUNDS, preloadSounds } from "./sounds"; + +type AudioCtxValue = { + isEnabled: boolean; + setIsEnabled: (enabled: boolean) => void; + loopingRef: RefObject>; +}; + +const AudioCtx = createContext(null); + +export function useAudioSettings(): { + isEnabled: boolean; + setIsEnabled: (enabled: boolean) => void; +} { + const ctx = useContext(AudioCtx); + if (!ctx) throw new Error("useAudioSettings must be used within AudioProvider"); + return { isEnabled: ctx.isEnabled, setIsEnabled: ctx.setIsEnabled }; +} + +export function usePlaySound(): ( + key: SoundKey, + { mode, force }?: { mode?: "once" | "loop"; force: boolean }, +) => void { + const ctx = useContext(AudioCtx); + if (!ctx) throw new Error("usePlaySound must be used within AudioProvider"); + + const { isEnabled, loopingRef } = ctx; + + return useCallback( + (key: SoundKey, { mode, force }: { mode?: "once" | "loop"; force?: boolean } = {}) => { + if (!isEnabled && !force) return; + + if (mode === "loop" && loopingRef.current.has(key)) return; + + const audio = new Audio(SOUNDS[key]); + + if (mode === "loop") { + audio.loop = true; + loopingRef.current.set(key, audio); + } else { + audio.addEventListener( + "ended", + () => { + audio.src = ""; + }, + { once: true }, + ); + } + + audio.play().catch(() => { + if (mode === "loop") loopingRef.current.delete(key); + }); + }, + [isEnabled, loopingRef], + ); +} + +export function AudioProvider({ children }: { children: ReactNode }) { + const [isEnabled, setIsEnabled] = useState(true); + const hydratedRef = useRef(false); + const loopingRef = useRef>(null); + if (!loopingRef.current) { + loopingRef.current = new Map(); + preloadSounds(); + } + + useEffect(() => { + tg.storage.getItem(STORAGE_KEYS.soundEnabled).then((value) => { + if (value !== "") { + setIsEnabled(value !== "false"); + } + hydratedRef.current = true; + }); + }, []); + + useEffect(() => { + if (!hydratedRef.current) return; + tg.storage.setItem(STORAGE_KEYS.soundEnabled, String(isEnabled)); + }, [isEnabled]); + + useEffect(() => { + if (!isEnabled) { + for (const audio of loopingRef.current!.values()) { + audio.pause(); + audio.src = ""; + } + loopingRef.current!.clear(); + } + }, [isEnabled]); + + return ( + + ({ + isEnabled, + setIsEnabled, + loopingRef, + }) as AudioCtxValue, + [isEnabled], + )} + > + {children} + + ); +} diff --git a/src/audio/assets/click.mp3 b/src/audio/assets/click.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..2ef87a0f76b63451ce599f5602c6c47ff3a0aace GIT binary patch literal 1906 zcmeZtF=k-^0w$x7h=>RvLy&=iIVrQGD6u4!!Q9xyz`)pu0V>4{l*&y>EQv43FJ>^Z zfC+PhgcB>kIt*Zf5@XlFX!>)cB;L#JrSZhQbmB1(*r}khc8%GO%gJ2F4b~ zFd1QxOlDqwQG8iyQE_H|9)qbN+z~=R$&%Fg%;NZ>)YQD>{FGD%LzqT3pqxiJf)7%g zo0tVO2y8!!^>RQtm!i}}phbzksU?Y?d1?6!)k;9`B`R4}*VZzG1P8kU!-Ef~&L^=f z&CE#8M9EXAtlj z3f@D(X9)NX1;3%-KLmhm2f8sC=te_h5C;ZSm=z#QHnUF?>(apN|DOd?fDFbZ5E*6< z5X};iFpUEw!wQu72bK8)q61hAf`ouFE?i(e3qWGbe;6GY7&u&+1Q|dwK)(X@9DvF+ zFfe%kUvqHI|IZInL=^bA!~4%g2$^jDw_1MI0@cm}AJ0axP_Z zbf2HW4+A{|iPh{65*qjzb}%w~KkeZuZQo$Pz`j#PkLia53v+`3hsS^30G|nk$8{~RxLo)uDaIaYC|giFH7@PxqZe--Pbf4amZ_1Op%TF&Co zP)^)s+%!pg@q@_t{|@^SlKiKh6;`=mZf@o3)4A}I8(*MQfP%AKoLa~I|DSf&8W+`i z+r{V!xd!po9BG~+h5!kH*ZsFs4BGtk{O1?3wXaODK78zT zr=T2L;ms+`3QsGzIG7Y1EGpz~CT1osR%|%%(Sq|14_AT_lcT_Yhf9y{xw11_{%Z4O z`YA5nnAf(WNdCmk!p`3de8tc7bnDgs^AT>{lrYCkUAbXLR;Ho8I8Xh5z3FFp&VD)h zxpG&J|7@vc6FWb}?0BjE|G%#a4|j*!n@X1_3mioa^!IkDZeH#uoV=t_F#iAl1@|MY3N=sLKN@?_3b%KFG%bRy!Y}EDFAy-$f4Xa;1SM=7O zm0nGU1h^7enizUIOgHkaeca~rK3eMxZ5Pbm;f<&>w&l>IMNCSK0(Xk4(o)4y&*h!rn;7`ILT zH`NarQf!Tj?|(XQq5S_-zfR}S1_q95kMtI!^Yiy)T{@K%bm`aDQAsAM;@csG3)cd70ypr!AY)T&H>srvtQ zuBRoh`uxA1DpNByU3A^D|Ns9blcJ_Z>upt<W%Jm7y|ajt%7|1s8R$AC_tYfcK48}W6aMqE69d-@CM%Aa3k`4WuRPqT|L~-5 zzaP(`qR@nmAzc4|t@3g7bu|VRT&t)I{(nwz`NJu)-BE_$*@ExJq&5qNPnuM=gIwc4 zrP6<<3|n9VQB!C#$XnpR;IkNP7)a&?g9oDysI&r=WFQhQ^9sH6gUh@}k^z^PFqxDu S;BpaU46yP6k#L!xSY-erVh`&8 literal 0 HcmV?d00001 diff --git a/src/audio/index.ts b/src/audio/index.ts new file mode 100644 index 0000000..b1c7266 --- /dev/null +++ b/src/audio/index.ts @@ -0,0 +1,3 @@ +export { AudioProvider, useAudioSettings, usePlaySound } from "./AudioContext"; +export { SOUNDS } from "./sounds"; +export type { SoundKey } from "./sounds"; diff --git a/src/audio/sounds.ts b/src/audio/sounds.ts new file mode 100644 index 0000000..314b5d6 --- /dev/null +++ b/src/audio/sounds.ts @@ -0,0 +1,15 @@ +import { prefetch } from "@/helpers/dom"; + +import click from "./assets/click.mp3"; + +export const SOUNDS = { + click, +} as const; + +export type SoundKey = keyof typeof SOUNDS; + +export function preloadSounds() { + for (const src of Object.values(SOUNDS)) { + prefetch(src); + } +} diff --git a/src/components/atoms/Button/Button.module.css b/src/components/atoms/Button/Button.module.css index 07bab05..d539998 100644 --- a/src/components/atoms/Button/Button.module.css +++ b/src/components/atoms/Button/Button.module.css @@ -24,6 +24,8 @@ &:disabled { pointer-events: none; + opacity: 0.9; + filter: grayscale(0.6); } } diff --git a/src/components/atoms/Button/Button.tsx b/src/components/atoms/Button/Button.tsx index 5aa4cfc..a80f52f 100644 --- a/src/components/atoms/Button/Button.tsx +++ b/src/components/atoms/Button/Button.tsx @@ -1,6 +1,7 @@ import { motion, type HTMLMotionProps } from "motion/react"; import clsx, { type ClassValue } from "clsx"; +import { usePlaySound } from "@/audio"; import tg from "@/tg"; import classes from "./Button.module.css"; @@ -18,10 +19,13 @@ const VARIANTS_MAP = { } satisfies Record, string>; export default function Button({ className, variant = "blue", onClick, ...props }: Props) { + const play = usePlaySound(); + return ( { + play("click"); tg.hapticFeedback.click(); onClick?.(e); }} diff --git a/src/components/atoms/Pagination/Pagination.module.css b/src/components/atoms/Pagination/Pagination.module.css new file mode 100644 index 0000000..59cf613 --- /dev/null +++ b/src/components/atoms/Pagination/Pagination.module.css @@ -0,0 +1,74 @@ +@layer base { + .wrapper { + display: flex; + flex-direction: row; + width: 100%; + height: 40px; + flex-shrink: 0; + gap: 1px; + border-radius: 9999px; + overflow: hidden; + } + + .item { + display: flex; + justify-content: center; + align-items: center; + border-radius: 5px; + } + + .startItem { + border-radius: 9999px 5px 5px 9999px; + } + + .endItem { + border-radius: 5px 9999px 9999px 5px; + } + + .button { + cursor: pointer; + flex: 1; + flex-shrink: 0; + user-select: none; + background: none; + border: none; + padding: 8px; + display: flex; + align-items: center; + justify-content: center; + + &:disabled { + opacity: 0.5; + pointer-events: none; + } + } + + .state { + flex: 1; + padding: 8px; + font-weight: 700; + font-size: 14px; + line-height: 1; + } + + .mirrored { + transform: scaleX(-1); + } + + .blueWrapper { + padding: 3px; + border-radius: 9999px; + } + + .blueItem { + background: #bef9fb; + box-shadow: + 0px 1px 0px 0px #ffffffbf inset, + -1px 0px 0px 0px #00000059 inset, + 1px 0px 0px 0px #00000059 inset, + 0px -1px 0px 0px #00000059 inset; + color: #0c2836; + --icon-fill: #227873; + --icon-stroke: #0c2836; + } +} diff --git a/src/components/atoms/Pagination/Pagination.tsx b/src/components/atoms/Pagination/Pagination.tsx new file mode 100644 index 0000000..21dac84 --- /dev/null +++ b/src/components/atoms/Pagination/Pagination.tsx @@ -0,0 +1,107 @@ +import { motion } from "motion/react"; +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; + +import tg from "@/tg"; +import ContentSurface from "@components/surface/ContentSurface"; +import { BlueSurface } from "@components/surface/BlueSectionSurface"; +import LightSurface from "@components/surface/LightSurface"; + +import BackIcon from "./icons/BackIcon"; +import StartIcon from "./icons/StartIcon"; +import classes from "./Pagination.module.css"; +import { usePlaySound } from "@/audio"; + +type Props = { + value: number; + total: number; + onChange?: (page: number) => unknown; + variant?: "default" | "blue"; +}; + +export default function Pagination({ value, total, onChange, variant = "default" }: Props) { + const { t } = useTranslation(); + const play = usePlaySound(); + + const isAtStart = value <= 1 || total <= 1; + const isAtEnd = value >= total || total <= 1; + const isBlue = variant === "blue"; + + const Wrapper = isBlue ? BlueSurface : ContentSurface; + const ItemSurface = isBlue ? motion.div : LightSurface; + + const itemClass = (...extra: Parameters) => + clsx(classes.item, isBlue ? classes.blueItem : undefined, ...extra); + + return ( + + + { + play("click"); + tg.hapticFeedback.click(); + onChange?.(1); + }} + disabled={isAtStart} + > + + + + + + { + play("click"); + tg.hapticFeedback.click(); + onChange?.(value - 1); + }} + disabled={isAtStart} + > + + + + + + {value} {t("pagination.of")} {total} + + + + { + play("click"); + tg.hapticFeedback.click(); + onChange?.(value + 1); + }} + disabled={isAtEnd} + > + + + + + + { + play("click"); + tg.hapticFeedback.click(); + onChange?.(total); + }} + disabled={isAtEnd} + > + + + + + ); +} diff --git a/src/components/atoms/Pagination/assets/back.svg b/src/components/atoms/Pagination/assets/back.svg new file mode 100644 index 0000000..1775ac6 --- /dev/null +++ b/src/components/atoms/Pagination/assets/back.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/atoms/Pagination/assets/start.svg b/src/components/atoms/Pagination/assets/start.svg new file mode 100644 index 0000000..a169e99 --- /dev/null +++ b/src/components/atoms/Pagination/assets/start.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/atoms/Pagination/icons/BackIcon.tsx b/src/components/atoms/Pagination/icons/BackIcon.tsx new file mode 100644 index 0000000..cdb2cd2 --- /dev/null +++ b/src/components/atoms/Pagination/icons/BackIcon.tsx @@ -0,0 +1,23 @@ +import { motion, type SVGMotionProps } from "motion/react"; +import clsx, { type ClassValue } from "clsx"; + +type Props = Omit, "className"> & { + className?: ClassValue; +}; + +export default function BackIcon(props: Props) { + return ( + + + + ); +} diff --git a/src/components/atoms/Pagination/icons/StartIcon.tsx b/src/components/atoms/Pagination/icons/StartIcon.tsx new file mode 100644 index 0000000..cdaf354 --- /dev/null +++ b/src/components/atoms/Pagination/icons/StartIcon.tsx @@ -0,0 +1,23 @@ +import { motion, type SVGMotionProps } from "motion/react"; +import clsx, { type ClassValue } from "clsx"; + +type Props = Omit, "className"> & { + className?: ClassValue; +}; + +export default function StartIcon(props: Props) { + return ( + + + + ); +} diff --git a/src/components/atoms/Pagination/index.ts b/src/components/atoms/Pagination/index.ts new file mode 100644 index 0000000..34fcdf4 --- /dev/null +++ b/src/components/atoms/Pagination/index.ts @@ -0,0 +1 @@ +export { default } from "./Pagination"; diff --git a/src/components/atoms/TabSelector/TabSelector.tsx b/src/components/atoms/TabSelector/TabSelector.tsx index 2feac01..d3cce4d 100644 --- a/src/components/atoms/TabSelector/TabSelector.tsx +++ b/src/components/atoms/TabSelector/TabSelector.tsx @@ -4,6 +4,8 @@ import DarkSurface from "@components/surface/DarkSurface"; import { motion, type HTMLMotionProps } from "motion/react"; import classes from "./TabSelector.module.css"; +import { usePlaySound } from "@/audio"; +import tg from "@/tg"; type Tab = { key: string; @@ -18,6 +20,8 @@ type Props = Omit, "className" | "onChange"> & { }; export default function TabSelector({ tabs, value, onChange, className, ...props }: Props) { + const play = usePlaySound(); + const selectedIndex = value != null ? tabs.findIndex((tab) => tab.key === value) : -1; return ( @@ -29,7 +33,11 @@ export default function TabSelector({ tabs, value, onChange, className, ...props type="button" key={tab.key} whileTap={{ scale: 0.95 }} - onClick={() => onChange?.(tab.key)} + onClick={() => { + play("click"); + tg.hapticFeedback.click(); + onChange?.(tab.key); + }} className={classes.tab} > {tab.title} diff --git a/src/components/form/NumberInput/NumberInput.tsx b/src/components/form/NumberInput/NumberInput.tsx index 7ae112f..a18f58a 100644 --- a/src/components/form/NumberInput/NumberInput.tsx +++ b/src/components/form/NumberInput/NumberInput.tsx @@ -4,6 +4,8 @@ import { type ReactNode, useRef, useState, type ChangeEvent, useId } from "react import KeyboardIcon from "@components/icons/KeyboardIcon"; import classes from "./NumberInput.module.css"; +import { usePlaySound } from "@/audio"; +import tg from "@/tg"; type Props = Omit, "className" | "type" | "onChange"> & { className?: ClassValue; @@ -29,6 +31,8 @@ export default function NumberInput({ onChange, ...props }: Props) { + const play = usePlaySound(); + const stableId = useId(); const inputRef = useRef(null); const id = props.id ?? stableId; @@ -66,7 +70,11 @@ export default function NumberInput({ inputRef.current?.focus()} + onClick={() => { + play("click"); + tg.hapticFeedback.click(); + inputRef.current?.focus(); + }} > diff --git a/src/components/form/SwitchInput/SwitchInput.tsx b/src/components/form/SwitchInput/SwitchInput.tsx index c69bd71..9b792da 100644 --- a/src/components/form/SwitchInput/SwitchInput.tsx +++ b/src/components/form/SwitchInput/SwitchInput.tsx @@ -1,9 +1,11 @@ import clsx, { type ClassValue } from "clsx"; +import { useTranslation } from "react-i18next"; import ContentSurface from "@components/surface/ContentSurface"; import LightSurface from "@components/surface/LightSurface"; import { motion, type HTMLMotionProps } from "motion/react"; import classes from "./SwitchInput.module.css"; +import tg from "@/tg"; type Props = Omit, "className" | "onChange"> & { value?: boolean | null; @@ -12,6 +14,7 @@ type Props = Omit, "className" | "onChange"> & { }; export default function SwitchInput({ value, onChange, className, ...props }: Props) { + const { t } = useTranslation(); const selectedIndex = value != null ? (value ? 0 : 1) : -1; return ( @@ -19,24 +22,24 @@ export default function SwitchInput({ value, onChange, className, ...props }: Pr {...props} className={clsx(classes.container, className)} whileTap={{ scale: 1.1 }} + onClick={() => { + tg.hapticFeedback.click(); + onChange?.(!value); + }} >
onChange?.(true)} className={clsx(classes.option, value === true && classes.selected)} > - on + {t("common.on")} onChange?.(false)} className={clsx(classes.option, value === false && classes.selected)} > - off + {t("common.off")}
{selectedIndex >= 0 && ( diff --git a/src/components/lift/LiftContext.tsx b/src/components/lift/LiftContext.tsx new file mode 100644 index 0000000..5af1a42 --- /dev/null +++ b/src/components/lift/LiftContext.tsx @@ -0,0 +1,57 @@ +import { createContext, useCallback, useContext, useRef, useState } from "react"; +import type { ReactNode } from "react"; + +type LiftContextValue = { + liftedIds: Set; + alwaysLiftedIds: Set; + setLiftedIds: (ids: string[]) => void; + registerAlways: (id: string) => void; + unregisterAlways: (id: string) => void; + portalContainer: HTMLElement | null; + setPortalContainer: (el: HTMLElement | null) => void; +}; + +const LiftContext = createContext(null); + +export function useLift(): LiftContextValue { + const ctx = useContext(LiftContext); + if (!ctx) throw new Error("useLift must be used within LiftProvider"); + return ctx; +} + +export function LiftProvider({ children }: { children: ReactNode }) { + const [liftedIds, setLiftedIdsRaw] = useState>(new Set()); + const [alwaysLiftedIds, setAlwaysLiftedIds] = useState>(new Set()); + const [portalContainer, setPortalContainer] = useState(null); + const alwaysRef = useRef>(new Set()); + + const setLiftedIds = useCallback((ids: string[]) => { + setLiftedIdsRaw(new Set(ids)); + }, []); + + const registerAlways = useCallback((id: string) => { + alwaysRef.current.add(id); + setAlwaysLiftedIds(new Set(alwaysRef.current)); + }, []); + + const unregisterAlways = useCallback((id: string) => { + alwaysRef.current.delete(id); + setAlwaysLiftedIds(new Set(alwaysRef.current)); + }, []); + + return ( + + {children} + + ); +} diff --git a/src/components/lift/LiftLayer.module.css b/src/components/lift/LiftLayer.module.css new file mode 100644 index 0000000..614196e --- /dev/null +++ b/src/components/lift/LiftLayer.module.css @@ -0,0 +1,8 @@ +@layer base { + .liftLayer { + position: fixed; + inset: 0; + z-index: 103; + pointer-events: none; + } +} diff --git a/src/components/lift/LiftLayer.tsx b/src/components/lift/LiftLayer.tsx new file mode 100644 index 0000000..fad673e --- /dev/null +++ b/src/components/lift/LiftLayer.tsx @@ -0,0 +1,16 @@ +import { useCallback } from "react"; +import { useLift } from "./LiftContext"; +import classes from "./LiftLayer.module.css"; + +export function LiftLayer() { + const { setPortalContainer } = useLift(); + + const refCallback = useCallback( + (node: HTMLDivElement | null) => { + setPortalContainer(node); + }, + [setPortalContainer], + ); + + return
; +} diff --git a/src/components/lift/Liftable.tsx b/src/components/lift/Liftable.tsx new file mode 100644 index 0000000..da60d80 --- /dev/null +++ b/src/components/lift/Liftable.tsx @@ -0,0 +1,121 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { useLift } from "./LiftContext"; + +type LiftableProps = { + id?: string; + always?: boolean; +}; + +type Props = LiftableProps & { + children: ReactNode; +}; + +export function useLiftable = Record>( + render: (props: { isLifted: boolean } & T) => ReactNode, + options?: LiftableProps & T, +): { id: string; element: ReactNode } { + const autoId = useId(); + const { + id: idProp, + always, + ...extraProps + } = (options ?? {}) as LiftableProps & Record; + const id = idProp ?? autoId; + const { liftedIds, alwaysLiftedIds, registerAlways, unregisterAlways } = useLift(); + + useEffect(() => { + if (always) { + registerAlways(id); + return () => unregisterAlways(id); + } + }, [always, id, registerAlways, unregisterAlways]); + + const isLifted = liftedIds.has(id) || alwaysLiftedIds.has(id); + + const element = ( + {render({ isLifted, ...extraProps } as { isLifted: boolean } & T)} + ); + + return { id, element }; +} + +export function Liftable({ id: idProp, always, children }: Props) { + const autoId = useId(); + const id = idProp ?? autoId; + const { liftedIds, alwaysLiftedIds, registerAlways, unregisterAlways, portalContainer } = + useLift(); + const wrapperRef = useRef(null); + const [rect, setRect] = useState(null); + + useEffect(() => { + if (always) { + registerAlways(id); + return () => unregisterAlways(id); + } + }, [always, id, registerAlways, unregisterAlways]); + + const isLifted = liftedIds.has(id) || alwaysLiftedIds.has(id); + + useLayoutEffect(() => { + if (isLifted && wrapperRef.current) { + setRect(wrapperRef.current.getBoundingClientRect()); + } + if (!isLifted) { + setRect(null); + } + }, [isLifted]); + + // Re-measure on resize while lifted + useEffect(() => { + if (!isLifted || !wrapperRef.current) return; + + const measure = () => { + if (wrapperRef.current) { + setRect(wrapperRef.current.getBoundingClientRect()); + } + }; + + const observer = new ResizeObserver(measure); + observer.observe(wrapperRef.current); + window.addEventListener("resize", measure); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", measure); + }; + }, [isLifted]); + + // When lifted and we have measurements + portal target, render in portal + if (isLifted && rect && portalContainer) { + return ( + <> + {/* Placeholder preserves layout space */} +
+ {/* Portal children above blur */} + {createPortal( +
+ {children} +
, + portalContainer, + )} + + ); + } + + // Normal inline rendering + return
{children}
; +} diff --git a/src/components/lift/index.ts b/src/components/lift/index.ts new file mode 100644 index 0000000..f61a3e8 --- /dev/null +++ b/src/components/lift/index.ts @@ -0,0 +1,3 @@ +export { LiftProvider, useLift } from "./LiftContext"; +export { LiftLayer } from "./LiftLayer"; +export { Liftable, useLiftable } from "./Liftable"; diff --git a/src/components/modals/Modal/Modal.module.css b/src/components/modals/Modal/Modal.module.css index b79986c..ebd24a8 100644 --- a/src/components/modals/Modal/Modal.module.css +++ b/src/components/modals/Modal/Modal.module.css @@ -1,11 +1,27 @@ @layer base { .overlay { + position: fixed; + inset: 0; + z-index: 100; + } + + .modalContainer { + width: 100%; position: fixed; inset: 0; display: flex; + flex-direction: column; align-items: center; justify-content: center; - z-index: 100; + z-index: 102; + } + + .modalInner { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 320px; } .modal { @@ -13,7 +29,19 @@ flex-direction: column; gap: 6px; width: 100%; - max-width: 320px; + max-height: calc( + var(--safe-area-height) - var(--header-total) - var(--navigation-total) - 70px + ); + overflow: auto; padding: 13px; } + + .title { + color: #ffffff; + -webkit-text-stroke: 0.7px #331b01; + font-weight: 700; + font-size: 24px; + margin-bottom: 12px; + text-align: center; + } } diff --git a/src/components/modals/Modal/Modal.tsx b/src/components/modals/Modal/Modal.tsx index b93c62a..f09ad01 100644 --- a/src/components/modals/Modal/Modal.tsx +++ b/src/components/modals/Modal/Modal.tsx @@ -1,36 +1,85 @@ +import { useEffect, useRef } from "react"; import type { ReactNode } from "react"; import { AnimatePresence, motion } from "motion/react"; -import SectionSurface from "../../surface/SectionSurface/SectionSurface"; +import SectionSurface from "@components/surface/SectionSurface"; +import { useLift } from "@components/lift"; import classes from "./Modal.module.css"; +import { clsx, type ClassValue } from "clsx"; type Props = { open: boolean; children: ReactNode; onClose: () => void; + liftIds?: string[]; + title?: ReactNode; + className?: ClassValue; }; -export default function Modal({ open, children, onClose }: Props) { +export default function Modal({ open, children, onClose, liftIds, title, className }: Props) { + const { setLiftedIds } = useLift(); + const prevLiftIdsRef = useRef(""); + + useEffect(() => { + const key = liftIds?.join(",") ?? ""; + + if (open && key) { + if (key !== prevLiftIdsRef.current) { + prevLiftIdsRef.current = key; + setLiftedIds(liftIds!); + } + } + + if (!open && prevLiftIdsRef.current) { + prevLiftIdsRef.current = ""; + setLiftedIds([]); + } + }, [open, liftIds, setLiftedIds]); + + useEffect(() => { + return () => { + if (prevLiftIdsRef.current) { + setLiftedIds([]); + } + }; + }, [setLiftedIds]); + return ( {open && ( - - e.stopPropagation()} + <> + + - {children} - - + {/* oxlint-disable-next-line jsx_a11y/no-static-element-interactions*/} +
e.stopPropagation()} + > + {title &&
{title}
} + + {children} + +
+ + )}
); diff --git a/src/components/surface/BlueSectionSurface/BlueSectionSurface.module.css b/src/components/surface/BlueSectionSurface/BlueSectionSurface.module.css index d6a2a69..77e7673 100644 --- a/src/components/surface/BlueSectionSurface/BlueSectionSurface.module.css +++ b/src/components/surface/BlueSectionSurface/BlueSectionSurface.module.css @@ -1,5 +1,3 @@ -@reference "@/index.css"; - @layer base { .blueSectionSurface { background: linear-gradient(180deg, #278789 0%, #206f66 100%); diff --git a/src/helpers/dom.ts b/src/helpers/dom.ts new file mode 100644 index 0000000..46c58de --- /dev/null +++ b/src/helpers/dom.ts @@ -0,0 +1,8 @@ +export const prefetch = (url: string | null | undefined) => { + if (!url) return; + const link = document.createElement("link"); + link.rel = "prefetch"; + link.href = url; + link.onload = () => link.remove(); + document.head.appendChild(link); +}; diff --git a/src/i18n/assets/br.svg b/src/i18n/assets/br.svg new file mode 100644 index 0000000..4452ddd --- /dev/null +++ b/src/i18n/assets/br.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/i18n/assets/de.svg b/src/i18n/assets/de.svg new file mode 100644 index 0000000..6847d29 --- /dev/null +++ b/src/i18n/assets/de.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/i18n/assets/es.svg b/src/i18n/assets/es.svg new file mode 100644 index 0000000..144d555 --- /dev/null +++ b/src/i18n/assets/es.svg @@ -0,0 +1,288 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/i18n/assets/fr.svg b/src/i18n/assets/fr.svg new file mode 100644 index 0000000..caf63da --- /dev/null +++ b/src/i18n/assets/fr.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/i18n/assets/gb.svg b/src/i18n/assets/gb.svg new file mode 100644 index 0000000..806e5c8 --- /dev/null +++ b/src/i18n/assets/gb.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/i18n/assets/id.svg b/src/i18n/assets/id.svg new file mode 100644 index 0000000..9e00a7d --- /dev/null +++ b/src/i18n/assets/id.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/i18n/assets/it.svg b/src/i18n/assets/it.svg new file mode 100644 index 0000000..1ab91a0 --- /dev/null +++ b/src/i18n/assets/it.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/i18n/assets/nl.svg b/src/i18n/assets/nl.svg new file mode 100644 index 0000000..00c404b --- /dev/null +++ b/src/i18n/assets/nl.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/i18n/assets/pl.svg b/src/i18n/assets/pl.svg new file mode 100644 index 0000000..b20cdf2 --- /dev/null +++ b/src/i18n/assets/pl.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/i18n/assets/ru.svg b/src/i18n/assets/ru.svg new file mode 100644 index 0000000..1ec2464 --- /dev/null +++ b/src/i18n/assets/ru.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/i18n/assets/tr.svg b/src/i18n/assets/tr.svg new file mode 100644 index 0000000..9e09c93 --- /dev/null +++ b/src/i18n/assets/tr.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/i18n/index.d.ts b/src/i18n/index.d.ts index 0d43767..570f48d 100644 --- a/src/i18n/index.d.ts +++ b/src/i18n/index.d.ts @@ -1,9 +1,11 @@ import "i18next"; -import type { resources } from "../../public/locales/en.d.ts"; +import resources from "./resources"; declare module "i18next" { interface CustomTypeOptions { resources: { translation: typeof resources }; + strictKeyChecks: true; + enableSelector: true; } } diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 8360e30..c5a78e1 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -6,10 +6,9 @@ declare const __LANGS__: string[]; declare const __LOCALES_PATH__: string; declare const __DEFAULT_LANG__: string; -export const languages = __LANGS__.map((key) => ({ - key, - label: key.toUpperCase(), -})); +export type { Language } from "./languages"; +export { languages } from "./languages"; +export { useLanguages } from "./useLanguages"; export default { init() { diff --git a/src/i18n/languages.ts b/src/i18n/languages.ts new file mode 100644 index 0000000..44f22b2 --- /dev/null +++ b/src/i18n/languages.ts @@ -0,0 +1,31 @@ +import gbSvg from "./assets/gb.svg"; +import ruSvg from "./assets/ru.svg"; +import brSvg from "./assets/br.svg"; +import esSvg from "./assets/es.svg"; +import trSvg from "./assets/tr.svg"; +import deSvg from "./assets/de.svg"; +import plSvg from "./assets/pl.svg"; +import itSvg from "./assets/it.svg"; +import nlSvg from "./assets/nl.svg"; +import frSvg from "./assets/fr.svg"; +import idSvg from "./assets/id.svg"; + +export type Language = { + key: string; + label: string; + image: string; +}; + +export const languages: Language[] = [ + { key: "en", label: "English", image: gbSvg }, + { key: "ru", label: "Русский", image: ruSvg }, + { key: "pt", label: "Português", image: brSvg }, + { key: "es", label: "Español", image: esSvg }, + { key: "tr", label: "Türkçe", image: trSvg }, + { key: "de", label: "Deutsch", image: deSvg }, + { key: "pl", label: "Polski", image: plSvg }, + { key: "it", label: "Italiano", image: itSvg }, + { key: "nl", label: "Nederlands", image: nlSvg }, + { key: "fr", label: "Français", image: frSvg }, + { key: "id", label: "Indonesia", image: idSvg }, +]; diff --git a/src/i18n/resources.d.ts b/src/i18n/resources.d.ts new file mode 100644 index 0000000..7981a4b --- /dev/null +++ b/src/i18n/resources.d.ts @@ -0,0 +1,23 @@ +// Auto-generated by i18nextTypesPlugin — do not edit manually +declare const resources: { + "actionModal.close": "Schließen" | "Close" | "Cerrar" | "Fermer" | "Tutup" | "Chiudi" | "Sluiten" | "Zamknij" | "Fechar" | "Закрыть" | "Kapat"; + "common.off": "aus" | "off" | "no" | "non" | "mati" | "uit" | "wył" | "não" | "выкл" | "kapalı"; + "common.on": "an" | "on" | "sí" | "oui" | "nyala" | "sì" | "aan" | "wł" | "sim" | "вкл" | "açık"; + "nav.apiary": "Bienenhaus" | "Apiary" | "Apiario" | "Rucher" | "Peternakan Lebah" | "Bijenstal" | "Pasieka" | "Apiário" | "Пасека" | "Arılık"; + "nav.cashdesk": "Kasse" | "Cashdesk" | "Caja" | "Caisse" | "Kasir" | "Cassa" | "Kassa" | "Kasa" | "Caixa" | "Касса"; + "nav.earnings": "Einnahmen" | "Earnings" | "Ganancias" | "Gains" | "Penghasilan" | "Guadagni" | "Inkomsten" | "Zarobki" | "Ganhos" | "Заработок" | "Kazançlar"; + "nav.game": "Spiel" | "Game" | "Juego" | "Jeu" | "Permainan" | "Gioco" | "Spel" | "Gra" | "Jogo" | "Игра" | "Oyun"; + "nav.menu": "Menü" | "Menu" | "Menú" | "Меню"; + "nav.roulette": "Roulette" | "Ruleta" | "Ruletka" | "Roleta" | "Рулетка" | "Rulet"; + "nav.shop": "Shop" | "Tienda" | "Boutique" | "Toko" | "Negozio" | "Winkel" | "Sklep" | "Loja" | "Магазин" | "Mağaza"; + "nav.tasks": "Aufgaben" | "Tasks" | "Tareas" | "Tâches" | "Tugas" | "Compiti" | "Taken" | "Zadania" | "Tarefas" | "Задания" | "Görevler"; + "pagination.of": "von" | "of" | "de" | "sur" | "dari" | "di" | "van" | "z" | "из" | "/"; + "settings.accountInfo": "Kontoinformationen" | "Account information" | "Información de la cuenta" | "Informations du compte" | "Informasi akun" | "Informazioni account" | "Accountinformatie" | "Informacje o koncie" | "Informações da conta" | "Информация об аккаунте" | "Hesap bilgileri"; + "settings.back": "Zurück" | "Back" | "Volver" | "Retour" | "Kembali" | "Indietro" | "Terug" | "Wstecz" | "Voltar" | "Назад" | "Geri"; + "settings.faq": "FAQ" | "ЧаВо" | "SSS"; + "settings.language": "Sprache" | "Language" | "Idioma" | "Langue" | "Bahasa" | "Lingua" | "Taal" | "Język" | "Язык" | "Dil"; + "settings.sound": "Ton" | "Sound" | "Sonido" | "Son" | "Suara" | "Suono" | "Geluid" | "Dźwięk" | "Som" | "Звук" | "Ses"; + "settings.support": "Support" | "Soporte" | "Dukungan" | "Supporto" | "Ondersteuning" | "Wsparcie" | "Suporte" | "Поддержка" | "Destek"; + "settings.transactionHistory": "Transaktionsverlauf" | "Transaction History" | "Historial de transacciones" | "Historique des transactions" | "Riwayat transaksi" | "Cronologia transazioni" | "Transactiegeschiedenis" | "Historia transakcji" | "Histórico de transações" | "История транзакций" | "İşlem Geçmişi"; +}; +export default resources; diff --git a/src/i18n/useLanguages.ts b/src/i18n/useLanguages.ts new file mode 100644 index 0000000..ffb3cc5 --- /dev/null +++ b/src/i18n/useLanguages.ts @@ -0,0 +1,20 @@ +import { useTranslation } from "react-i18next"; + +import { languages } from "./languages"; +import type { Language } from "./languages"; + +export function useLanguages(): { + languages: Language[]; + current: Language; + setLanguage: (key: string) => void; +} { + const { i18n } = useTranslation(); + + const current = languages.find((lang) => lang.key === i18n.language)!; + + const setLanguage = (key: string): void => { + i18n.changeLanguage(key); + }; + + return { languages, current, setLanguage }; +} diff --git a/src/main.tsx b/src/main.tsx index 0ed0bcf..1f5cdee 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -14,6 +14,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import i18n from "@/i18n"; import tg from "@/tg"; import { routeTree } from "./routeTree.gen"; +import { AudioProvider } from "@/audio"; +import { LiftProvider } from "@components/lift"; import "./styles/index.css"; @@ -32,7 +34,11 @@ i18n.init(); ReactDOM.createRoot(document.getElementById("root")!).render( - + + + + + , ); diff --git a/src/routes/-/RootLayout/RootLayout.tsx b/src/routes/-/RootLayout/RootLayout.tsx index 32c6c7e..6fe5c9f 100644 --- a/src/routes/-/RootLayout/RootLayout.tsx +++ b/src/routes/-/RootLayout/RootLayout.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import Header from "./components/Header"; import Navigation from "./components/Navigation"; +import { LiftLayer } from "@components/lift"; import classes from "./RootLayout.module.css"; type Props = { @@ -16,6 +17,7 @@ export default function RootLayout({ children, hideControls }: Props) {
{children}
{!hideControls && } +
); } diff --git a/src/routes/-/RootLayout/components/Header/Header.module.css b/src/routes/-/RootLayout/components/Header/Header.module.css index d661c2e..f6fb56f 100644 --- a/src/routes/-/RootLayout/components/Header/Header.module.css +++ b/src/routes/-/RootLayout/components/Header/Header.module.css @@ -10,7 +10,7 @@ top: 0; right: 0; left: 0; - z-index: 10; + z-index: 20; padding-top: var(--header-padding); padding-left: var(--safe-left); diff --git a/src/routes/-/RootLayout/components/Header/components/Profile/Profile.module.css b/src/routes/-/RootLayout/components/Header/components/Profile/Profile.module.css index 43664d5..41173cd 100644 --- a/src/routes/-/RootLayout/components/Header/components/Profile/Profile.module.css +++ b/src/routes/-/RootLayout/components/Header/components/Profile/Profile.module.css @@ -8,12 +8,51 @@ background-repeat: no-repeat; background-size: cover; display: flex; + align-items: center; gap: 2px; } .left { } + .avatar { + position: relative; + display: flex; + flex-direction: column; + } + + .avatarBorder { + width: 68px; + height: 68px; + border-radius: 50%; + background: linear-gradient(180deg, #eed074 0%, #a5602c 100%); + border: 1px solid #401e08; + box-shadow: + 4px 0px 1px -2px #00000040, + 0px 2px 0px 0px #ffefd1 inset, + 0px -2px 0px 0px #7a451c inset; + padding: 5px; + } + + .avatarInner { + width: 100%; + height: 100%; + border-radius: 50%; + background: linear-gradient(180deg, #9e6025 0%, #feeea1 100%); + box-shadow: + 0px -1px 0px 0px #ffffffa6 inset, + 0px 1px 0px 0px #00000040 inset; + padding: 3px; + overflow: hidden; + } + + .avatarImage { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + } + .right { display: flex; flex-direction: column; diff --git a/src/routes/-/RootLayout/components/Header/components/Profile/Profile.tsx b/src/routes/-/RootLayout/components/Header/components/Profile/Profile.tsx index 1d27c91..1391555 100644 --- a/src/routes/-/RootLayout/components/Header/components/Profile/Profile.tsx +++ b/src/routes/-/RootLayout/components/Header/components/Profile/Profile.tsx @@ -1,19 +1,30 @@ import classes from "./Profile.module.css"; import { motion } from "motion/react"; +import { Liftable } from "@components/lift"; +import { useTelegramUser } from "@/tg"; export default function Profile() { + const user = useTelegramUser(); return ( - -
-
-
-
-
- + + +
+
+
+ {user?.photoUrl && } +
+
+
+
+
+
+
+ + ); } diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/Settings.tsx b/src/routes/-/RootLayout/components/Header/components/Settings/Settings.tsx index 56c8c64..6f39d40 100644 --- a/src/routes/-/RootLayout/components/Header/components/Settings/Settings.tsx +++ b/src/routes/-/RootLayout/components/Header/components/Settings/Settings.tsx @@ -1,49 +1,114 @@ -import { useState } from "react"; +import { lazy, Suspense } from "react"; +import { useMachine } from "@xstate/react"; import { motion } from "motion/react"; import tg from "@/tg"; +import { settingsMachine } from "./settingsMachine"; +import SettingsModal, { type SettingsModalId } from "./components/SettingsModal"; + +const FAQModal = lazy(() => import("./components/FAQModal")); +const AccountModal = lazy(() => import("./components/AccountModal")); +const SupportModal = lazy(() => import("./components/SupportModal")); +const TransactionsHistoryModal = lazy(() => import("./components/TransactionsHistoryModal")); +const LanguageModal = lazy(() => import("./components/LanguageModal")); import classes from "./Settings.module.css"; +import { usePlaySound } from "@/audio"; export default function Settings() { - const [isOpen, setIsOpen] = useState(false); + const play = usePlaySound(); + + const [state, send] = useMachine(settingsMachine); + + const isOpen = state.value !== "closed"; const toggle = () => { + play("click"); tg.hapticFeedback.click(); - setIsOpen((prev) => !prev); + if (isOpen) { + send({ type: "CLOSE" }); + } else { + send({ type: "OPEN_SETTINGS" }); + } + }; + + const handleClose = () => { + send({ type: "CLOSE" }); + }; + + const handleBack = () => { + send({ type: "BACK" }); + }; + + const handleNavigate = (modal: SettingsModalId) => { + const eventMap = { + faq: "OPEN_FAQ", + account: "OPEN_ACCOUNT", + support: "OPEN_SUPPORT", + transactionsHistory: "OPEN_TRANSACTIONS_HISTORY", + language: "OPEN_LANGUAGE", + } satisfies Record; + send({ type: eventMap[modal] as Parameters[0]["type"] }); }; return ( - -
- {[0, 1, 2].map((i) => ( - - ))} -
-
+ <> + +
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+ + + + + + + + + + + + + + + ); } diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/AccountModal/AccountModal.tsx b/src/routes/-/RootLayout/components/Header/components/Settings/components/AccountModal/AccountModal.tsx new file mode 100644 index 0000000..a7c6e55 --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/AccountModal/AccountModal.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from "react-i18next"; + +import Modal from "@components/modals/Modal"; +import ContentSurface from "@components/surface/ContentSurface"; +import LightSurface from "@components/surface/LightSurface"; + +type Props = { + open: boolean; + onClose: () => void; +}; + +const USER_DATA_MOCK = { + id: 123456789, + registrationDate: "2023-01-01", + paymentBalance: 1000, + withdrawalBalance: 500, +}; + +export default function AccountModal({ open, onClose }: Props) { + const { t } = useTranslation(); + + return ( + + + + {t("accountInfo.yourId")} - {USER_DATA_MOCK.id} + + + + + {t("accountInfo.registrationDate")} - {USER_DATA_MOCK.registrationDate} + + + + + {t("accountInfo.paymentBalance")} - {USER_DATA_MOCK.paymentBalance} + + + + + {t("accountInfo.withdrawalBalance")} - {USER_DATA_MOCK.withdrawalBalance} + + + + ); +} diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/AccountModal/index.ts b/src/routes/-/RootLayout/components/Header/components/Settings/components/AccountModal/index.ts new file mode 100644 index 0000000..ed0b26a --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/AccountModal/index.ts @@ -0,0 +1 @@ +export { default } from "./AccountModal"; diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/FAQModal/FAQModal.tsx b/src/routes/-/RootLayout/components/Header/components/Settings/components/FAQModal/FAQModal.tsx new file mode 100644 index 0000000..2737624 --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/FAQModal/FAQModal.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from "react-i18next"; + +import Modal from "@components/modals/Modal"; +import ContentSurface from "@components/surface/ContentSurface"; +import LightSurface from "@components/surface/LightSurface"; + +type Props = { + open: boolean; + onClose: () => void; +}; + +export default function FAQModal({ open, onClose }: Props) { + const { t } = useTranslation(); + + return ( + + + +

{t("faq.1q")}

+

{t("faq.1a")}

+
+
+ + +

{t("faq.1q")}

+

{t("faq.1a")}

+
+
+ + +

{t("faq.1q")}

+

{t("faq.1a")}

+
+
+ + +

{t("faq.1q")}

+

{t("faq.1a")}

+
+
+
+ ); +} diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/FAQModal/index.ts b/src/routes/-/RootLayout/components/Header/components/Settings/components/FAQModal/index.ts new file mode 100644 index 0000000..3e80c1c --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/FAQModal/index.ts @@ -0,0 +1 @@ +export { default } from "./FAQModal"; diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/LanguageModal.module.css b/src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/LanguageModal.module.css new file mode 100644 index 0000000..4395ddb --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/LanguageModal.module.css @@ -0,0 +1,48 @@ +@layer base { + .grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 5px; + } + + .contentSurface { + border-radius: 9999px; + width: 100%; + + &.active > * { + background-color: #f8eb86; + } + } + + .item { + all: unset; + box-sizing: border-box; + width: 100%; + height: 100%; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 8px 12px; + font-size: 15px; + font-weight: 700; + } + + .langIcon { + width: 27px; + height: 27px; + border-radius: 50%; + background: #ccc; + flex-shrink: 0; + + image { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .itemActive { + color: #4b2c13; + } +} diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/LanguageModal.tsx b/src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/LanguageModal.tsx new file mode 100644 index 0000000..df7056f --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/LanguageModal.tsx @@ -0,0 +1,72 @@ +import { useTranslation } from "react-i18next"; +import { motion } from "motion/react"; + +import Modal from "@components/modals/Modal"; +import ContentSurface from "@components/surface/ContentSurface"; +import DarkSurface from "@components/surface/DarkSurface"; +import { useLanguages } from "@/i18n/useLanguages"; +import { usePlaySound } from "@/audio"; +import tg from "@/tg"; + +import classes from "./LanguageModal.module.css"; +import LightSurface from "@components/surface/LightSurface"; +import clsx from "clsx"; + +type Props = { + open: boolean; + onClose: () => void; + onBack: () => void; +}; + +export default function LanguageModal({ open, onClose, onBack }: Props) { + const { t } = useTranslation(); + const { languages, current, setLanguage } = useLanguages(); + const play = usePlaySound(); + + const handleSelect = (key: string) => { + play("click"); + tg.hapticFeedback.click(); + setLanguage(key); + onClose(); + }; + + const handleBack = () => { + play("click"); + tg.hapticFeedback.click(); + onBack(); + }; + + return ( + +
+ {languages.map((lang) => ( + + + handleSelect(lang.key)} + whileTap={{ scale: 0.95 }} + > +
+ {lang.label} +
+ {lang.label} +
+
+
+ ))} + + + + + {t("settings.back")} + + + +
+
+ ); +} diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/index.ts b/src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/index.ts new file mode 100644 index 0000000..a974518 --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/index.ts @@ -0,0 +1 @@ +export { default } from "./LanguageModal"; diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/SettingsModal.module.css b/src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/SettingsModal.module.css new file mode 100644 index 0000000..55133f0 --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/SettingsModal.module.css @@ -0,0 +1,51 @@ +@layer base { + .items { + display: flex; + flex-direction: column; + gap: 5px; + } + + .contentSurface { + border-radius: 9999px; + width: 100%; + } + + .lightSurface { + border-radius: 9999px; + } + + .item { + all: unset; + box-sizing: border-box; + width: 100%; + text-align: center; + cursor: pointer; + padding: 10px 12px; + font-size: 20px; + font-weight: 700; + } + + .itemRow { + box-sizing: border-box; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + font-size: 20px; + font-weight: 700; + } + + .langIcon { + width: 27px; + height: 27px; + border-radius: 50%; + background: #ccc; + flex-shrink: 0; + image { + width: 100%; + height: 100%; + object-fit: cover; + } + } +} diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/SettingsModal.tsx b/src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/SettingsModal.tsx new file mode 100644 index 0000000..f57fc74 --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/SettingsModal.tsx @@ -0,0 +1,118 @@ +import { useTranslation } from "react-i18next"; +import { motion } from "motion/react"; + +import Modal from "@components/modals/Modal"; +import ContentSurface from "@components/surface/ContentSurface"; +import LightSurface from "@components/surface/LightSurface"; +import SwitchInput from "@components/form/SwitchInput"; +import { useAudioSettings, usePlaySound } from "@/audio"; +import tg from "@/tg"; + +import classes from "./SettingsModal.module.css"; +import { useLanguages } from "@/i18n/useLanguages"; + +export type SettingsModalId = "faq" | "account" | "support" | "transactionsHistory" | "language"; + +type Props = { + open: boolean; + onClose: () => void; + onNavigate: (modal: SettingsModalId) => void; + liftIds?: string[]; +}; + +export default function SettingsModal({ open, onClose, onNavigate, liftIds }: Props) { + const { t } = useTranslation(); + const { current } = useLanguages(); + const { isEnabled, setIsEnabled } = useAudioSettings(); + const play = usePlaySound(); + + const handleNavigate = (modal: SettingsModalId) => { + play("click"); + tg.hapticFeedback.click(); + onNavigate(modal); + }; + + return ( + +
+ + + handleNavigate("faq")} + whileTap={{ scale: 0.95 }} + > + {t("settings.faq")} + + + + + + + handleNavigate("account")} + whileTap={{ scale: 0.95 }} + > + {t("settings.accountInfo")} + + + + + + + handleNavigate("support")} + whileTap={{ scale: 0.95 }} + > + {t("settings.support")} + + + + + + + handleNavigate("transactionsHistory")} + whileTap={{ scale: 0.95 }} + > + {t("settings.transactionHistory")} + + + + + + +
+ {t("settings.sound")} + { + setIsEnabled(value); + if (value) play("click", { force: true }); + }} + /> +
+
+
+ + + + handleNavigate("language")} + whileTap={{ scale: 0.95 }} + > + {t("settings.language")} +
+ lang +
+
+
+
+
+
+ ); +} diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/index.ts b/src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/index.ts new file mode 100644 index 0000000..a7acddf --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/index.ts @@ -0,0 +1,2 @@ +export { default } from "./SettingsModal"; +export type { SettingsModalId } from "./SettingsModal"; diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/SupportModal/SupportModal.tsx b/src/routes/-/RootLayout/components/Header/components/Settings/components/SupportModal/SupportModal.tsx new file mode 100644 index 0000000..c9dbec0 --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/SupportModal/SupportModal.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next"; + +import Modal from "@components/modals/Modal"; +import ContentSurface from "@components/surface/ContentSurface"; +import LightSurface from "@components/surface/LightSurface"; +import Button from "@components/atoms/Button"; + +type Props = { + open: boolean; + onClose: () => void; +}; + +export default function SupportModal({ open, onClose }: Props) { + const { t } = useTranslation(); + + return ( + + + + {t("support.text")} + + + + + ); +} diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/SupportModal/index.ts b/src/routes/-/RootLayout/components/Header/components/Settings/components/SupportModal/index.ts new file mode 100644 index 0000000..2bdba8b --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/SupportModal/index.ts @@ -0,0 +1 @@ +export { default } from "./SupportModal"; diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/TransactionsHistoryModal/TransactionsHistoryModal.module.css b/src/routes/-/RootLayout/components/Header/components/Settings/components/TransactionsHistoryModal/TransactionsHistoryModal.module.css new file mode 100644 index 0000000..eda13eb --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/TransactionsHistoryModal/TransactionsHistoryModal.module.css @@ -0,0 +1,6 @@ +@layer base { + .placeholder { + padding: 12px; + text-align: center; + } +} diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/TransactionsHistoryModal/TransactionsHistoryModal.tsx b/src/routes/-/RootLayout/components/Header/components/Settings/components/TransactionsHistoryModal/TransactionsHistoryModal.tsx new file mode 100644 index 0000000..9547cdd --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/TransactionsHistoryModal/TransactionsHistoryModal.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from "react-i18next"; + +import Modal from "@components/modals/Modal"; +import ContentSurface from "@components/surface/ContentSurface"; +import LightSurface from "@components/surface/LightSurface"; + +import DarkSurface from "@components/surface/DarkSurface"; +import HoneyIcon from "@components/icons/HoneyIcon"; +import MoneyIcon from "@components/icons/MoneyIcon"; +import Pagination from "@components/atoms/Pagination"; +import { useState } from "react"; + +const MOCK_TRANSACTIONS: { + id: number; + amount: number; + date: string; + currency: "honey" | "money"; + kind: "withdrawal" | "deposit" | "greeting" | "referral"; +}[] = [ + { id: 1, amount: 500, date: "2026-03-20", currency: "honey", kind: "deposit" }, + { id: 2, amount: -150, date: "2026-03-19", currency: "money", kind: "withdrawal" }, + { id: 3, amount: 100, date: "2026-03-18", currency: "honey", kind: "greeting" }, + { id: 4, amount: 250, date: "2026-03-17", currency: "honey", kind: "referral" }, + { id: 7, amount: 75, date: "2026-03-14", currency: "money", kind: "referral" }, + { id: 8, amount: -200, date: "2026-03-13", currency: "honey", kind: "withdrawal" }, +]; + +type Props = { + open: boolean; + onClose: () => void; +}; + +const numberFormat = new Intl.NumberFormat("en-US", { signDisplay: "exceptZero" }); +const dateFormat = new Intl.DateTimeFormat("fi-FI"); + +export default function TransactionsHistoryModal({ open, onClose }: Props) { + const { t } = useTranslation(); + + const totalPages = 6; + const [page, setPage] = useState(1); + + return ( + + + {t("transactionHistory.yourTransactions")} + +
+ + + {t("transactionHistory.sum")} + + + + + {t("transactionHistory.date")} + + +
+ + {MOCK_TRANSACTIONS.map(({ id, kind, date, currency, amount }) => ( + + +
+ {numberFormat.format(amount)} + {currency === "honey" ? : } +
+
{dateFormat.format(new Date(date))}
+
+ + {t("transactionHistory.operationType")}: {t(`operationType.${kind}`)} + +
+ ))} + + +
+ ); +} diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/TransactionsHistoryModal/index.ts b/src/routes/-/RootLayout/components/Header/components/Settings/components/TransactionsHistoryModal/index.ts new file mode 100644 index 0000000..a7c5f6b --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/TransactionsHistoryModal/index.ts @@ -0,0 +1 @@ +export { default } from "./TransactionsHistoryModal"; diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/settingsMachine.ts b/src/routes/-/RootLayout/components/Header/components/Settings/settingsMachine.ts new file mode 100644 index 0000000..8820c4a --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/settingsMachine.ts @@ -0,0 +1,65 @@ +import { setup } from "xstate"; + +export const settingsMachine = setup({ + types: { + events: {} as + | { type: "OPEN_SETTINGS" } + | { type: "OPEN_FAQ" } + | { type: "OPEN_ACCOUNT" } + | { type: "OPEN_SUPPORT" } + | { type: "OPEN_TRANSACTIONS_HISTORY" } + | { type: "OPEN_LANGUAGE" } + | { type: "CLOSE" } + | { type: "BACK" }, + }, +}).createMachine({ + id: "settings", + initial: "closed", + states: { + closed: { + on: { + OPEN_SETTINGS: { target: "settings" }, + }, + }, + settings: { + on: { + CLOSE: { target: "closed" }, + OPEN_FAQ: { target: "faq" }, + OPEN_ACCOUNT: { target: "account" }, + OPEN_SUPPORT: { target: "support" }, + OPEN_TRANSACTIONS_HISTORY: { target: "transactionsHistory" }, + OPEN_LANGUAGE: { target: "language" }, + }, + }, + faq: { + on: { + CLOSE: { target: "closed" }, + BACK: { target: "settings" }, + }, + }, + account: { + on: { + CLOSE: { target: "closed" }, + BACK: { target: "settings" }, + }, + }, + support: { + on: { + CLOSE: { target: "closed" }, + BACK: { target: "settings" }, + }, + }, + transactionsHistory: { + on: { + CLOSE: { target: "closed" }, + BACK: { target: "settings" }, + }, + }, + language: { + on: { + CLOSE: { target: "closed" }, + BACK: { target: "settings" }, + }, + }, + }, +}); diff --git a/src/routes/-/RootLayout/components/Navigation/Navigation.tsx b/src/routes/-/RootLayout/components/Navigation/Navigation.tsx index 2aac27d..a841522 100644 --- a/src/routes/-/RootLayout/components/Navigation/Navigation.tsx +++ b/src/routes/-/RootLayout/components/Navigation/Navigation.tsx @@ -23,6 +23,7 @@ import rouletteIcon from "./assets/roulette.svg"; import tasksIcon from "./assets/tasks.svg"; import earningsIcon from "./assets/earnings.svg"; import tg, { useTelegramViewportValue } from "@/tg"; +import { usePlaySound } from "@/audio"; const ANIMATION_DURATION = 0.2; const SPRING_ANIMATION = { @@ -34,7 +35,6 @@ const SPRING_ANIMATION = { const BAR_HEIGHT = 64; const ACTIVE_BAR_HEIGHT = 74; -const OFFSCREEN_BAR_OFFSET = 20; const NAV_ITEMS = [ { key: "nav.shop", route: ShopRoute, icon: shopIcon }, @@ -162,6 +162,7 @@ function MenuBar({ labelKey, icon, delay, active, onClick }: MenuBarProps) { } export default function Navigation() { + const play = usePlaySound(); const matchRoute = useMatchRoute(); const navigate = useNavigate(); const [menuOpen, setMenuOpen] = useState(0); @@ -181,6 +182,7 @@ export default function Navigation() { }, [menuOpen, handleOutsideClick]); const navigateRoute = async (route: AnyRoute, wait = false) => { + play("click"); tg.hapticFeedback.click(); const redirection = navigate({ to: route.to }); if (wait) { @@ -220,6 +222,7 @@ export default function Navigation() { onClick={ item.isMenu ? () => { + play("click"); tg.hapticFeedback.click(); setMenuOpen((v) => (v ? 0 : Math.random())); } diff --git a/src/routes/game/-/GameRoute.tsx b/src/routes/game/-/GameRoute.tsx index dfe3b8c..31d0a45 100644 --- a/src/routes/game/-/GameRoute.tsx +++ b/src/routes/game/-/GameRoute.tsx @@ -15,6 +15,7 @@ import TextInput from "@components/form/TextInput"; import NumberInput from "@components/form/NumberInput"; import TextAreaInput from "@components/form/TextAreaInput"; import ActionModal from "@components/modals/ActionModal"; +import Pagination from "@components/atoms/Pagination"; const TABS = [ { key: "tab1", title: "Tab 1" }, @@ -24,9 +25,11 @@ const TABS = [ export default function GameRoute() { const [activeTab, setActiveTab] = useState(TABS[0].key); + const [page, setPage] = useState(1); const [progressValue, setProgressValue] = useState(0); const [switchValue, setSwitchValue] = useState(false); const [modalOpen, setModalOpen] = useState(false); + const [buttonsDisabled, setButtonsDisabled] = useState(false); return ( @@ -42,12 +45,23 @@ export default function GameRoute() { 100$ - - - - - - + + + + + + + @@ -66,6 +80,8 @@ export default function GameRoute() { + + mockCssVar(key, value)); } console.log( import.meta.env.DEV ? "TMA Debug mode in Web initialized" : "Telegram Mini App initialized", ); + + prefetch(launchParams.tgWebAppData?.user?.photo_url); }, openLink(url: string | URL, options?: tg.OpenLinkOptions) { tg.openLink.ifAvailable(url, options); @@ -258,3 +265,5 @@ export const useTelegramViewportValue = (key: keyof typeof externalTgApi.viewpor useSignal(externalTgApi.viewport[key]); return externalTgApi.viewportCss[key](); }; + +export const useTelegramUser = () => useLaunchParams(true).tgWebAppData?.user; diff --git a/vite.config.ts b/vite.config.ts index 403cf95..1d19298 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,7 +5,8 @@ import react from "@vitejs/plugin-react-swc"; import tailwindcss from "@tailwindcss/vite"; import tanstackRouter from "@tanstack/router-plugin/vite"; import { devtools as tanstackDevtools } from "@tanstack/devtools-vite"; -import { i18nextVitePlugin } from "@i18next-selector/vite-plugin"; +import { i18nextSortPlugin } from "./plugins/i18nextSortPlugin"; +import { i18nextTypesPlugin } from "./plugins/i18nextTypesPlugin"; import i18nextConfig, { DEFAULT_LANGUAGE, LOCALES_PATH, @@ -14,9 +15,13 @@ import i18nextConfig, { export default defineConfig({ plugins: [ - i18nextVitePlugin({ + i18nextSortPlugin({ sourceDir: LOCAL_LOCALES_PATH, }), + i18nextTypesPlugin({ + sourceDir: LOCAL_LOCALES_PATH, + destination: "./src/i18n/resources.d.ts", + }), tanstackDevtools({ removeDevtoolsOnBuild: true, }),