├── .editorconfig ├── .gitignore ├── .vscode └── extensions.json ├── LICENSE ├── README.md ├── client ├── .env.example ├── .eslintrc.cjs ├── README.md ├── index.html ├── package.json ├── postcss.config.js ├── public │ ├── pics │ │ ├── _hotel-1.jpg │ │ ├── _hotel-2.jpg │ │ ├── _room-1-1.jpg │ │ ├── _room-1-2.jpg │ │ ├── hotel-1.jpg │ │ ├── hotel-2.jpg │ │ ├── hotel-3.jpg │ │ ├── hotel-4.jpg │ │ ├── hotel-5.jpg │ │ ├── hotel-6.jpg │ │ ├── hotel-7.jpg │ │ ├── room-1-1.jpg │ │ ├── room-1-2.jpg │ │ ├── room-2-1.jpg │ │ ├── room-2-2.jpg │ │ ├── room-2-3.jpg │ │ ├── room-3-1.jpg │ │ ├── room-3-2.jpg │ │ ├── room-3-3.jpg │ │ ├── room-4-1.jpg │ │ ├── room-4-2.jpg │ │ ├── room-4-3.jpg │ │ ├── room-5-1.jpg │ │ ├── room-5-2.jpg │ │ ├── room-6-1.jpg │ │ ├── room-6-2.jpg │ │ ├── room-6-3.jpg │ │ ├── room-7-1.jpg │ │ └── room-7-2.jpg │ └── telebook.svg ├── src │ ├── App.vue │ ├── application │ │ ├── router-shims.d.ts │ │ ├── router.ts │ │ └── services │ │ │ ├── README.md │ │ │ ├── index.ts │ │ │ ├── useLayout.ts │ │ │ ├── useLottie.ts │ │ │ ├── useScroll.ts │ │ │ ├── useTelegram.ts │ │ │ └── useThumbnail.ts │ ├── domain │ │ ├── entities │ │ │ ├── Award.ts │ │ │ ├── Chart.ts │ │ │ ├── City.ts │ │ │ ├── EntityPicture.ts │ │ │ ├── Hotel.ts │ │ │ ├── LabeledPrice.ts │ │ │ ├── Rating.ts │ │ │ ├── Room.ts │ │ │ ├── TripDetails.ts │ │ │ ├── errors │ │ │ │ ├── Base.ts │ │ │ │ ├── NotFound.ts │ │ │ │ └── Unauthorized.ts │ │ │ └── index.ts │ │ └── services │ │ │ ├── README.md │ │ │ ├── index.ts │ │ │ ├── useCities.ts │ │ │ ├── useHotel.ts │ │ │ ├── useInvoice.ts │ │ │ └── useTripDetails.ts │ ├── infra │ │ ├── store │ │ │ ├── cities │ │ │ │ ├── index.ts │ │ │ │ └── mock │ │ │ │ │ └── cities.ts │ │ │ ├── hotels │ │ │ │ └── mock │ │ │ │ │ ├── amenities.ts │ │ │ │ │ └── hotels.ts │ │ │ ├── reviews │ │ │ │ └── mock │ │ │ │ │ └── reviews.ts │ │ │ └── thumbs │ │ │ │ ├── image.cache.ts │ │ │ │ └── thumbs.json │ │ ├── transport │ │ │ ├── api │ │ │ │ ├── index.ts │ │ │ │ └── types │ │ │ │ │ └── response.ts │ │ │ ├── fetch │ │ │ │ └── index.ts │ │ │ └── types │ │ │ │ └── json.ts │ │ └── utils │ │ │ ├── color.ts │ │ │ ├── date.ts │ │ │ ├── dom.ts │ │ │ └── number.ts │ ├── main.ts │ ├── presentation │ │ ├── assets │ │ │ ├── fonts │ │ │ │ ├── OpenSans.ttf │ │ │ │ ├── Roboto-Black.ttf │ │ │ │ ├── Roboto-Bold.ttf │ │ │ │ ├── Roboto-Medium.ttf │ │ │ │ └── Roboto-Regular.ttf │ │ │ ├── icons │ │ │ │ ├── arrow-left.svg │ │ │ │ ├── arrow-right.svg │ │ │ │ ├── card.svg │ │ │ │ ├── checkmark.svg │ │ │ │ ├── chevron-right.svg │ │ │ │ ├── clock-fill.svg │ │ │ │ ├── laurel-left.svg │ │ │ │ ├── laurel-right.svg │ │ │ │ ├── market-fill.svg │ │ │ │ ├── search.svg │ │ │ │ ├── settings-faq.svg │ │ │ │ ├── settings-user.svg │ │ │ │ ├── square-filled-air.svg │ │ │ │ ├── square-filled-bed.svg │ │ │ │ ├── square-filled-parking.svg │ │ │ │ ├── square-filled-safe.svg │ │ │ │ ├── square-filled-sport.svg │ │ │ │ ├── square-filled-wifi.svg │ │ │ │ ├── user-circle-fill.svg │ │ │ │ └── xmark-24.svg │ │ │ └── lottie │ │ │ │ ├── eyes.json │ │ │ │ └── simp.json │ │ ├── components │ │ │ ├── Amount │ │ │ │ └── Amount.vue │ │ │ ├── Avatar │ │ │ │ └── Avatar.vue │ │ │ ├── DataOverview │ │ │ │ ├── DataOverview.vue │ │ │ │ └── DataOverviewItem.vue │ │ │ ├── DatePicker │ │ │ │ ├── DatePicker.vue │ │ │ │ └── DatePickerCompact.vue │ │ │ ├── Icon │ │ │ │ └── Icon.vue │ │ │ ├── Input │ │ │ │ └── Input.vue │ │ │ ├── List │ │ │ │ ├── List.vue │ │ │ │ ├── ListCard.vue │ │ │ │ ├── ListItem.vue │ │ │ │ ├── ListItemExpandable.vue │ │ │ │ └── ListItemIcon.vue │ │ │ ├── Lottie │ │ │ │ └── Lottie.vue │ │ │ ├── Page │ │ │ │ ├── FixedFooter.vue │ │ │ │ └── WithHeader.vue │ │ │ ├── Placeholder │ │ │ │ └── Placeholder.vue │ │ │ ├── README.md │ │ │ ├── Rating │ │ │ │ └── Rating.vue │ │ │ ├── Section │ │ │ │ ├── Section.vue │ │ │ │ └── Sections.vue │ │ │ ├── Text │ │ │ │ └── Text.vue │ │ │ └── index.ts │ │ ├── screens │ │ │ ├── Home.vue │ │ │ ├── Hotel.vue │ │ │ ├── Location.vue │ │ │ └── Room.vue │ │ └── styles │ │ │ ├── hacks.css │ │ │ ├── index.css │ │ │ ├── reset.css │ │ │ └── theme │ │ │ ├── animations.css │ │ │ ├── colors.css │ │ │ ├── sizes.css │ │ │ ├── spacings.css │ │ │ └── typescale.css │ └── vite-env.d.ts ├── tools │ ├── README.md │ └── thumbs.js ├── tsconfig.eslint.json ├── tsconfig.json ├── tsconfig.node.json ├── vercel.json ├── vite.config.ts └── yarn.lock ├── docs ├── Awesome.md ├── Deployment.md ├── GetStarted.md ├── Payments.md └── assets │ ├── cover-light.png │ ├── cover.png │ ├── payments-showcase.png │ └── ui │ ├── Amount.png │ ├── Avatar.png │ ├── DataOverview.png │ ├── DatePicker.png │ ├── DatePickerCompact.png │ ├── FixedFooter.png │ ├── Icon.png │ ├── Input.png │ ├── List.png │ ├── ListCard.png │ ├── ListItem.png │ ├── ListItemExpandable.png │ ├── Lottie.png │ ├── PageWithHeader.png │ ├── Placeholder.png │ ├── Rating.png │ ├── Section.png │ ├── Sections.png │ └── Text.png ├── ngrok.example.yml └── server ├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── README.md ├── api └── index.ts ├── package.json ├── public └── privacy.html ├── src ├── api │ ├── bot.ts │ ├── http.ts │ └── router │ │ └── index.ts ├── config.ts ├── index.ts └── infra │ └── utils │ └── notify │ ├── index.ts │ └── notify.ts ├── tsconfig.json ├── vercel.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | indent_style = space 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .env 26 | ngrok.yml 27 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Peter Savchenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Telebook 2 | 3 | Telegram Mini App for booking hotels * 4 | 5 | * — it's a demonstration of [Telegram Mini Apps](https://core.telegram.org/bots/webapps) platform. No real hotels and payments. 6 | 7 |

8 | 9 | 10 | 11 | 12 | Editor.js Logo 13 | 14 | 15 |

16 | 17 |

18 | @tebook_bot/telebook | 19 | Telegram Mini Apps | 20 | Documentation 21 |

22 | 23 | Use this project as an example or template for the creation of your app: 24 | 25 | 1. 🧩 Meet Telegram Vue UI Kit — build native-like interfaces with ready-to-use components 26 | 3. ❤️‍🔥 Instant picture previews and on-device cache 27 | 4. ☘️ Smooth screen transitions 28 | 2. ✨ Advanced DX — fast build, hot reloading, modern code style and linters, well-documented code 29 | 5. 💎 Clean but simple architecture — easy to scale and maintain 30 | 6. 📦 Production-ready deployment setup 31 | 7. 💵 Payments support 32 | 8. 📋 Privacy Policy template 33 | 34 | ## 👋 About the example 35 | 36 | Telebook — is a kind of booking app that runs inside the Telegram. It provides several screens demonstrating different abilities: list views, cards, animations, forms, payments, etc. 37 | 38 | It uses mocked data: 39 | - Cities available for search 40 | - Hotels 41 | - Rooms 42 | - Reviews 43 | - All mock pictures are generated using [Shedevrum AI](https://shedevrum.ai) 44 | 45 | ## 📖 How to use repo 46 | 47 | Use following instructions 48 | 49 | - 💗 [Get Started](./docs/GetStarted.md) - basic info about Mini Apps development 50 | - 🏠 [Frontend tech guide](./client/README.md) - how to setup Client 51 | - 🎁 [Backend tech guide](./server/README.md) - how to setup Backend 52 | - 🛍️ [Telegram Vue UI Kit](./client/src/presentation/components/README.md) - UI Kit guide 53 | - 💰 [How to setup Payments](./docs/Payments.md) - useful information about Payments integration 54 | - ⛅️ [Deployment guide](./docs/Deployment.md) - how to deploy 55 | - 😎 [Awesome List](./docs/Awesome.md) - list of resources that can be useful when building your own Telegram Mini App 56 | 57 | Feel free to [Open Issue](https://github.com/neSpecc/telebook/issues/new) with your question or suggestion 58 | -------------------------------------------------------------------------------- /client/.env.example: -------------------------------------------------------------------------------- 1 | # Web client (this) host 2 | VITE_WEB_HOST=https://xxxx-xx-xxx-xxx-xx.ngrok-free.app 3 | 4 | # Backend host 5 | VITE_API_HOST=https://xxxx-xx-xxx-xxx-xx.ngrok-free.app 6 | -------------------------------------------------------------------------------- /client/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'standard-with-typescript', 5 | 'plugin:vue/vue3-recommended', 6 | 'plugin:listeners/recommended', 7 | ], 8 | parser: 'vue-eslint-parser', 9 | parserOptions: { 10 | parser: '@typescript-eslint/parser', 11 | tsconfigRootDir: __dirname, 12 | project: [ 13 | './tsconfig.json', 14 | './tsconfig.eslint.json', 15 | ], 16 | ecmaVersion: 2022, 17 | extraFileExtensions: ['.vue'], 18 | }, 19 | plugins: [ 20 | 'clean-timer', 21 | 'listeners', 22 | ], 23 | rules: { 24 | /** Prefer semicolons */ 25 | semi: 'off', 26 | '@typescript-eslint/semi': ['error', 'never'], 27 | '@typescript-eslint/member-delimiter-style': [ 28 | 'error', 29 | { 30 | multiline: { 31 | delimiter: 'semi', 32 | requireLast: true, 33 | }, 34 | singleline: { 35 | delimiter: 'semi', 36 | requireLast: false, 37 | }, 38 | multilineDetection: 'brackets', 39 | }, 40 | ], 41 | 42 | /** Require trailing comma for better git diffs */ 43 | 'comma-dangle': 'off', 44 | '@typescript-eslint/comma-dangle': ['error', 'always-multiline'], 45 | 46 | /** Do not allow spaces before function parenthesis */ 47 | 'space-before-function-paren': 'off', 48 | '@typescript-eslint/space-before-function-paren': ['error', { 49 | anonymous: 'always', 50 | named: 'never', 51 | }], 52 | 53 | /** Allow and enforce `... as Foo` type assertion */ 54 | '@typescript-eslint/consistent-type-assertions': ['error', { 55 | assertionStyle: 'as', 56 | objectLiteralTypeAssertions: 'allow', 57 | }], 58 | 59 | /** 60 | * Do not enforce nullish coalescing, 61 | * so you can use `valueA || valueB` 62 | */ 63 | '@typescript-eslint/prefer-nullish-coalescing': 'off', 64 | 65 | /** Temporarily allow empty interfaces, while in active development */ 66 | '@typescript-eslint/no-empty-interface': 'off', 67 | 68 | /** 69 | * Use TS rule to highlight unused variables. 70 | * Default one does not recognize enums 71 | */ 72 | 'no-unused-vars': 'off', 73 | '@typescript-eslint/no-unused-vars': 'error', 74 | 75 | /** 76 | * Use TS rule to highlight unnecessary spacings before functions identifiers. 77 | * Default one does not work well with TS interfaces 78 | */ 79 | 'func-call-spacing': 'off', 80 | '@typescript-eslint/func-call-spacing': 'error', 81 | 82 | /** 83 | * Disable, as this rule does not play nice with functions, which return promises 84 | */ 85 | '@typescript-eslint/no-confusing-void-expression': 'off', 86 | 87 | /** 88 | * Specify an order of top-level tags in vue single-file components 89 | * https://github.com/vuejs/eslint-plugin-vue/blob/master/docs/rules/component-tags-order.md 90 | * https://vuejs.org/v2/style-guide/#Single-file-component-top-level-element-order-recommended 91 | */ 92 | 'vue/component-tags-order': ['error', { 93 | order: ['script', 'template', 'style'], 94 | }], 95 | 96 | /** Allow single-word component names */ 97 | 'vue/multi-word-component-names': 'off', 98 | 99 | /** Require just one line break after opening and before closing block tags */ 100 | 'vue/block-tag-newline': ['error', { 101 | singleline: 'always', 102 | multiline: 'always', 103 | maxEmptyLines: 0, 104 | blocks: { 105 | template: { 106 | singleline: 'always', 107 | multiline: 'always', 108 | maxEmptyLines: 1, 109 | }, 110 | }, 111 | }], 112 | 113 | 'clean-timer/assign-timer-id': 2, 114 | }, 115 | /** 116 | * Rules overrides for .js files 117 | */ 118 | overrides: [ 119 | { 120 | files: ['*.js', '*.tsx'], 121 | rules: { 122 | '@typescript-eslint/explicit-function-return-type': 'off', 123 | '@typescript-eslint/strict-boolean-expressions': 'off', 124 | '@typescript-eslint/no-floating-promises': 'off', 125 | }, 126 | }, 127 | ], 128 | } 129 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Telegram Mini App client 2 | 3 | This project contains an example of Telegram Mini App. 4 | 5 | Telebook — is a fictional hotel booking service integrated to Telegram. It uses completely mocked data of hotels, cities and pictures. 6 | 7 |

8 | 9 | 10 | 11 | 12 | Editor.js Logo 13 | 14 | 15 |

16 | 17 | ## Features and Stack 18 | 19 | - 📦 Vue Telegram UI Kit 20 | - 📲 Declarative screens construction 21 | - ✈️ `useTelegram()` composable that simplifies usage of Telegram Web App SDK 22 | - ❤️‍🔥 Instant picture previews and on-device cache 23 | - 💎 Clean but simple architecture 24 | - ✨ Hot Reloading 25 | - 💰 Payments support 26 | 27 | ## Telegram Vue UI Kit 28 | 29 | It contains ready-to-use collection of components that could be used to create native-like views that will look perfect on iOS, Android and other devices 30 | 31 | - [Amount](./src/presentation/components/README.md#amount) 32 | - [Avatar](./src/presentation/components/README.md#avatar) 33 | - [DataOverview](./src/presentation/components/README.md#dataoverview) 34 | - [DatePicker](./src/presentation/components/README.md#datepicker) 35 | - [DatePickerCompact ](./src/presentation/components/README.md#datepickercompact) 36 | - [Icon](./src/presentation/components/README.md#icon) 37 | - [Input](./src/presentation/components/README.md#input) 38 | - [List](./src/presentation/components/README.md#list) 39 | - [ListItem](./src/presentation/components/README.md#listitem) 40 | - [ListCard](./src/presentation/components/README.md#listcard) 41 | - [ListItemExpandable](./src/presentation/components/README.md#listitemexpandable) 42 | - [Lottie](./src/presentation/components/README.md#lottie) 43 | - [FixedFooter](./src/presentation/components/README.md#fixedfooter) 44 | - [PageWithHeader](./src/presentation/components/README.md#pagewithheader) 45 | - [Placeholder](./src/presentation/components/README.md#placeholder) 46 | - [Rating](./src/presentation/components/README.md#rating) 47 | - [Section](./src/presentation/components/README.md#section) 48 | - [Sections](./src/presentation/components/README.md#sections) 49 | - [Text](./src/presentation/components/README.md#text) 50 | 51 | Read more in [Telegram Vue UI Kit](./src/presentation/components/README.md) documentation 52 | 53 | ## Get started 54 | 55 | 0. Crate a bot 56 | 57 | Go to [@BotFather](https://t.me/@BotFather), write the `/newbot` command and follow instructions. 58 | 59 | Then, call the `/newapp` comman to create your app. When BotFather will ask about Web App Url, start running the application as described below. 60 | 61 | 1. Install dependencies 62 | 63 | ``` 64 | yarn install 65 | ``` 66 | 67 | 2. Copy .env.example to .env and fill the variables 68 | 69 | ``` 70 | cp .env.example .env 71 | ``` 72 | 73 | | Name | Description | Example | Where to get | 74 | | -- | -- | -- | -- | 75 | | VITE_WEB_HOST | Web client endpoint | `https://xxxx-xx-xxx-xxx-xx.ngrok-free.app` | Use ngrok host for local development and your production host on real environemnt | 76 | | VITE_API_HOST | Backend endpoint | `https://xxxx-xx-xxx-xxx-xx.ngrok-free.app` | Use ngrok host for local development and your production host on real environemnt | 77 | 78 | 3. Run 79 | 80 | | Command | Description | 81 | | -- | -- | 82 | | `yarn dev` | Start dev server with Hot Reloading | 83 | | `yarn build` | Compile TS and prepare bundle for production | 84 | | `yarn preview` | Preview production bundle | 85 | | `yarn link` | Check ESLint problems | 86 | | `yarn link:fox` | Autofix ESLint problems when possible | 87 | 88 | 4. When app is running, give a link to @BotFather: 89 | 90 | Copy the URL of your running app and send it to [@BotFather](https://t.me/@BotFather) if he still waiting for it during app creation process. Or call `/myapps`, select our bot and press `Edit Web App URL` button on the Inline Keyboard. 91 | 92 | ### Recommended IDE Setup 93 | 94 | - [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). 95 | 96 | 97 | ## Tech Stack and credits 98 | 99 | List of libraries the project relies on 100 | 101 | - TypeScript 102 | - [Vue.js 3](https://vuejs.org) — reactive UI framework 103 | - [Vite](https://vitejs.dev) — build system 104 | - [@twa-dev/sdk](https://github.com/twa-dev/SDK) — Telegram Web App SDK wrapper and Type Definitions 105 | - [@vueuse/core](https://vueuse.org) — Collection of Essential Vue Composition Utilities 106 | - [normalize.css](https://necolas.github.io/normalize.css/) — makes browsers render all elements more consistently 107 | - [Vue Router](https://router.vuejs.org) — helps handling of navigation 108 | - [vue3-lottie](https://vue3-lottie.vercel.app) — Lottie animations player 109 | 110 | ## Directory structure 111 | 112 | The directory structure introduces the simple variation of [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html). 113 | We separate the application into layers, where each layer has its own responsibility. The layers are: 114 | 115 | - `/presentation` - responsible for the UI. Contains all the UI components, screens, and assets 116 | - `/application` - responsible for the presentation-related business logic. It contains the application services, which are actually Vue Composables. 117 | - `/domain` - responsible for the domain logic. It contains the entities and business rules (domain services) 118 | - `/infra` - responsible for transport, store, and utils 119 | 120 | ``` 121 | client 122 | ├── application 123 | │ ├── services 124 | │ │ ├── ... 125 | │ │ └── vue composables used by presentation layer 126 | │ └── router.ts - vue-router instance 127 | ├── domain 128 | │ ├── entities 129 | │ │ ├── ... 130 | │ │ └── domain entities (things from the real world) 131 | │ └── services 132 | │ ├── ... 133 | │ └── domain services (business rules) 134 | ├── infra 135 | │ ├── store 136 | │ │ ├── ... 137 | │ │ └── storages used by domain layer 138 | │ ├── transport 139 | │ │ ├── ... 140 | │ │ └── transport layer (telebook api, etc) 141 | │ └── utils 142 | │ ├── ... 143 | │ └── utils used by any layer 144 | └── presentation 145 | ├── assets 146 | │ ├── ... 147 | │ └── icons, fonts, lottie, etc 148 | ├── components 149 | │ ├── ... 150 | │ └── Telegram Vue UI Kit 151 | ├── screens 152 | │ ├── ... 153 | │ └── application screens (pages) used by router 154 | └── styles 155 | ├── ... 156 | └── styles used by presentation layer 157 | ``` 158 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Telebook 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "issuegram", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc && vite build", 9 | "preview": "vite preview", 10 | "lint": "eslint ./src --ext .ts,.vue", 11 | "lint:fix": "yarn lint --fix" 12 | }, 13 | "dependencies": { 14 | "@twa-dev/sdk": "^6.9.0", 15 | "@vueuse/core": "^10.4.1", 16 | "normalize.css": "^8.0.1", 17 | "vue": "^3.3.4", 18 | "vue-router": "4", 19 | "vue3-lottie": "^3.1.0" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^20.7.1", 23 | "@typescript-eslint/eslint-plugin": "^6.7.3", 24 | "@typescript-eslint/parser": "^6.7.3", 25 | "@vitejs/plugin-vue": "^4.2.3", 26 | "eslint": "^8.50.0", 27 | "eslint-config-standard-with-typescript": "^39.1.0", 28 | "eslint-import-resolver-typescript": "^3.6.1", 29 | "eslint-plugin-clean-timer": "^1.0.0", 30 | "eslint-plugin-import": "^2.28.1", 31 | "eslint-plugin-listeners": "^1.2.0", 32 | "eslint-plugin-n": "^16.1.0", 33 | "eslint-plugin-promise": "^6.1.1", 34 | "eslint-plugin-vue": "^9.17.0", 35 | "postcss-apply": "^0.12.0", 36 | "postcss-nested": "^6.0.1", 37 | "postcss-preset-env": "^9.1.4", 38 | "sharp": "^0.32.6", 39 | "typescript": "^5.0.2", 40 | "vite": "^4.4.5", 41 | "vue-tsc": "^1.8.5" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | import postcssNested from 'postcss-nested' 2 | import postcssPresetEnv from 'postcss-preset-env' 3 | import postcssApply from 'postcss-apply' 4 | 5 | export default function () { 6 | return { 7 | plugins: [ 8 | postcssNested(), 9 | postcssPresetEnv({ 10 | features: { 11 | 'nesting-rules': false, 12 | 'custom-properties': { 13 | disableDeprecationNotice: true, 14 | }, 15 | }, 16 | }), 17 | postcssApply(), 18 | ], 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/public/pics/_hotel-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/_hotel-1.jpg -------------------------------------------------------------------------------- /client/public/pics/_hotel-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/_hotel-2.jpg -------------------------------------------------------------------------------- /client/public/pics/_room-1-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/_room-1-1.jpg -------------------------------------------------------------------------------- /client/public/pics/_room-1-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/_room-1-2.jpg -------------------------------------------------------------------------------- /client/public/pics/hotel-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/hotel-1.jpg -------------------------------------------------------------------------------- /client/public/pics/hotel-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/hotel-2.jpg -------------------------------------------------------------------------------- /client/public/pics/hotel-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/hotel-3.jpg -------------------------------------------------------------------------------- /client/public/pics/hotel-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/hotel-4.jpg -------------------------------------------------------------------------------- /client/public/pics/hotel-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/hotel-5.jpg -------------------------------------------------------------------------------- /client/public/pics/hotel-6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/hotel-6.jpg -------------------------------------------------------------------------------- /client/public/pics/hotel-7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/hotel-7.jpg -------------------------------------------------------------------------------- /client/public/pics/room-1-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-1-1.jpg -------------------------------------------------------------------------------- /client/public/pics/room-1-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-1-2.jpg -------------------------------------------------------------------------------- /client/public/pics/room-2-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-2-1.jpg -------------------------------------------------------------------------------- /client/public/pics/room-2-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-2-2.jpg -------------------------------------------------------------------------------- /client/public/pics/room-2-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-2-3.jpg -------------------------------------------------------------------------------- /client/public/pics/room-3-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-3-1.jpg -------------------------------------------------------------------------------- /client/public/pics/room-3-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-3-2.jpg -------------------------------------------------------------------------------- /client/public/pics/room-3-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-3-3.jpg -------------------------------------------------------------------------------- /client/public/pics/room-4-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-4-1.jpg -------------------------------------------------------------------------------- /client/public/pics/room-4-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-4-2.jpg -------------------------------------------------------------------------------- /client/public/pics/room-4-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-4-3.jpg -------------------------------------------------------------------------------- /client/public/pics/room-5-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-5-1.jpg -------------------------------------------------------------------------------- /client/public/pics/room-5-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-5-2.jpg -------------------------------------------------------------------------------- /client/public/pics/room-6-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-6-1.jpg -------------------------------------------------------------------------------- /client/public/pics/room-6-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-6-2.jpg -------------------------------------------------------------------------------- /client/public/pics/room-6-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-6-3.jpg -------------------------------------------------------------------------------- /client/public/pics/room-7-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-7-1.jpg -------------------------------------------------------------------------------- /client/public/pics/room-7-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/public/pics/room-7-2.jpg -------------------------------------------------------------------------------- /client/public/telebook.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 43 | 44 | 108 | -------------------------------------------------------------------------------- /client/src/application/router-shims.d.ts: -------------------------------------------------------------------------------- 1 | import 'vue-router' 2 | 3 | /** 4 | * To ensure it is treated as a module, add at least one `export` statement 5 | */ 6 | export {} 7 | 8 | declare module 'vue-router' { 9 | interface RouteMeta { 10 | /** 11 | * Place custom route meta props here 12 | */ 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /client/src/application/router.ts: -------------------------------------------------------------------------------- 1 | import { type RouteRecordRaw, createRouter, createWebHistory } from 'vue-router' 2 | import Home from '@/presentation/screens/Home.vue' 3 | import Hotel from '@/presentation/screens/Hotel.vue' 4 | import Room from '@/presentation/screens/Room.vue' 5 | import Location from '@/presentation/screens/Location.vue' 6 | 7 | const routes: RouteRecordRaw[] = [ 8 | { 9 | path: '/', 10 | component: Home, 11 | }, 12 | { 13 | path: '/location', 14 | component: Location, 15 | }, 16 | { 17 | path: '/hotel/:id', 18 | component: Hotel, 19 | props: route => ({ 20 | id: parseInt(route.params.id as string, 10), 21 | }), 22 | }, 23 | { 24 | path: '/room/:hotelId/:roomId', 25 | component: Room, 26 | props: route => ({ 27 | hotelId: parseInt(route.params.hotelId as string, 10), 28 | roomId: parseInt(route.params.roomId as string, 10), 29 | }), 30 | }, 31 | ] 32 | 33 | const router = createRouter({ 34 | history: createWebHistory(), 35 | routes, 36 | }) 37 | 38 | export default router 39 | -------------------------------------------------------------------------------- /client/src/application/services/README.md: -------------------------------------------------------------------------------- 1 | # Application Service 2 | 3 | Local-level logic. Usually, used by Components. 4 | 5 | Could access `Domain` level to perform some business-logic actions 6 | 7 | ## Example of Domain Service logic 8 | 9 | - Retrieve device information 10 | - Working with animations 11 | - Load icons 12 | -------------------------------------------------------------------------------- /client/src/application/services/index.ts: -------------------------------------------------------------------------------- 1 | import { useScroll } from './useScroll' 2 | import useTelegram from './useTelegram' 3 | import { useLayout } from './useLayout' 4 | import useThumbnail from './useThumbnail' 5 | import useLottie from './useLottie' 6 | 7 | export { 8 | useScroll, 9 | useTelegram, 10 | useLayout, 11 | useThumbnail, 12 | useLottie, 13 | } 14 | -------------------------------------------------------------------------------- /client/src/application/services/useLayout.ts: -------------------------------------------------------------------------------- 1 | import { createSharedComposable } from '@vueuse/core' 2 | import { type Ref, ref } from 'vue' 3 | 4 | /** 5 | * App layout attributes 6 | */ 7 | interface useLayoutComposableState { 8 | /** 9 | * Visible application width 10 | */ 11 | appWidth: Ref; 12 | } 13 | 14 | /** 15 | * Service for for working with layout 16 | */ 17 | export const useLayout = createSharedComposable((): useLayoutComposableState => { 18 | const appWidth = ref(0) 19 | 20 | if (appWidth.value === 0) { 21 | appWidth.value = document.getElementById('app')?.offsetWidth ?? 0 22 | } 23 | 24 | return { 25 | appWidth, 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /client/src/application/services/useLottie.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, shallowRef, type ShallowRef } from 'vue' 2 | 3 | interface useLottieComposableState { 4 | /** 5 | * Prepare lottie data 6 | */ 7 | animationData: ShallowRef; 8 | } 9 | 10 | /** 11 | * Preloads lottie animation 12 | * 13 | * @param name - animation name 14 | */ 15 | export default function useLottie(name: 'simp' | 'eyes'): useLottieComposableState { 16 | /** 17 | * Lottie animation data 18 | */ 19 | const animationData = shallowRef(null) 20 | 21 | onMounted(() => { 22 | void import(`../../presentation/assets/lottie/${name}.json`) 23 | .then(({ default: data }) => { 24 | animationData.value = data 25 | }) 26 | }) 27 | 28 | return { 29 | animationData, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/application/services/useScroll.ts: -------------------------------------------------------------------------------- 1 | interface useScrollComposableState { 2 | /** 3 | * Lock the scroll 4 | */ 5 | lock: () => void; 6 | 7 | /** 8 | * Unlock the scroll 9 | */ 10 | unlock: () => void; 11 | 12 | /** 13 | * Scroll to element 14 | */ 15 | scrollTo: (el: HTMLElement, offset?: number) => void; 16 | } 17 | 18 | /** 19 | * Methods for working with the scroll 20 | */ 21 | export const useScroll = (): useScrollComposableState => { 22 | /** 23 | * Lock the scroll 24 | */ 25 | function lock(): void { 26 | document.body.style.overflow = 'hidden' 27 | } 28 | 29 | /** 30 | * Unlock the scroll 31 | */ 32 | function unlock(): void { 33 | document.body.style.overflow = 'unset' 34 | } 35 | 36 | /** 37 | * Scroll to element 38 | * 39 | * @param el The element to scroll to 40 | * @param offset The offset to scroll to 41 | */ 42 | function scrollTo(el: HTMLElement, offset = 0): void { 43 | window.scrollTo({ 44 | top: el.offsetTop - offset, 45 | behavior: 'smooth', 46 | }) 47 | } 48 | 49 | return { 50 | lock, 51 | unlock, 52 | scrollTo, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /client/src/application/services/useTelegram.ts: -------------------------------------------------------------------------------- 1 | import WebApp from '@twa-dev/sdk' 2 | import { type Ref, ref } from 'vue' 3 | 4 | interface useTelegramComposableState { 5 | showMainButton: (text: string, callback: () => void) => void; 6 | hideMainButton: () => void; 7 | showBackButton: (callback: () => void) => void; 8 | hideBackButton: () => void; 9 | setButtonLoader: (state: boolean) => void; 10 | showAlert: (text: string) => void; 11 | openInvoice: (url: string, callback: (status: 'pending' | 'failed' | 'cancelled' | 'paid') => void) => void; 12 | closeApp: () => void; 13 | expand: () => void; 14 | getViewportHeight: () => number; 15 | vibrate: (style?: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft' | 'error' | 'warning' | 'success') => void; 16 | ready: () => void; 17 | colorScheme: 'light' | 'dark' | undefined; 18 | platform: 'android' | 'android_x' | 'ios' | 'macos' | 'tdesktop' | 'web' | 'weba' | 'webk' | 'unigram' | 'unknown'; 19 | headerColor: string; 20 | setHeaderColor: (color: 'bg_color' | 'secondary_bg_color' | `#${string}`) => void; 21 | } 22 | 23 | /** 24 | * Composable to work with Telegram using the Mini Apps SDK 25 | * 26 | * @see https://core.telegram.org/bots/webapps 27 | */ 28 | export default function useTelegram(): useTelegramComposableState { 29 | /** 30 | * We store current MainButton callback to be able to remove it later 31 | */ 32 | const mainButtonCallback = ref<(() => void) | null>(null) 33 | 34 | /** 35 | * We store current BackButton callback to be able to remove it later 36 | */ 37 | const backButtonCallback = ref<(() => void) | null>(null) 38 | 39 | const debugMainButton = ref() 40 | const debugBackButton = ref() 41 | 42 | /** 43 | * When we debug the app in the browser, we need to create a fake main button 44 | * 45 | * @param reference The reference to store the button 46 | * @param className The class name to add to the button 47 | */ 48 | function prepareDebugButton(reference: Ref, className: string): void { 49 | if (WebApp.platform !== 'unknown') { 50 | return 51 | } 52 | 53 | if (reference.value !== undefined) { 54 | return 55 | } 56 | 57 | const button = document.createElement('button') 58 | 59 | button.classList.add(className) 60 | document.body.appendChild(button) 61 | reference.value = button 62 | } 63 | 64 | /** 65 | * Show the main button 66 | * 67 | * @param text The text to show on the button 68 | * @param callback The callback to call when the button is clicked 69 | */ 70 | function showMainButton(text: string, callback: () => void): void { 71 | prepareDebugButton(debugMainButton, 'fake-main-button') 72 | 73 | if (mainButtonCallback.value !== null) { 74 | WebApp.MainButton.offClick(mainButtonCallback.value) 75 | } 76 | 77 | mainButtonCallback.value = callback 78 | 79 | WebApp.MainButton.text = text ?? 'Submit' 80 | WebApp.MainButton.onClick(mainButtonCallback.value) 81 | WebApp.MainButton.isVisible = true 82 | 83 | if (debugMainButton.value !== undefined) { 84 | debugMainButton.value.innerText = text ?? 'Submit' 85 | debugMainButton.value.addEventListener('click', mainButtonCallback.value) 86 | debugMainButton.value.classList.add('visible') 87 | } 88 | } 89 | 90 | /** 91 | * Hide the main button 92 | */ 93 | function hideMainButton(): void { 94 | if (mainButtonCallback.value === null) { 95 | console.warn('Trying to hide main button but no callback was set') 96 | return 97 | } 98 | 99 | WebApp.MainButton.offClick(mainButtonCallback.value) 100 | debugMainButton.value?.removeEventListener('click', mainButtonCallback.value) 101 | mainButtonCallback.value = null 102 | 103 | WebApp.MainButton.isVisible = false 104 | debugMainButton.value?.classList.remove('visible') 105 | } 106 | 107 | /** 108 | * Show the back button 109 | */ 110 | function showBackButton(callback: () => void): void { 111 | prepareDebugButton(debugBackButton, 'fake-back-button') 112 | 113 | if (backButtonCallback.value !== null) { 114 | WebApp.BackButton.offClick(backButtonCallback.value) 115 | } 116 | 117 | backButtonCallback.value = callback 118 | 119 | WebApp.BackButton.onClick(backButtonCallback.value) 120 | WebApp.BackButton.show() 121 | 122 | if (debugBackButton.value !== undefined) { 123 | debugBackButton.value.innerText = '‹ Back' 124 | debugBackButton.value.addEventListener('click', backButtonCallback.value) 125 | debugBackButton.value.classList.add('visible') 126 | } 127 | } 128 | 129 | /** 130 | * Hide the back button 131 | */ 132 | function hideBackButton(): void { 133 | if (backButtonCallback.value === null) { 134 | console.warn('Trying to hide back button but no callback was set') 135 | return 136 | } 137 | 138 | WebApp.BackButton.offClick(backButtonCallback.value) 139 | debugBackButton.value?.removeEventListener('click', backButtonCallback.value) 140 | backButtonCallback.value = null 141 | 142 | WebApp.BackButton.hide() 143 | debugBackButton.value?.classList.remove('visible') 144 | } 145 | 146 | /** 147 | * Show/hide the main button loader 148 | * 149 | * @param state The state to set the loader to 150 | */ 151 | function setButtonLoader(state: boolean): void { 152 | if (state) { 153 | WebApp.MainButton.showProgress() 154 | } else { 155 | WebApp.MainButton.hideProgress() 156 | } 157 | } 158 | 159 | /** 160 | * Shows native Telegram alert message 161 | * 162 | * @param text The text to show in the alert 163 | */ 164 | function showAlert(text: string): void { 165 | WebApp.showAlert(text) 166 | } 167 | 168 | /** 169 | * Opens Telegram invoice 170 | * 171 | * @param url The invoice URL 172 | * @param callback The callback to call when the invoice is paid 173 | */ 174 | function openInvoice(url: string, callback: (status: 'pending' | 'failed' | 'cancelled' | 'paid') => void): void { 175 | WebApp.openInvoice(url, callback) 176 | } 177 | 178 | /** 179 | * Closes the app 180 | */ 181 | function closeApp(): void { 182 | WebApp.close() 183 | } 184 | 185 | /** 186 | * Expands Telegram app layout 187 | */ 188 | function expand(): void { 189 | WebApp.expand() 190 | } 191 | 192 | /** 193 | * 194 | * The current height of the visible area of the Mini App. Also available in CSS as the variable var(--tg-viewport-height). 195 | * The application can display just the top part of the Mini App, with its lower part remaining outside the screen area. 196 | * From this position, the user can “pull” the Mini App to its maximum height, while the bot can do the same by calling the expand() method. 197 | * As the position of the Mini App changes, the current height value of the visible area will be updated in real time. 198 | * Please note that the refresh rate of this value is not sufficient to smoothly follow the lower border of the window. 199 | * It should not be used to pin interface elements to the bottom of the visible area. 200 | * It's more appropriate to use the value of the viewportStableHeight field for this purpose. 201 | */ 202 | function getViewportHeight(): number { 203 | return WebApp.viewportStableHeight 204 | } 205 | 206 | /** 207 | * Vibrate the device 208 | * 209 | * @param style The style of the vibration 210 | */ 211 | function vibrate(style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft' | 'error' | 'warning' | 'success' = 'heavy'): void { 212 | switch (style) { 213 | case 'light': 214 | case 'medium': 215 | case 'heavy': 216 | case 'rigid': 217 | case 'soft': 218 | WebApp.HapticFeedback.impactOccurred(style) 219 | break 220 | case 'error': 221 | case 'warning': 222 | case 'success': 223 | WebApp.HapticFeedback.notificationOccurred(style) 224 | break 225 | } 226 | } 227 | 228 | /** 229 | * Tells Telegram 230 | */ 231 | function ready(): void { 232 | WebApp.ready() 233 | } 234 | 235 | /** 236 | * Sets the header color of the app wrapper 237 | */ 238 | function setHeaderColor(color: 'bg_color' | 'secondary_bg_color' | `#${string}`): void { 239 | WebApp.setHeaderColor(color) 240 | } 241 | 242 | /** 243 | * The current color scheme of the device. Can be light or dark. 244 | * If app is launched in a browser, the value will be undefined. 245 | */ 246 | const colorScheme = WebApp.platform !== 'unknown' ? WebApp.colorScheme : undefined 247 | 248 | /** 249 | * The current platform of the device. 250 | */ 251 | const platform = WebApp.platform 252 | 253 | /** 254 | * The current header color of the app wrapper 255 | */ 256 | const headerColor = WebApp.headerColor 257 | 258 | return { 259 | showMainButton, 260 | hideMainButton, 261 | setButtonLoader, 262 | showAlert, 263 | openInvoice, 264 | closeApp, 265 | expand, 266 | getViewportHeight, 267 | showBackButton, 268 | hideBackButton, 269 | vibrate, 270 | ready, 271 | colorScheme, 272 | platform, 273 | headerColor, 274 | setHeaderColor, 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /client/src/application/services/useThumbnail.ts: -------------------------------------------------------------------------------- 1 | import { imageToBase64 } from '@/infra/utils/dom' 2 | import { type Ref, onMounted, ref } from 'vue' 3 | import ImageCache from '@/infra/store/thumbs/image.cache' 4 | 5 | interface useThumbnailComposableState { 6 | /** 7 | * Loading state of the picture. 8 | */ 9 | isPictureLoaded: Ref; 10 | 11 | /** 12 | * Picture data in base64. 13 | */ 14 | pictureUrl: Ref; 15 | } 16 | 17 | /** 18 | * Application service to return picture data: 19 | * - thumbnail when picture is not loaded yet, 20 | * - picture when picture is loaded. 21 | * 22 | * @param src picture url 23 | * @param thumbData thumbnail data in base64 24 | */ 25 | export default function useThumbnail(src: string, thumbData: string): useThumbnailComposableState { 26 | /** 27 | * Get cached data if exists. 28 | */ 29 | const cachedData = ImageCache.get(src) 30 | 31 | if (cachedData !== undefined) { 32 | return { 33 | pictureUrl: ref(cachedData), 34 | isPictureLoaded: ref(true), 35 | } 36 | } 37 | 38 | /** 39 | * Loading state of the picture. 40 | */ 41 | const isPictureLoaded = ref(false) 42 | 43 | /** 44 | * Picture data in base64. 45 | */ 46 | const data = ref(`data:image/png;base64,${thumbData}`) 47 | 48 | onMounted(() => { 49 | void imageToBase64(src) 50 | .then(base64 => { 51 | data.value = base64 52 | isPictureLoaded.value = true 53 | 54 | ImageCache.set(src, base64) 55 | }) 56 | }) 57 | 58 | return { 59 | pictureUrl: data, 60 | isPictureLoaded, 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/src/domain/entities/Award.ts: -------------------------------------------------------------------------------- 1 | export default interface Award { 2 | name: string; 3 | section: string; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/domain/entities/Chart.ts: -------------------------------------------------------------------------------- 1 | export default interface Chart { 2 | place: number; 3 | category: string; 4 | }; 5 | -------------------------------------------------------------------------------- /client/src/domain/entities/City.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Describes a trip target (City) 3 | */ 4 | export default interface City { 5 | /** 6 | * Identifier 7 | */ 8 | id: number; 9 | 10 | /** 11 | * Visible title 12 | */ 13 | title: string; 14 | 15 | /** 16 | * Just picture of a country flag 17 | */ 18 | emoji: string; 19 | 20 | /** 21 | * Country name 22 | */ 23 | country: string; 24 | } 25 | -------------------------------------------------------------------------------- /client/src/domain/entities/EntityPicture.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents data to render some entity picture. 3 | */ 4 | export default interface EntityPicture { 5 | /** 6 | * Unique identifier of the entity 7 | */ 8 | id?: number; 9 | /** 10 | * Picture url. 11 | */ 12 | src?: string; 13 | 14 | /** 15 | * Entity name to use as placeholder when entity picture is not defined 16 | */ 17 | placeholder?: string; 18 | 19 | /** 20 | * Picture thumbnail base64 url 21 | */ 22 | pictureThumb?: string; 23 | } 24 | 25 | -------------------------------------------------------------------------------- /client/src/domain/entities/Hotel.ts: -------------------------------------------------------------------------------- 1 | import type { Award, Room, Chart, Rating } from '.' 2 | 3 | export default interface Hotel { 4 | id: number; 5 | title: string; 6 | subtitle: string; 7 | description: string; 8 | address: string; 9 | price: number; 10 | picture: string; 11 | pictureThumb?: string; 12 | ratingsCount: number; 13 | rating: Rating; 14 | award: Award; 15 | chart: Chart; 16 | rooms: Room[]; 17 | } 18 | -------------------------------------------------------------------------------- /client/src/domain/entities/LabeledPrice.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This object represents a portion of the price for goods or services. 3 | * 4 | * @see https://core.telegram.org/bots/api#labeledprice 5 | */ 6 | export default interface LabeledPrice { 7 | /** 8 | * Portion label 9 | */ 10 | label: string; 11 | 12 | /** 13 | * Price of the product in the smallest units of the currency (integer, not float/double). 14 | * For example, for a price of US$ 1.45 pass amount = 145. 15 | * See the exp parameter in currencies.json, it shows the number of digits past the decimal point for each currency 16 | * (2 for the majority of currencies). 17 | */ 18 | amount: number; 19 | 20 | } 21 | -------------------------------------------------------------------------------- /client/src/domain/entities/Rating.ts: -------------------------------------------------------------------------------- 1 | export default interface Rating { 2 | votesCount: string; 3 | rating: number; 4 | stars: number; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/domain/entities/Room.ts: -------------------------------------------------------------------------------- 1 | export default interface Room { 2 | id: number; 3 | title: string; 4 | subtitle: string; 5 | price: number; 6 | picture: string; 7 | pictureThumb?: string; 8 | } 9 | -------------------------------------------------------------------------------- /client/src/domain/entities/TripDetails.ts: -------------------------------------------------------------------------------- 1 | import type City from './City' 2 | 3 | /** 4 | * Structure describing information of the trip 5 | */ 6 | export default interface TripDetails { 7 | /** 8 | * The date of the trip 9 | */ 10 | startDate: Date; 11 | 12 | /** 13 | * The end date of the trip 14 | */ 15 | endDate: Date; 16 | 17 | /** 18 | * Idd of the location of the trip 19 | */ 20 | city: City['id']; 21 | 22 | /** 23 | * Selected hotel id 24 | */ 25 | hotel: number; 26 | 27 | /** 28 | * The room in the selected hotel 29 | */ 30 | room: number; 31 | } 32 | -------------------------------------------------------------------------------- /client/src/domain/entities/errors/Base.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Domain Error — own class describing exceptions in our business-logic. 3 | * For example, when some resource is not found, or user is not authorized. 4 | */ 5 | export default class DomainError extends Error {} 6 | -------------------------------------------------------------------------------- /client/src/domain/entities/errors/NotFound.ts: -------------------------------------------------------------------------------- 1 | import DomainError from './Base' 2 | 3 | /** 4 | * Domain error thrown when some resource is not found 5 | */ 6 | export default class NotFoundError extends DomainError { 7 | /** 8 | * Constructor for NotFound error 9 | * 10 | * @param message - Error message 11 | */ 12 | constructor(message: string = 'NotFound') { 13 | super(message) 14 | this.name = 'NotFoundError' 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/domain/entities/errors/Unauthorized.ts: -------------------------------------------------------------------------------- 1 | import DomainError from './Base' 2 | 3 | /** 4 | * Domain error thrown when user is not authorized to perform some action 5 | */ 6 | export default class UnauthorizedError extends DomainError { 7 | /** 8 | * Constructor for unauthorized error 9 | * 10 | * @param message - Error message 11 | */ 12 | constructor(message: string = 'Unauthorized') { 13 | super(message) 14 | this.name = 'UnauthorizedError' 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/domain/entities/index.ts: -------------------------------------------------------------------------------- 1 | import type Hotel from './Hotel' 2 | import type Room from './Room' 3 | import type Award from './Award' 4 | import type Chart from './Chart' 5 | import type City from './City' 6 | import type LabeledPrice from './LabeledPrice' 7 | import type Rating from './Rating' 8 | import type TripDetails from './TripDetails' 9 | 10 | export type { 11 | Hotel, 12 | Room, 13 | Award, 14 | Chart, 15 | City, 16 | LabeledPrice, 17 | Rating, 18 | TripDetails, 19 | } 20 | -------------------------------------------------------------------------------- /client/src/domain/services/README.md: -------------------------------------------------------------------------------- 1 | # Domain Service 2 | 3 | Contains pure business logic. For example, some actions with `Entities` 4 | 5 | Could access `Infrastructure` level for transport / store purposes. 6 | 7 | ## Example of Domain Service logic 8 | 9 | - Save trip details 10 | - Retrieve cities list from API 11 | - Prepare Invoice 12 | -------------------------------------------------------------------------------- /client/src/domain/services/index.ts: -------------------------------------------------------------------------------- 1 | import { useCities } from './useCities' 2 | import { useHotel } from './useHotel' 3 | import useInvoice from './useInvoice' 4 | import { useTripDetails } from './useTripDetails' 5 | 6 | export { 7 | useCities, 8 | useHotel, 9 | useInvoice, 10 | useTripDetails, 11 | } 12 | -------------------------------------------------------------------------------- /client/src/domain/services/useCities.ts: -------------------------------------------------------------------------------- 1 | import { ref, type Ref } from 'vue' 2 | import type Location from '../entities/City' 3 | import { createSharedComposable } from '@vueuse/core' 4 | import { getCitiesAvailable } from '@/infra/store/cities' 5 | 6 | interface useCitiesComposableState { 7 | /** 8 | * Cities available as locations 9 | */ 10 | cities: Ref; 11 | } 12 | 13 | /** 14 | * Composable to work with cities 15 | */ 16 | export const useCities = createSharedComposable((): useCitiesComposableState => { 17 | const cities = ref(getCitiesAvailable()) 18 | 19 | return { 20 | cities, 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /client/src/domain/services/useHotel.ts: -------------------------------------------------------------------------------- 1 | import { hotels } from '@/infra/store/hotels/mock/hotels' 2 | import { type ComputedRef, computed, type MaybeRefOrGetter, unref } from 'vue' 3 | import type Hotel from '../entities/Hotel' 4 | 5 | interface useHotelComposableState { 6 | hotel: ComputedRef; 7 | } 8 | 9 | export function useHotel(id: MaybeRefOrGetter): useHotelComposableState { 10 | const hotelId = unref(id) 11 | 12 | if (hotelId === undefined) { 13 | throw new Error('Hotel ID is not defined') 14 | } 15 | 16 | const hotel = computed(() => { 17 | return hotels.find((hotel) => hotel.id === hotelId) 18 | }) 19 | 20 | return { 21 | hotel, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/domain/services/useInvoice.ts: -------------------------------------------------------------------------------- 1 | import type LabeledPrice from '@/domain/entities/LabeledPrice' 2 | import Transport from '@/infra/transport/api' 3 | 4 | interface useInvoiceComposableState { 5 | create: (params: CreateInvoiceParams) => Promise; 6 | toPrice: (usdAmount: number) => number; 7 | } 8 | 9 | /** 10 | * Params for creating a new invoice 11 | * 12 | * @see https://core.telegram.org/bots/api#createinvoicelink 13 | */ 14 | interface CreateInvoiceParams { 15 | /** 16 | * Product name, 1-32 characters 17 | */ 18 | title: string; 19 | 20 | /** 21 | * Product description, 1-255 characters 22 | */ 23 | description: string; 24 | 25 | /** 26 | * Three-letter ISO 4217 currency code, see more on currencies 27 | * 28 | * @see https://core.telegram.org/bots/payments#supported-currencies 29 | */ 30 | currency: string; 31 | 32 | /** 33 | * Price breakdown, a JSON-serialized list of components (e.g. product price, tax, discount, delivery cost, delivery tax, bonus, etc.) 34 | */ 35 | prices: LabeledPrice[]; 36 | 37 | /** 38 | * JSON-serialized data about the invoice, which will be shared with the payment provider. A detailed description of required fields should be provided by the payment provider. 39 | */ 40 | provider_data?: string; 41 | 42 | /** 43 | * URL of the product photo for the invoice. Can be a photo of the goods or a marketing image for a service. People like it better when they see what they are paying for. 44 | */ 45 | photo_url?: string; 46 | 47 | /** 48 | * Photo size 49 | */ 50 | photo_size?: number; 51 | 52 | /** 53 | * Photo width 54 | */ 55 | photo_width?: number; 56 | 57 | /** 58 | * Photo height 59 | */ 60 | photo_height?: number; 61 | 62 | /** 63 | * Pass True, if you require the user's full name to complete the order 64 | */ 65 | need_name?: boolean; 66 | 67 | /** 68 | * Pass True, if you require the user's phone number to complete the order 69 | */ 70 | need_phone_number?: boolean; 71 | 72 | /** 73 | * Pass True, if you require the user's email address to complete the order 74 | */ 75 | need_email?: boolean; 76 | 77 | /** 78 | * Pass True, if you require the user's shipping address to complete the order 79 | */ 80 | need_shipping_address?: boolean; 81 | 82 | /** 83 | * Pass True, if user's phone number should be sent to provider 84 | */ 85 | send_phone_number_to_provider?: boolean; 86 | 87 | /** 88 | * Pass True, if user's email address should be sent to provider 89 | */ 90 | send_email_to_provider?: boolean; 91 | 92 | /** 93 | * Pass True, if the final price depends on the shipping method 94 | */ 95 | is_flexible?: boolean; 96 | } 97 | 98 | /** 99 | * Service to create a new invoice 100 | */ 101 | export default function useInvoice(): useInvoiceComposableState { 102 | const url = import.meta.env.VITE_API_HOST 103 | 104 | /** 105 | * @todo use IoC container 106 | */ 107 | const transport = new Transport(url) 108 | 109 | /** 110 | * Create a new invoice 111 | * 112 | * @param params - Params for creating a new invoice 113 | */ 114 | const create = async (params: CreateInvoiceParams): Promise => { 115 | try { 116 | const response = await transport.post('/createInvoice', params) 117 | 118 | return (response as { invoiceLink: string }).invoiceLink 119 | } catch (e) { 120 | return null 121 | } 122 | } 123 | 124 | /** 125 | * Convert USD amount to price in cents 126 | * 127 | * @param usdAmount - USD amount 128 | */ 129 | function toPrice(usdAmount: number): number { 130 | return Math.round(usdAmount * 100) 131 | } 132 | 133 | return { 134 | create, 135 | toPrice, 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /client/src/domain/services/useTripDetails.ts: -------------------------------------------------------------------------------- 1 | import type TripDetails from '@/domain/entities/TripDetails' 2 | import { createSharedComposable } from '@vueuse/core' 3 | import { computed, type ComputedRef, reactive, type UnwrapNestedRefs } from 'vue' 4 | import { useCities } from './useCities' 5 | import type City from '../entities/City' 6 | 7 | /** 8 | * Attributes of the useTripDetails composable 9 | */ 10 | interface useTripDetailsComposableState { 11 | /** 12 | * Selects the start date of the trip 13 | * 14 | * @param date - The date of the trip 15 | */ 16 | setStartDate: (date: TripDetails['startDate']) => void; 17 | 18 | /** 19 | * Selects the end date of the trip 20 | * 21 | * @param date - The end date of the trip 22 | */ 23 | setEndDate: (date: TripDetails['endDate']) => void; 24 | 25 | /** 26 | * Selects the location of the trip 27 | * 28 | * @param id - Id of the location of the trip 29 | */ 30 | setCity: (id: TripDetails['city']) => void; 31 | 32 | /** 33 | * Selects the hotel of the trip 34 | * 35 | * @param hotel - Id of the hotel of the trip 36 | */ 37 | setHotel: (hotel: TripDetails['hotel']) => void; 38 | 39 | /** 40 | * Selects the room of the trip 41 | * 42 | * @param room - Id of the room of the trip 43 | */ 44 | setRoom: (room: TripDetails['room']) => void; 45 | 46 | /** 47 | * Information about current trip 48 | */ 49 | trip: UnwrapNestedRefs; 50 | 51 | /** 52 | * Location based on trip details 53 | */ 54 | location: ComputedRef; 55 | 56 | /** 57 | * Number of days of the trip 58 | */ 59 | days: ComputedRef; 60 | 61 | /** 62 | * Selects default trip details: 63 | * - current date as start date 64 | * - current date + 1 day as end date 65 | * - first location as location 66 | */ 67 | selectDefault: () => void; 68 | } 69 | 70 | /** 71 | * Information about current trip 72 | */ 73 | const trip = reactive({ 74 | startDate: new Date(), 75 | endDate: new Date(), 76 | city: 0, 77 | hotel: 0, 78 | room: 0, 79 | }) 80 | 81 | /** 82 | * Composable to handle trip details 83 | */ 84 | export const useTripDetails = createSharedComposable((): useTripDetailsComposableState => { 85 | const { cities } = useCities() 86 | 87 | /** 88 | * Selects the start date of the trip 89 | * 90 | * @param date - The date of the trip 91 | */ 92 | function setStartDate(date: TripDetails['startDate']): void { 93 | trip.startDate = date 94 | } 95 | 96 | /** 97 | * Selects the end date of the trip 98 | * 99 | * @param date - The end date of the trip 100 | */ 101 | function setEndDate(date: TripDetails['endDate']): void { 102 | trip.endDate = date 103 | } 104 | 105 | /** 106 | * Selects the location of the trip 107 | * 108 | * @param cityId - The location of the trip 109 | */ 110 | function setCity(cityId: TripDetails['city']): void { 111 | trip.city = cityId 112 | } 113 | 114 | /** 115 | * Selects the hotel of the trip 116 | * 117 | * @param hotel - The hotel of the trip 118 | */ 119 | function setHotel(hotel: TripDetails['hotel']): void { 120 | trip.hotel = hotel 121 | } 122 | 123 | /** 124 | * Selects the room of the trip 125 | * 126 | * @param room - The room of the trip 127 | */ 128 | function setRoom(room: TripDetails['room']): void { 129 | trip.room = room 130 | } 131 | 132 | /** 133 | * Currently selected location based on trip details 134 | */ 135 | const location = computed(() => { 136 | return cities.value.find((city) => city.id === trip.city) 137 | }) 138 | 139 | /** 140 | * Number of days of the trip 141 | */ 142 | const days = computed(() => { 143 | const diffTime = Math.abs(trip.endDate.getTime() - trip.startDate.getTime()) 144 | return Math.max(Math.ceil(diffTime / (1000 * 60 * 60 * 24)), 1) 145 | }) 146 | 147 | /** 148 | * Selects default trip details: 149 | * - current date as start date 150 | * - current date + 1 day as end date 151 | * - first location as location 152 | */ 153 | function selectDefault(): void { 154 | const today = new Date() 155 | const tomorrow = new Date(today) 156 | 157 | tomorrow.setDate(tomorrow.getDate() + 1) 158 | 159 | setStartDate(today) 160 | setEndDate(tomorrow) 161 | 162 | if (cities.value.length > 0) { 163 | setCity(cities.value[0].id) 164 | } 165 | } 166 | 167 | return { 168 | setStartDate, 169 | setEndDate, 170 | setCity, 171 | setHotel, 172 | setRoom, 173 | trip, 174 | location, 175 | selectDefault, 176 | days 177 | } 178 | }) 179 | -------------------------------------------------------------------------------- /client/src/infra/store/cities/index.ts: -------------------------------------------------------------------------------- 1 | import type City from '@/domain/entities/City.js' 2 | 3 | /** 4 | * Cities Store simple example 5 | * --------------------------- 6 | */ 7 | 8 | /** 9 | * Cities available as cities (Mocked data) 10 | */ 11 | const citiesAvailable: City[] = [] 12 | 13 | /** 14 | * Get cities available as Cities 15 | */ 16 | export function getCitiesAvailable(): City[] { 17 | return citiesAvailable 18 | } 19 | 20 | /** 21 | * Get city by id 22 | * 23 | * @param id - City id 24 | */ 25 | export function getCityById(id: City['id']): City | undefined { 26 | return citiesAvailable.find((city) => city.id === id) 27 | } 28 | 29 | /** 30 | * Example of loading data. 31 | * In real app data should be loaded from API in Domain layer using Transport 32 | */ 33 | export async function loadCities(): Promise { 34 | const cities = (await import('./mock/cities.js')).citiesAvailable 35 | 36 | citiesAvailable.push(...cities) 37 | 38 | return cities 39 | } 40 | -------------------------------------------------------------------------------- /client/src/infra/store/cities/mock/cities.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Cities available as locations (Mocked data) 3 | */ 4 | export const citiesAvailable = [ 5 | { 6 | id: 1, 7 | title: 'London', 8 | emoji: '🇬🇧', 9 | country: 'United Kingdom', 10 | }, 11 | { 12 | id: 2, 13 | title: 'Paris', 14 | emoji: '🇫🇷', 15 | country: 'France', 16 | }, 17 | { 18 | id: 3, 19 | title: 'Berlin', 20 | emoji: '🇩🇪', 21 | country: 'Germany', 22 | }, 23 | { 24 | id: 4, 25 | title: 'Rome', 26 | emoji: '🇮🇹', 27 | country: 'Italy', 28 | }, 29 | { 30 | id: 5, 31 | title: 'Madrid', 32 | emoji: '🇪🇸', 33 | country: 'Spain', 34 | }, 35 | { 36 | id: 6, 37 | title: 'Moscow', 38 | emoji: '🇷🇺', 39 | country: 'Russia', 40 | }, 41 | { 42 | id: 7, 43 | title: 'Tokyo', 44 | emoji: '🇯🇵', 45 | country: 'Japan', 46 | }, 47 | { 48 | id: 8, 49 | title: 'Beijing', 50 | emoji: '🇨🇳', 51 | country: 'China', 52 | }, 53 | { 54 | id: 9, 55 | title: 'New Delhi', 56 | emoji: '🇮🇳', 57 | country: 'India', 58 | }, 59 | { 60 | id: 10, 61 | title: 'Cairo', 62 | emoji: '🇪🇬', 63 | country: 'Egypt', 64 | }, 65 | { 66 | id: 11, 67 | title: 'Brasília', 68 | emoji: '🇧🇷', 69 | country: 'Brazil', 70 | }, 71 | { 72 | id: 12, 73 | title: 'Ottawa', 74 | emoji: '🇨🇦', 75 | country: 'Canada', 76 | }, 77 | { 78 | id: 13, 79 | title: 'Canberra', 80 | emoji: '🇦🇺', 81 | country: 'Australia', 82 | }, 83 | { 84 | id: 14, 85 | title: 'Wellington', 86 | emoji: '🇳🇿', 87 | country: 'New Zealand', 88 | }, 89 | { 90 | id: 15, 91 | title: 'Washington, D.C.', 92 | emoji: '🇺🇸', 93 | country: 'United States', 94 | }, 95 | ] 96 | -------------------------------------------------------------------------------- /client/src/infra/store/hotels/mock/amenities.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Mocked room amenities 3 | */ 4 | export const amenities = [ 5 | { 6 | icon: 'square-filled-wifi', 7 | name: 'Free Wi-Fi', 8 | }, 9 | { 10 | icon: 'square-filled-bed', 11 | name: 'King size bed', 12 | }, 13 | { 14 | icon: 'square-filled-air', 15 | name: 'Air Conditioner', 16 | }, 17 | { 18 | icon: 'square-filled-parking', 19 | name: 'Free parking', 20 | }, 21 | { 22 | icon: 'square-filled-safe', 23 | name: 'Safety deposit box', 24 | }, 25 | { 26 | icon: 'square-filled-sport', 27 | name: 'Sport facilities', 28 | }, 29 | ] 30 | -------------------------------------------------------------------------------- /client/src/infra/store/reviews/mock/reviews.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Hotel reviews mock 3 | */ 4 | export const reviews = [ 5 | { 6 | name: 'Blazing Bear', 7 | rating: 5, 8 | text: 'Great hotel, great service, great location', 9 | }, 10 | { 11 | name: 'Crazy Horse', 12 | rating: 4.5, 13 | text: 'Very nice hotel, but the food could be better', 14 | }, 15 | { 16 | name: 'Ugly Parrot', 17 | rating: 3, 18 | text: 'Not bad, but not good either', 19 | }, 20 | { 21 | name: 'Grey Wolf', 22 | rating: 5, 23 | text: 'Nothing to complain about. Everything was perfect', 24 | }, 25 | { 26 | name: 'Clever Fox', 27 | rating: 4.9, 28 | text: 'I would definitely recommend this hotel to my friends', 29 | }, 30 | ] 31 | -------------------------------------------------------------------------------- /client/src/infra/store/thumbs/image.cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This class is used to cache loaded images data 3 | * It represents a Stack: keys are image urls, values are image data 4 | */ 5 | class ImageCache { 6 | /** 7 | * Cache data 8 | */ 9 | private readonly cache = new Map() 10 | 11 | /** 12 | * @param maxSize — Max size of the cache. If cache is full, first element will be removed 13 | */ 14 | constructor(private readonly maxSize = 100) {} 15 | 16 | /** 17 | * Get image data from cache 18 | * @param url image url 19 | */ 20 | public get(url: string): string | undefined { 21 | return this.cache.get(url) 22 | } 23 | 24 | /** 25 | * Set image data in cache 26 | * @param url image url 27 | * @param data image data 28 | */ 29 | public set(url: string, data: string): void { 30 | /** 31 | * Check if image already exists in cache 32 | */ 33 | if (this.cache.has(url)) { 34 | return 35 | } 36 | 37 | /** 38 | * If cache is full, remove first element 39 | */ 40 | if (this.cache.size >= this.maxSize) { 41 | const firstKey = this.cache.keys().next().value 42 | 43 | this.cache.delete(firstKey) 44 | } 45 | 46 | /** 47 | * Add new image data to cache 48 | */ 49 | this.cache.set(url, data) 50 | } 51 | } 52 | 53 | export default new ImageCache(15) 54 | -------------------------------------------------------------------------------- /client/src/infra/transport/api/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-magic-numbers */ 2 | import type { ApiErrorResponse } from './types/response.ts' 3 | import NotFoundError from '@/domain/entities/errors/NotFound.ts' 4 | import UnauthorizedError from '@/domain/entities/errors/Unauthorized.ts' 5 | import FetchTransport from '../fetch/index.ts' 6 | 7 | /** 8 | * Api transport — wrapper around FetchTransport working with our API formats 9 | */ 10 | export default class ApiTransport extends FetchTransport { 11 | /** 12 | * Constructor for api transport 13 | * 14 | * @param baseUrl - Base URL 15 | */ 16 | constructor(baseUrl: string) { 17 | super(baseUrl, { 18 | /** 19 | * Method for creating an Error based on API response 20 | * 21 | * @param status - HTTP status 22 | * @param payload - Response JSON payload 23 | * @param endpoint - API endpoint we requested 24 | */ 25 | errorFormatter(status, payload) { 26 | const { message, code } = (payload as ApiErrorResponse) 27 | 28 | let errorText = '' 29 | 30 | /** 31 | * If 'code' is provided, use it as an error text so we can show it to the user using corresponded i18n message 32 | */ 33 | if (code !== undefined) { 34 | errorText = code.toString() 35 | } else if (message !== undefined) { 36 | errorText = message 37 | } else { 38 | errorText = 'Unknown error' 39 | } 40 | 41 | /** 42 | * Create error based on response status 43 | */ 44 | switch (status) { 45 | case 401: 46 | case 403: 47 | return new UnauthorizedError(errorText) 48 | case 404: 49 | return new NotFoundError(errorText) 50 | default: 51 | return new Error(errorText) 52 | } 53 | }, 54 | }) 55 | } 56 | 57 | /** 58 | * Make GET request to the API 59 | * 60 | * @param endpoint - API endpoint 61 | * @param data - data to be sent url encoded 62 | */ 63 | public async get(endpoint: string, data?: any): Promise { 64 | const response = await super.get(endpoint, data) 65 | 66 | return response as Payload 67 | } 68 | 69 | /** 70 | * Make POST request to theAPI 71 | * 72 | * @param endpoint - API endpoint 73 | * @param data - data to be sent with request body 74 | */ 75 | public async post(endpoint: string, data?: any): Promise { 76 | const response = await super.post(endpoint, data) 77 | 78 | return response as Payload 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /client/src/infra/transport/api/types/response.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Api successfully response type 3 | * 4 | * @template Payload - Response payload type 5 | */ 6 | export type ApiSuccessfulResponse = Payload 7 | 8 | /** 9 | * Api error response type 10 | */ 11 | export interface ApiErrorResponse { 12 | /** 13 | * Message identifier used for translation on client side 14 | * 15 | * NOT HTTP STATUS CODE — it will be send in `status` field 16 | */ 17 | code?: number; 18 | 19 | /** 20 | * Message text for better DX. Should not be showed to users. 21 | */ 22 | message?: string; 23 | } 24 | 25 | /** 26 | * Api response type 27 | */ 28 | export type ApiResponse = ApiSuccessfulResponse | ApiErrorResponse 29 | -------------------------------------------------------------------------------- /client/src/infra/transport/fetch/index.ts: -------------------------------------------------------------------------------- 1 | import type JSONValue from '../types/json.ts' 2 | 3 | /** 4 | * Additional options for fetch transport 5 | */ 6 | export interface FetchTransportOptions { 7 | /** 8 | * Error formatter to create an Error based on status and payload 9 | */ 10 | errorFormatter?: (status: number, payload: JSONValue, endpoint: string) => Error; 11 | } 12 | 13 | /** 14 | * Fetch transport to make HTTP requests 15 | */ 16 | export default class FetchTransport { 17 | /** 18 | * Common headers for all requests 19 | * For example, may contain authorization 20 | */ 21 | protected readonly headers = new Headers() 22 | 23 | /** 24 | * Fetch constructor 25 | * 26 | * @param baseUrl - Base URL 27 | * @param options - Transport options 28 | */ 29 | constructor(private readonly baseUrl: string, private readonly options?: FetchTransportOptions) { 30 | } 31 | 32 | /** 33 | * Gets specific resource 34 | * 35 | * @template Response - Response data type 36 | * @param endpoint - API endpoint 37 | * @param data - data to be sent url encoded 38 | */ 39 | public async get(endpoint: string, data?: JSONValue): Promise { 40 | const resourceUrl = new URL(this.baseUrl + endpoint) 41 | 42 | if (data !== undefined) { 43 | resourceUrl.search = new URLSearchParams(data as Record).toString() 44 | } 45 | 46 | const response = await fetch(resourceUrl.toString(), { 47 | method: 'GET', 48 | headers: this.headers, 49 | }) 50 | 51 | return await this.parseResponse(response, endpoint) 52 | } 53 | 54 | /** 55 | * Make POST request to update some resource 56 | * 57 | * @template Response - Response data type 58 | * @param endpoint - API endpoint 59 | * @param payload - JSON POST data body 60 | */ 61 | public async post(endpoint: string, payload?: JSONValue): Promise { 62 | this.headers.set('Content-Type', 'application/json') 63 | 64 | /** 65 | * Send payload as body to allow Fastify accept it 66 | */ 67 | const response = await fetch(this.baseUrl + endpoint, { 68 | method: 'POST', 69 | headers: this.headers, 70 | body: payload !== undefined ? JSON.stringify(payload) : undefined, 71 | }) 72 | 73 | return await this.parseResponse(response, endpoint) 74 | } 75 | 76 | /** 77 | * Check response for errors 78 | * 79 | * @param response - Response object 80 | * @param endpoint - API endpoint used for logging 81 | * @throws Error 82 | */ 83 | private async parseResponse(response: Response, endpoint: string): Promise { 84 | let payload 85 | 86 | /** 87 | * Try to parse error data. If it is not valid JSON, throw error 88 | */ 89 | try { 90 | payload = await response.json() 91 | } catch (error) { 92 | throw new Error(`The response is not valid JSON (requesting ${endpoint})`) 93 | } 94 | 95 | /** 96 | * The 'ok' read-only property of the Response interface contains a Boolean 97 | * stating whether the response was successful (status in the range 200-299) or not 98 | * 99 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Response/ok 100 | */ 101 | if (response.ok) { 102 | return payload 103 | } 104 | 105 | /** 106 | * If error formatter is provided, use it to create an Error based on status and payload 107 | */ 108 | if (this.options?.errorFormatter !== undefined) { 109 | throw this.options.errorFormatter(response.status, payload, endpoint) 110 | } else { 111 | throw new Error(`${response.statusText || 'Bad response'} (requesting ${endpoint}))`) 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /client/src/infra/transport/types/json.ts: -------------------------------------------------------------------------------- 1 | type JSONValue = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | JSONObject 7 | | JSONArray 8 | 9 | type JSONObject = Record 10 | 11 | interface JSONArray extends Array { } 12 | 13 | export default JSONValue 14 | -------------------------------------------------------------------------------- /client/src/infra/utils/color.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Makes passed hex color darker 3 | */ 4 | export function darkenColor(color: string, percent: number): string { 5 | const num = parseInt(color.replace('#', ''), 16) 6 | const amt = Math.round(2.55 * percent) 7 | const R = (num >> 16) - amt 8 | const B = ((num >> 8) & 0x00FF) - amt 9 | const G = (num & 0x0000FF) - amt 10 | 11 | return '#' + (0x1000000 + (R > 0 ? R : 0) * 0x10000 + (B > 0 ? B : 0) * 0x100 + (G > 0 ? G : 0)).toString(16).slice(1) 12 | } 13 | -------------------------------------------------------------------------------- /client/src/infra/utils/date.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert Date to "4 Oct 2023" format 3 | * 4 | * @param date - Date to format 5 | * @param short - Whether to use short month name "4/06/23" instead of "4 Jun 2023" 6 | */ 7 | export function formatDate(date: Date, short: boolean = false): string { 8 | const day = date.getDate() 9 | 10 | if (!short) { 11 | const month = date.toLocaleString('default', { month: 'short' }) 12 | const year = date.getFullYear() 13 | 14 | return `${day} ${month} ${year}` 15 | } 16 | 17 | const month = date.toLocaleString('default', { month: 'numeric' }) 18 | const year = date.getFullYear() 19 | 20 | return `${day}/${month}/${year}` 21 | } 22 | -------------------------------------------------------------------------------- /client/src/infra/utils/dom.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get CSS property value 3 | * 4 | * @param property - CSS property name 5 | * @param element - element that owns the property 6 | */ 7 | export function getCSSVariable(property: string, element?: HTMLElement): string | undefined { 8 | return window.getComputedStyle(element ?? document.documentElement).getPropertyValue(property) 9 | } 10 | 11 | /** 12 | * Loads an image and converts it to base64 13 | * 14 | * @param src - image source 15 | */ 16 | export async function imageToBase64(src: string): Promise { 17 | const response = await fetch(src) 18 | const blob = await response.blob() 19 | 20 | return await new Promise((resolve, reject) => { 21 | const reader = new FileReader() 22 | reader.onloadend = () => resolve(reader.result as string) 23 | reader.onerror = reject 24 | reader.readAsDataURL(blob) 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /client/src/infra/utils/number.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Make short abbreviation of number 3 | * 123 -> 123 4 | * 1234 -> 1.2K 5 | * 1234367 -> 1.2M 6 | * 7 | * @param val The value to make short 8 | */ 9 | export function shortNumber(val: number): string { 10 | if (val < 1000) { 11 | return val.toString() 12 | } 13 | 14 | if (val < 1000000) { 15 | /** 16 | * Do not add .0 17 | */ 18 | if (val % 1000 === 0) { 19 | return `${(val / 1000).toFixed(0)}K` 20 | } 21 | 22 | return `${(val / 1000).toFixed(1)}K` 23 | } 24 | 25 | return `${(val / 1000000).toFixed(1)}M` 26 | } 27 | 28 | /** 29 | * Add spaces to number 30 | * 1234567 -> 1 234 567 31 | * 32 | * @param val The value to add spaces to 33 | */ 34 | export function spaced(val: number): string { 35 | if (val < 10000) { 36 | return val.toString() 37 | } 38 | 39 | return val.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ') 40 | } 41 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import Router from '@/application/router' 4 | import './presentation/styles/index.css' 5 | import { loadCities } from '@/infra/store/cities' 6 | import { useTelegram } from '@/application/services' 7 | import { getCSSVariable } from './infra/utils/dom' 8 | import { darkenColor } from './infra/utils/color' 9 | 10 | /** 11 | * @todo async lottie-player loading 12 | * @todo preload all icons 13 | * @todo describe thumbnail generation and work 14 | * @todo describe next/main buttons simulation 15 | * @todo close confirmation 16 | * @todo cancel payment toast 17 | */ 18 | 19 | const { platform, ready, showAlert } = useTelegram() 20 | 21 | if (platform !== 'unknown') { 22 | switch (platform) { 23 | case 'android': 24 | case 'android_x': 25 | case 'web': 26 | case 'weba': 27 | case 'tdesktop': 28 | document.body.classList.add('is-material') 29 | break 30 | case 'ios': 31 | case 'macos': 32 | document.body.classList.add('is-apple') 33 | break 34 | default: 35 | document.body.classList.add(`is-${platform}`) 36 | break 37 | } 38 | } 39 | 40 | /** 41 | * Some clients may use material/apple base styles, but has some overrides 42 | * For instance, WebK uses material but more rounded and clean 43 | */ 44 | document.body.classList.add(`is-exact-${platform}`) 45 | 46 | /** 47 | * In the last Telegram iOS client, some theme variable are broken in Dark mode: 48 | * --tg-theme-bg-color (used for island in docs) became #000000 49 | * --tg-theme-secondary-bg-color (used for app bg in docs) became lighter that the --tg-theme-bg-color 50 | * 51 | * As a temporary workaround, we check if the variables are broken and swap them 52 | * 53 | * Another issue we have in iOS Dark Dimmed theme: both variables are the same, so we manually change one of them 54 | * 55 | */ 56 | function handleBrokenVariables(): void { 57 | const themeBgColor = getCSSVariable('--tg-theme-bg-color') 58 | const themeSecondaryBgColor = getCSSVariable('--tg-theme-secondary-bg-color') 59 | 60 | if (themeBgColor === '#000000' && themeSecondaryBgColor !== '#000000') { 61 | document.documentElement.style.setProperty('--tg-theme-bg-color', themeSecondaryBgColor ?? '') 62 | document.documentElement.style.setProperty('--tg-theme-secondary-bg-color', themeBgColor ?? '') 63 | 64 | return 65 | } 66 | 67 | /** 68 | * Workaround problem with iOS Dark Dimmed theme. Manually make secondary bg color darker 69 | */ 70 | if (themeBgColor === themeSecondaryBgColor && themeBgColor !== undefined) { 71 | document.documentElement.style.setProperty('--tg-theme-secondary-bg-color', darkenColor(themeBgColor, 2.3)) 72 | } 73 | } 74 | 75 | /** 76 | * Prepare app data 77 | * 78 | * @todo load icons 79 | * @todo prepare image thumbs 80 | */ 81 | void loadCities() 82 | .then(() => { 83 | const app = createApp(App) 84 | 85 | app.use(Router) 86 | app.mount('#app') 87 | 88 | requestAnimationFrame(() => { 89 | if (platform === 'ios') { 90 | handleBrokenVariables() 91 | } 92 | 93 | ready() 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /client/src/presentation/assets/fonts/OpenSans.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/src/presentation/assets/fonts/OpenSans.ttf -------------------------------------------------------------------------------- /client/src/presentation/assets/fonts/Roboto-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/src/presentation/assets/fonts/Roboto-Black.ttf -------------------------------------------------------------------------------- /client/src/presentation/assets/fonts/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/src/presentation/assets/fonts/Roboto-Bold.ttf -------------------------------------------------------------------------------- /client/src/presentation/assets/fonts/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/src/presentation/assets/fonts/Roboto-Medium.ttf -------------------------------------------------------------------------------- /client/src/presentation/assets/fonts/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/client/src/presentation/assets/fonts/Roboto-Regular.ttf -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/arrow-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/arrow-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/card.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/checkmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/chevron-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/clock-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/laurel-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/laurel-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/market-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/settings-faq.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/settings-user.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/square-filled-air.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/square-filled-bed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/square-filled-parking.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/square-filled-safe.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/square-filled-sport.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/square-filled-wifi.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/user-circle-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/presentation/assets/icons/xmark-24.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/presentation/components/Amount/Amount.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 43 | 44 | 81 | -------------------------------------------------------------------------------- /client/src/presentation/components/Avatar/Avatar.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 103 | 104 | 149 | -------------------------------------------------------------------------------- /client/src/presentation/components/DataOverview/DataOverview.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 72 | 73 | 110 | -------------------------------------------------------------------------------- /client/src/presentation/components/DataOverview/DataOverviewItem.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 25 | 26 | 53 | -------------------------------------------------------------------------------- /client/src/presentation/components/DatePicker/DatePickerCompact.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 29 | 30 | 45 | -------------------------------------------------------------------------------- /client/src/presentation/components/Icon/Icon.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 28 | 30 | -------------------------------------------------------------------------------- /client/src/presentation/components/Input/Input.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 39 | 40 | 85 | -------------------------------------------------------------------------------- /client/src/presentation/components/List/List.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 25 | 26 | 55 | -------------------------------------------------------------------------------- /client/src/presentation/components/List/ListItem.vue: -------------------------------------------------------------------------------- 1 | 126 | 127 | 200 | 201 | 333 | -------------------------------------------------------------------------------- /client/src/presentation/components/List/ListItemExpandable.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 47 | 48 | 103 | -------------------------------------------------------------------------------- /client/src/presentation/components/List/ListItemIcon.vue: -------------------------------------------------------------------------------- 1 | 9 | 17 | 18 | 57 | -------------------------------------------------------------------------------- /client/src/presentation/components/Lottie/Lottie.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | -------------------------------------------------------------------------------- /client/src/presentation/components/Page/FixedFooter.vue: -------------------------------------------------------------------------------- 1 | 3 | 11 | 12 | 24 | -------------------------------------------------------------------------------- /client/src/presentation/components/Page/WithHeader.vue: -------------------------------------------------------------------------------- 1 | 39 | 54 | 55 | 56 | 74 | -------------------------------------------------------------------------------- /client/src/presentation/components/Placeholder/Placeholder.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 57 | 58 | 119 | -------------------------------------------------------------------------------- /client/src/presentation/components/Rating/Rating.vue: -------------------------------------------------------------------------------- 1 | 11 | 26 | 27 | 38 | -------------------------------------------------------------------------------- /client/src/presentation/components/Section/Section.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 43 | 44 | 86 | -------------------------------------------------------------------------------- /client/src/presentation/components/Section/Sections.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 11 | 12 | 23 | -------------------------------------------------------------------------------- /client/src/presentation/components/Text/Text.vue: -------------------------------------------------------------------------------- 1 | 4 | 9 | 10 | 21 | -------------------------------------------------------------------------------- /client/src/presentation/components/index.ts: -------------------------------------------------------------------------------- 1 | import PageWithHeader from './Page/WithHeader.vue' 2 | import Icon from './Icon/Icon.vue' 3 | import DataOverview from './DataOverview/DataOverview.vue' 4 | import Avatar from './Avatar/Avatar.vue' 5 | import List from './List/List.vue' 6 | import ListItem from './List/ListItem.vue' 7 | import ListCard from './List/ListCard.vue' 8 | import ListItemExpandable from './List/ListItemExpandable.vue' 9 | import ListItemIcon from './List/ListItemIcon.vue' 10 | import Amount from './Amount/Amount.vue' 11 | import Placeholder from './Placeholder/Placeholder.vue' 12 | import Sections from './Section/Sections.vue' 13 | import Section from './Section/Section.vue' 14 | import DatePicker from './DatePicker/DatePicker.vue' 15 | import DatePickerCompact from './DatePicker/DatePickerCompact.vue' 16 | import Input from './Input/Input.vue' 17 | import Text from './Text/Text.vue' 18 | import Rating from './Rating/Rating.vue' 19 | import FixedFooter from './Page/FixedFooter.vue' 20 | import Lottie from './Lottie/Lottie.vue' 21 | 22 | export { 23 | PageWithHeader, 24 | Icon, 25 | DataOverview, 26 | Avatar, 27 | List, 28 | ListItem, 29 | ListItemExpandable, 30 | ListItemIcon, 31 | ListCard, 32 | Amount, 33 | Placeholder, 34 | Sections, 35 | Section, 36 | DatePicker, 37 | DatePickerCompact, 38 | Input, 39 | Text, 40 | Rating, 41 | FixedFooter, 42 | Lottie, 43 | } 44 | -------------------------------------------------------------------------------- /client/src/presentation/screens/Hotel.vue: -------------------------------------------------------------------------------- 1 | 113 | 238 | 239 | 300 | -------------------------------------------------------------------------------- /client/src/presentation/screens/Location.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | 137 | 138 | 177 | -------------------------------------------------------------------------------- /client/src/presentation/styles/hacks.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /** 3 | * If an element has overflow: hidden and border-radius, overflow is ignored in Safari. 4 | * This fix solves the issue 5 | */ 6 | --safari-overflow-hidden-fix { 7 | /** 8 | * Hack to target only Safari and not all webkit browsers 9 | */ 10 | @media not all and (min-resolution:.001dpcm) { 11 | -webkit-mask-image: -webkit-radial-gradient(white, black); 12 | } 13 | } 14 | } 15 | 16 | 17 | .fake-main-button { 18 | padding: 20px; 19 | border-radius: 10px 10px 0 0; 20 | color: #fff; 21 | background-color: var(--color-bg-button); 22 | position: fixed; 23 | bottom: 0; 24 | right: 0; 25 | left: 0; 26 | 27 | font-size: 17px; 28 | font-family: system-ui, Inter, Avenir, Helvetica, Arial, sans-serif; 29 | font-weight: 500; 30 | display: none; 31 | cursor: pointer; 32 | 33 | &.visible { 34 | display: block; 35 | } 36 | } 37 | 38 | .fake-back-button { 39 | padding: 12px 18px; 40 | border-radius: 7px; 41 | color: var(--color-link); 42 | background-color: var(--color-bg); 43 | position: fixed; 44 | top: 10px; 45 | left: 10px; 46 | z-index: 9; 47 | 48 | font-size: 17px; 49 | font-family: system-ui, Inter, Avenir, Helvetica, Arial, sans-serif; 50 | font-weight: 500; 51 | display: none; 52 | cursor: pointer; 53 | 54 | &.visible { 55 | display: block; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /client/src/presentation/styles/index.css: -------------------------------------------------------------------------------- 1 | @import "normalize.css"; 2 | @import "reset.css"; 3 | @import "hacks.css"; 4 | @import "theme/colors.css"; 5 | @import "theme/typescale.css"; 6 | @import "theme/spacings.css"; 7 | @import "theme/sizes.css"; 8 | @import "theme/animations.css"; 9 | -------------------------------------------------------------------------------- /client/src/presentation/styles/reset.css: -------------------------------------------------------------------------------- 1 | html, body, div, span, applet, object, iframe, 2 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 3 | a, abbr, acronym, address, big, cite, code, 4 | del, dfn, em, img, ins, kbd, q, s, samp, 5 | small, strike, strong, sub, sup, tt, var, 6 | b, u, i, center, 7 | dl, dt, dd, ol, ul, li, 8 | fieldset, form, label, legend, 9 | table, caption, tbody, tfoot, thead, tr, th, td, 10 | article, aside, canvas, details, embed, 11 | figure, figcaption, footer, header, hgroup, 12 | menu, nav, output, ruby, section, summary, 13 | time, mark, audio, video { 14 | margin: 0; 15 | padding: 0; 16 | border: 0; 17 | font-size: 100%; 18 | font: inherit; 19 | vertical-align: baseline; 20 | box-sizing: inherit; 21 | } 22 | 23 | article, aside, details, figcaption, figure, 24 | footer, header, hgroup, menu, nav, section { 25 | display: block; 26 | } 27 | 28 | html { 29 | box-sizing: border-box; 30 | } 31 | 32 | blockquote, q { 33 | quotes: none; 34 | } 35 | 36 | blockquote:before, blockquote:after, 37 | q:before, q:after { 38 | content: ''; 39 | content: none; 40 | } 41 | 42 | table { 43 | border-collapse: collapse; 44 | border-spacing: 0; 45 | } 46 | 47 | a { 48 | color: inherit; 49 | text-decoration: none; 50 | } 51 | 52 | button { 53 | padding: 0; 54 | background: none; 55 | border: none; 56 | } 57 | 58 | svg { 59 | display: block; 60 | } 61 | 62 | input { 63 | width: 100%; 64 | height: 100%; 65 | margin: 0; 66 | padding: 0; 67 | font: inherit; 68 | line-height: 1; 69 | color: inherit; 70 | background-color: transparent; 71 | border: none; 72 | box-shadow: none; 73 | outline: none; 74 | appearance: none; 75 | 76 | &[disabled]::placeholder { 77 | color: inherit; 78 | } 79 | 80 | &:-webkit-autofill { 81 | &, 82 | &:hover, 83 | &:focus, 84 | &:active { 85 | transition-delay: 9999s; 86 | transition-property: background-color, color; 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /client/src/presentation/styles/theme/animations.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --animation-skeleton { 3 | animation: skeleton 1.2s ease-in-out infinite; 4 | background-color: var(--skeleton-color); 5 | } 6 | } 7 | 8 | @keyframes skeleton { 9 | 0% { 10 | opacity: 1; 11 | } 12 | 13 | 50% { 14 | opacity: 0.5; 15 | } 16 | 17 | 100% { 18 | opacity: 1; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/src/presentation/styles/theme/colors.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --dark-theme { 3 | --color-bg: var(--tg-theme-bg-color, #1c1c1d); 4 | --color-bg-secondary: var(--tg-theme-secondary-bg-color, #000); 5 | --color-bg-tertiary: rgba(118, 118, 128, 0.1); 6 | --color-bg-quarternary: rgba(255,255,255,0.16); 7 | --color-bg-button: var(--tg-theme-button-color, #3e88f7); 8 | --color-bg-overlay: rgba(0, 0, 0, 0.3); 9 | 10 | --color-text: var(--tg-theme-text-color, #fff); 11 | --color-text-button: var(--tg-theme-button-text-color, #fff); 12 | --color-hint: var(--tg-theme-hint-color, #98989e); 13 | --color-link: var(--tg-theme-link-color, #3e88f7); 14 | 15 | --separator-color: rgba(255, 255, 255, 0.1); 16 | 17 | --skeleton-color: var(--color-bg-tertiary); 18 | 19 | --color-overlay-floating: rgba(19, 19, 19, 0.82); 20 | } 21 | 22 | --light-theme { 23 | --color-bg: var(--tg-theme-bg-color, #fff); 24 | --color-bg-secondary: var(--tg-theme-secondary-bg-color, #efeff4); 25 | --color-bg-tertiary: rgba(235, 235, 235, 0.75); 26 | --color-bg-button: var(--tg-theme-button-color, #007aff); 27 | 28 | --color-text: var(--tg-theme-text-color, #000); 29 | --color-text-button: var(--tg-theme-button-text-color, #fff); 30 | --color-hint: var(--tg-theme-hint-color, #8e8e93); 31 | --color-link: var(--tg-theme-link-color, #007aff); 32 | 33 | --separator-color: rgba(204, 204, 204, 0.36); 34 | --color-overlay-floating: rgba(19, 19, 19, 0.79); 35 | } 36 | 37 | .is-material { 38 | --color-island-shadow: rgba(0,0,0,0.02); 39 | } 40 | } 41 | 42 | :root { 43 | @apply --dark-theme; 44 | 45 | .dark { 46 | @apply --dark-theme; 47 | } 48 | 49 | .light { 50 | @apply --light-theme; 51 | } 52 | 53 | @media (prefers-color-scheme: dark) { 54 | @apply --dark-theme; 55 | } 56 | 57 | @media (prefers-color-scheme: light) { 58 | @apply --light-theme; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /client/src/presentation/styles/theme/sizes.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --size-avatar-small: 28px; 3 | --size-avatar-medium: 40px; 4 | --size-avatar-big: 84px; 5 | 6 | --size-border-radius-small: 8px; 7 | --size-border-radius-medium: 10px; 8 | --size-border-radius-big: 13px; 9 | --size-border-radius-large: 16px; 10 | 11 | --size-cell-min-height: 44px; 12 | --size-cell-h-margin: 16px; 13 | --size-cell-v-margin: 16px; 14 | --size-cell-h-padding: 16px; 15 | --size-cell-v-padding: 4px; 16 | --size-separator-height: 1px; 17 | } 18 | 19 | .is-ios, 20 | .is-mac-os { 21 | @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) { 22 | --size-separator-height: 0.33px; 23 | } 24 | } 25 | 26 | .is-material { 27 | --size-cell-h-margin: 0px; 28 | --size-border-radius-medium: 2px; 29 | --size-border-radius-small: 0px; 30 | --size-border-radius-big: 0px; 31 | --size-cell-min-height: 48px; 32 | } 33 | 34 | .is-exact-web, 35 | .is-exact-weba { 36 | /* --size-border-radius-small: 5px; */ 37 | /* --size-border-radius-big: 10px; */ 38 | --size-avatar-big: 68px; 39 | } 40 | -------------------------------------------------------------------------------- /client/src/presentation/styles/theme/spacings.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --spacing-3: 3px; 3 | --spacing-8: 8px; 4 | --spacing-10: 10px; 5 | --spacing-20: 20px; 6 | --spacing-28: 28px; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/presentation/styles/theme/typescale.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --family: system-ui, Inter, Avenir, Helvetica, Arial, sans-serif; 3 | --rounded-family: SF Pro Rounded, "ui-rounded", system-ui; 4 | 5 | --font-size-title-2: 22px; 6 | --line-height-title-2: 28px; 7 | 8 | --font-size-headline: 17px; 9 | --line-height-headline: 22px; 10 | 11 | --font-size-subheadline-2: 14px; 12 | --line-height-subheadline-2: 18px; 13 | --ls-subheadline-2: -0.15px; 14 | 15 | --font-size-body: 17px; 16 | --line-height-body: 22px; 17 | 18 | 19 | .is-material { 20 | /** 21 | * Some android devices uses custom fonts, but Telegram uses Roboto, so we need to load it manually 22 | */ 23 | @font-face { 24 | font-family: 'Roboto'; 25 | font-style: normal; 26 | font-weight: 400; 27 | src: url('../../assets/fonts/Roboto-Regular.ttf') format('truetype'); 28 | } 29 | 30 | @font-face { 31 | font-family: 'Roboto'; 32 | font-style: normal; 33 | font-weight: 500; 34 | src: url('../../assets/fonts/Roboto-Medium.ttf') format('truetype'); 35 | } 36 | 37 | @font-face { 38 | font-family: 'Roboto'; 39 | font-style: normal; 40 | font-weight: 700; 41 | src: url('../../assets/fonts/Roboto-Bold.ttf') format('truetype'); 42 | } 43 | 44 | @font-face { 45 | font-family: 'Roboto'; 46 | font-style: normal; 47 | font-weight: 900; 48 | src: url('../../assets/fonts/Roboto-Black.ttf') format('truetype'); 49 | } 50 | 51 | --family: "Roboto", system-ui, Inter, Avenir, Helvetica, Arial, sans-serif; 52 | 53 | --font-size-title-2: 20px; 54 | --line-height-title-2: 24px; 55 | 56 | --font-size-headline: 16px; 57 | --line-height-headline: 20px; 58 | 59 | --font-size-subheadline-2: 15px; 60 | --line-height-subheadline-2: 17px; 61 | --ls-subheadline-2: 0; 62 | 63 | --font-size-body: 16px; 64 | --line-height-body: 20px; 65 | } 66 | 67 | .is-exact-tdesktop { 68 | @font-face { 69 | font-family: 'Open Sans'; 70 | src: url('../../assets/fonts/OpenSans.ttf') format('truetype'); 71 | } 72 | 73 | --family: 'Open Sans',"Lucida Grande","Lucida Sans Unicode",Arial,Helvetica,Verdana,sans-serif; 74 | } 75 | 76 | --title-1 { 77 | font-size: 28px; 78 | line-height: 34px; 79 | letter-spacing: 0.38px; 80 | } 81 | 82 | --title-2 { 83 | font-size: var(--font-size-title-2); 84 | line-height: var(--line-height-title-2); 85 | /* letter-spacing: -0.26px; */ 86 | } 87 | 88 | --title-2-semibold { 89 | @apply --title-2; 90 | 91 | font-weight: 600; 92 | } 93 | 94 | --title-3 { 95 | font-size: 20px; 96 | line-height: 24px; 97 | letter-spacing: -0.45px; 98 | } 99 | 100 | --title-3-bold { 101 | @apply --title-3; 102 | 103 | font-weight: 700; 104 | } 105 | 106 | --title-3-rounded-semibold { 107 | @apply --title-3-bold; 108 | 109 | font-weight: 600; 110 | font-family: var(--rounded-family); 111 | } 112 | 113 | --headline { 114 | font-size: var(--font-size-headline); 115 | line-height: var(--line-height-headline); 116 | } 117 | 118 | --headline--semibold { 119 | @apply --headline; 120 | 121 | font-weight: 590; 122 | } 123 | 124 | --body { 125 | font-size: var(--font-size-body); 126 | line-height: var(--line-height-body); 127 | letter-spacing: -0.43px; 128 | } 129 | 130 | --body-medium { 131 | @apply --body; 132 | 133 | font-weight: 500; 134 | } 135 | 136 | --body-semibold { 137 | @apply --body; 138 | 139 | font-weight: 600; 140 | } 141 | 142 | --body-strikethrough { 143 | @apply --body; 144 | 145 | text-decoration: line-through; 146 | } 147 | 148 | --body-mono { 149 | @apply --body; 150 | 151 | font-family: SF Mono, Monospace; 152 | } 153 | 154 | --body-mono-numbers { 155 | @apply --body-mono; 156 | 157 | font-feature-settings: 'tnum' on, 'lnum' on; 158 | } 159 | 160 | --body-compact { 161 | font-size: 16px; 162 | line-height: 20px; 163 | } 164 | 165 | --body-compact-medium { 166 | @apply --body-compact; 167 | 168 | font-weight: 500; 169 | } 170 | 171 | 172 | --callout { 173 | font-size: 16px; 174 | line-height: 22px; 175 | letter-spacing: -0.31px; 176 | } 177 | 178 | --callout-medium { 179 | @apply --callout; 180 | 181 | font-weight: 500; 182 | } 183 | 184 | --callout-semibold { 185 | @apply --callout; 186 | 187 | font-weight: 600; 188 | } 189 | 190 | --subheadline-1 { 191 | font-size: 15px; 192 | line-height: 18px; 193 | letter-spacing: -0.23px; 194 | } 195 | 196 | --subheadline-rounded-semibold { 197 | @apply --subheadline-1; 198 | 199 | font-weight: 600; 200 | font-family: var(--rounded-family); 201 | letter-spacing: 0.45px; 202 | } 203 | 204 | --subheadline-2 { 205 | font-size: var(--font-size-subheadline-2:); 206 | line-height: var(--line-height-subheadline-2); 207 | letter-spacing: var(--ls-subheadline-2); 208 | } 209 | 210 | --subheadline-2-semibold { 211 | @apply --subheadline-2; 212 | 213 | font-weight: 600; 214 | } 215 | 216 | --subheadline-2-rounded-semibold { 217 | @apply --subheadline-2-semibold; 218 | 219 | font-family: var(--rounded-family); 220 | letter-spacing: 0.49px; 221 | } 222 | 223 | --footnote { 224 | font-size: 13px; 225 | line-height: 16px; 226 | /* letter-spacing: -0.08px; */ 227 | } 228 | 229 | --footnote-semibold { 230 | @apply --footnote; 231 | 232 | font-weight: 600; 233 | } 234 | 235 | --footnote-caps { 236 | @apply --footnote; 237 | 238 | text-transform: uppercase; 239 | } 240 | 241 | --footnote-caps-semibold { 242 | @apply --footnote-caps; 243 | 244 | font-feature-settings: 'clig' off, 'liga' off; 245 | font-weight: 590; 246 | } 247 | 248 | --footnote-rounded-semibold { 249 | @apply --footnote; 250 | 251 | font-weight: 600; 252 | font-family: var(--rounded-family); 253 | } 254 | 255 | --caption-1 { 256 | font-size: 12px; 257 | line-height: 16px; 258 | } 259 | 260 | --caption-2 { 261 | font-size: 11px; 262 | line-height: 13px; 263 | letter-spacing: 0.06px; 264 | } 265 | 266 | --caption-2-medium { 267 | @apply --caption-2; 268 | 269 | font-weight: 500; 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/tools/README.md: -------------------------------------------------------------------------------- 1 | # Thumbnail generator 2 | 3 | Tool for generating thumbnails for images in /public/pics 4 | 5 | Generates JSON with the following format: 6 | 7 | ``` 8 | { 9 | "thumbs": { 10 | "hotel-1.jpg": "Base64 of 10x10 thumb" 11 | }, 12 | ... 13 | } 14 | ``` 15 | 16 | Saves the JSON to `/src/infra/store/thumbs/thumbs.json` 17 | 18 | Then it will be used by the app to display thumbnails in the gallery, see `application/services/useThumbnail.ts` 19 | 20 | ## Usage 21 | 22 | ``` 23 | node tools/thumbs.js 24 | ``` 25 | -------------------------------------------------------------------------------- /client/tools/thumbs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Util that generates thumbnails for images in /public/pics 3 | * 4 | * Generates JSON with the following format: 5 | * { 6 | * "thumbs": { 7 | * "hotel-1.jpg": "", 8 | * } 9 | * } 10 | */ 11 | import fs from 'fs'; 12 | import path from 'path'; 13 | import sharp from 'sharp'; 14 | 15 | const __dirname = path.dirname(new URL(import.meta.url).pathname); 16 | const sourceDir = path.resolve(__dirname, '..' ,'public', 'pics'); 17 | const mocksDir = path.resolve(__dirname, '..', 'src/infra/store', 'thumbs'); 18 | const thumbsFileName = 'thumbs.json'; 19 | 20 | 21 | /** 22 | * Generates a Base 64 of 10x10 thumbnail for a given file 23 | */ 24 | function generateThumbForFile(filePath) { 25 | return new Promise((resolve, reject) => { 26 | sharp(filePath) 27 | .resize({ width: 10 }) 28 | .toBuffer((err, buffer) => { 29 | if (err) reject(err); 30 | 31 | // Convert thumbnail buffer to base64 string 32 | const thumbBase64 = buffer.toString('base64'); 33 | 34 | resolve(thumbBase64); 35 | }); 36 | }); 37 | } 38 | 39 | /** 40 | * Promise wrapper around fs.readdirSync 41 | */ 42 | function readDir(dirPath) { 43 | return new Promise((resolve, reject) => { 44 | fs.readdir(dirPath, (err, files) => { 45 | if (err) reject(err); 46 | 47 | resolve(files); 48 | }); 49 | }); 50 | } 51 | 52 | 53 | 54 | async function generateThumbnails() { 55 | const files = await readDir(sourceDir); 56 | 57 | const resultTuples = await Promise.all( 58 | files.map(async file => { 59 | // Only process image files 60 | if (/\.(jpg|jpeg|png|gif)$/i.test(file) === false ) { 61 | return 62 | } 63 | 64 | /** 65 | * Print file name in console with replacement of previous line 66 | */ 67 | console.log(`🏞️ Generating thumbnail for ${file}`); 68 | 69 | const filePath = path.resolve(sourceDir, file); 70 | 71 | const thumb = await generateThumbForFile(filePath); 72 | 73 | return [file, thumb] 74 | }) 75 | ) 76 | 77 | return Object.fromEntries(resultTuples); 78 | } 79 | 80 | const thumbs = await generateThumbnails(); 81 | 82 | console.log(`🏁 Writing thumbnails to ${mocksDir}/${thumbsFileName}`); 83 | 84 | fs.writeFileSync( 85 | path.resolve(mocksDir, thumbsFileName), 86 | JSON.stringify({ thumbs }, null, 2) 87 | ); 88 | 89 | console.log(`🎉 Done!`); 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /client/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | ".eslintrc.cjs", 5 | "postcss.config.js", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | "types": [ 17 | "vite/client" 18 | ], 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": ["./src/*"], 22 | }, 23 | 24 | /* Linting */ 25 | "strict": true, 26 | "noFallthroughCasesInSwitch": true 27 | }, 28 | "include": [ 29 | "vite.config.ts", 30 | "src/**/*.ts", 31 | "src/**/*.d.ts", 32 | "src/**/*.tsx", 33 | "src/**/*.vue" 34 | ], 35 | "references": [{ "path": "./tsconfig.node.json" }] 36 | } 37 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "allowSyntheticDefaultImports": true, 8 | "types": [ 9 | "node" 10 | ] 11 | }, 12 | "include": ["vite.config.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /client/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/(.*)", 5 | "destination": "/index.html" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import path from 'node:path' 3 | import vue from '@vitejs/plugin-vue' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [vue()], 8 | resolve: { 9 | alias: { 10 | '@': path.resolve(__dirname, './src'), 11 | }, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /docs/Awesome.md: -------------------------------------------------------------------------------- 1 | # 😎 Awesome list 2 | 3 | List of resources that can be useful when building your own Telegram Mini App 4 | 5 | ## Examples 6 | 7 | - [@DurgerKingBot](https://t.me/durgerkingbot/menu) - simple official example demonstrating what Mini Apps is 8 | - [@wallet](https://t.me/@wallet) - full featured example of how Mini App could be 9 | 10 | ## Guides 11 | 12 | - [Telegram Mini Apps](https://core.telegram.org/bots/webapps) — official platform documentation 13 | - Web Apps ([docs.twa.dev](https://docs.twa.dev/docs/introduction/about-platform)) — more detailed platform documentation by community 14 | - Ton Community / [What are Mini Apps?](https://docs.ton.org/develop/dapps/telegram-apps/) — guide from Ton Community 15 | 16 | ## Development 17 | 18 | - [@twa-dev/SDK](https://github.com/twa-dev/SDK) - Node.js API wrapper and type definitions 19 | - [@twa-dev/Mark42](https://github.com/twa-dev/Mark42) - React Ui library 20 | - [@twa-dev/vanilla-js-boilerplate](https://github.com/twa-dev/vanilla-js-boilerplate) - boilerplate based on simple web technologies: JavaScript, HTML, and CSS 21 | - [@twa-dev/webpack-boilerplate](https://github.com/twa-dev/webpack-boilerplate) - React + TypeScript + Webpack boilerplate 22 | - [@twa-dev/vite-boilerplate](https://github.com/twa-dev/vite-boilerplate) - React + TypeScript + Vite boilerplate 23 | - [@yagop/node-telegram-bot-api](https://github.com/yagop/node-telegram-bot-api) - Telegram Bot API for NodeJS 24 | 25 | ## Community 26 | 27 | - [Telegram Mini Apps dev community](https://github.com/twa-dev) - A community of builders that enhances developer experience for the Telegram Mini Apps (TMA) platform with tools, docs, and tutorials 28 | - [Telegram Developers Community](https://t.me/+1mQMqTopB1FkNjIy) - Telegram chat for mini apps developers 29 | - [Telegram Apps](https://www.tapps.center) — Telegram Apps catalog 30 | 31 | ## Payments 32 | 33 | - [Platform Overview](https://core.telegram.org/bots/payments) — Platform overview and Step-by-step guide 34 | - [Payments API](https://core.telegram.org/bots/api#payments) — official API documentation 35 | - [Stripe Test Mode](https://stripe.com/docs/test-mode) — how get test account, keys, cards numbers etc 36 | - [Stripe Testing](https://stripe.com/docs/testing) — detailed guide how to simulate transactions 37 | 38 | ## Design 39 | 40 | - Figma / [Telegram Library](https://www.figma.com/@firststagelabs) — basic design system for iOS and Android 41 | - Figma / [Apple iOS 17 UI Kit](https://www.figma.com/community/file/1247769024068708989/apple-ios-17-ui-kit-variables) - Figma with iOS 17 components 42 | - Figma / [iOS 17 and iPadOS 17 ](https://www.figma.com/file/tYi5KTNYSPGBsyrxz4SZaG/Apple-Design-Resources-%E2%80%93-iOS-17-and-iPadOS-17-(Community)?type=design&node-id=209-55480&mode=design&t=BA25hDUllNQGDsAa-0) - Apple Design Resources community library 43 | - [Material 3](https://m3.material.io) — Material 3 Design Guidelines 44 | - Figma / [Material 3](https://www.figma.com/community/file/1035203688168086460/material-3-design-kit) — Material 3 Design Kit 45 | -------------------------------------------------------------------------------- /docs/Deployment.md: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | This guide contains instructions on how to deploy your own instance of the bot. 4 | 5 | ## Vercel (serverless) 6 | 7 | 1. Create a new project on [Vercel](https://vercel.com/) and link it to your GitHub repository. 8 | 2. Fill in the environment variables in the Vercel dashboard (see [Backend Guide](../server/README.md)). 9 | 3. Update Vercel project settings: 10 | 11 | "Build Command" — `yarn build` 12 | "Output Directory" — `dist` 13 | "Install Command" — `yarn install` 14 | 15 | "Root Directory" — `server` 16 | 17 | "Node.js Version" — `18.x 18 | 19 | That's it! Other things are already configured for you via `vercel.json` and `package.json` files. 20 | 21 | Deployment will be triggered automatically on every push to the `main` branch. 22 | 23 | ## Own server 24 | 25 | 1. Clone the repository to your server. 26 | 2. Install dependencies: `yarn install`. 27 | 3. Build the project: `yarn build`. 28 | 4. Fill in the environment variables (see [Backend Guide](../server/README.md)). 29 | 5. Daemonize bot. Here is an example using screen: 30 | 31 | ```bash 32 | screen -dmS bot yarn start 33 | ``` 34 | 35 | You can also use [pm2](https://pm2.keymetrics.io/) or `forever`, or systemd, or Docker, or whatever you want. 36 | -------------------------------------------------------------------------------- /docs/GetStarted.md: -------------------------------------------------------------------------------- 1 | # Get Started with the repo 2 | 3 | This guide describes base aspects of this example implementation 4 | 5 | - 🏠 [Frontend tech guide](../client/README.md) - how to setup Client 6 | - 🎁 [Backend tech guide](../server/README.md) - how to setup Backend 7 | - ⛅️ [Deployment guide](./Deployment.md) - how to deploy 8 | 9 | ## Local Development 10 | 11 | What you need to know about development of Telegram Mini Apps and bot: 12 | 13 | - Telegram Test Environment 14 | - Telegram Debug Mode 15 | - Local ports forwarding through ngrok 16 | 17 | ### About Telegram Test Environment 18 | 19 | Telegram has a test environment that allows you to test your bots without affecting the production environment. 20 | The database of users in the test environment is separate from the production environment. 21 | 22 | Read more: [Using bots in the test environment](https://core.telegram.org/bots/webapps#using-bots-in-the-test-environment) 23 | 24 | It is recommended to use the test environment for development and testing of bots. 25 | 26 | ### Telegram Debug Mode 27 | 28 | It is a special mode of Telegram clients allowing to debug and inspect Mini Apps 29 | 30 | Read more: [Debug Mode for Mini Apps](https://core.telegram.org/bots/webapps#debug-mode-for-mini-apps) 31 | 32 | #### Debug Mode on Android devices 33 | 34 | Note. Official guide does not contain information about how to enable debug mode on Android devices. 35 | However, it is possible: 36 | 37 | - Download [Telegram Beta](https://install.appcenter.ms/users/drklo-2kb-ghpo/apps/Telegram-Beta-2/distribution_groups/All-users-of-Telegram-Beta-2) android client 38 | - On login, entered your number and check the "Test Backend" mark. 39 | - Scan the QR code 40 | 41 | ### Ports forwarding using ngrok 42 | 43 | To test your Mini App or Bot you need to give its URL to @BotFather. It does not accept local URLs so you'll need port forwarding. 44 | 45 | Ngrok — is util that allows you to expose your local ports to the internet. We need to expose two ports: both for client and server. 46 | 47 | 1. [Install ngrok](https://ngrok.com/docs/getting-started/#step-2-install-the-ngrok-agent) 48 | 2. Sigh up and get Auth Token 49 | 3. Copy ngrok.yml.example to ngrok.yml 50 | 4. Fill it with your token and local ports 51 | 5. Run ngrok using config: 52 | 53 | ``` 54 | ngrok start --all --config ./ngrok.yml 55 | ``` 56 | 57 | You'll see public hosts assigned to you. Send Client host to BotFather, and fill both client and server .env files with them 58 | -------------------------------------------------------------------------------- /docs/Payments.md: -------------------------------------------------------------------------------- 1 | # Telegram Mini App Payments 2 | 3 | This guide contains information about how to implement payments in your Telegram Mini App 4 | 5 | ![Payments Showcase](./assets/payments-showcase.png) 6 | 7 | ### How to create receive "payment_token" 8 | 9 | 1. Open BotFather and call `/mybots` 10 | 2. From the menu select your bot 11 | 3. From the Inline Keyboard press Settings -> Payments 12 | 4. Select "Stripe test" system 13 | 5. Open Stripe bot and follow instructions 14 | 6. When you will be asking to create a Stripe test account, follow the [Testing Stripe Connect](https://stripe.com/docs/connect/testing#account-numbers) guide 15 | 7. After you create the Stirpe account, you will receive the "payment_token" from BotFather 16 | 17 | ### How to use payments in this example 18 | 19 | When receiving the Payment Token, put at the `server/.env` to the `PROVIDER_TOKEN` variable. 20 | 21 | Then you'll be able to make test payments 22 | 23 | ### How to make test payments 24 | 25 | When you're testing payments using Stripe Test Account, you can enter a `4242 4242 4242 4242` card number, any future expiration date and random CVC. 26 | 27 | Stripe also have other test card numbers allowing to test different cases (success, failure, etc). Read more at Stripe docs: 28 | 29 | - [Stripe Test Mode](https://stripe.com/docs/test-mode) — how get test account, keys, cards numbers etc 30 | - [Stripe Testing](https://stripe.com/docs/testing) — detailed guide how to simulate transactions 31 | 32 | 33 | -------------------------------------------------------------------------------- /docs/assets/cover-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/cover-light.png -------------------------------------------------------------------------------- /docs/assets/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/cover.png -------------------------------------------------------------------------------- /docs/assets/payments-showcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/payments-showcase.png -------------------------------------------------------------------------------- /docs/assets/ui/Amount.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/Amount.png -------------------------------------------------------------------------------- /docs/assets/ui/Avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/Avatar.png -------------------------------------------------------------------------------- /docs/assets/ui/DataOverview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/DataOverview.png -------------------------------------------------------------------------------- /docs/assets/ui/DatePicker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/DatePicker.png -------------------------------------------------------------------------------- /docs/assets/ui/DatePickerCompact.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/DatePickerCompact.png -------------------------------------------------------------------------------- /docs/assets/ui/FixedFooter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/FixedFooter.png -------------------------------------------------------------------------------- /docs/assets/ui/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/Icon.png -------------------------------------------------------------------------------- /docs/assets/ui/Input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/Input.png -------------------------------------------------------------------------------- /docs/assets/ui/List.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/List.png -------------------------------------------------------------------------------- /docs/assets/ui/ListCard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/ListCard.png -------------------------------------------------------------------------------- /docs/assets/ui/ListItem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/ListItem.png -------------------------------------------------------------------------------- /docs/assets/ui/ListItemExpandable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/ListItemExpandable.png -------------------------------------------------------------------------------- /docs/assets/ui/Lottie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/Lottie.png -------------------------------------------------------------------------------- /docs/assets/ui/PageWithHeader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/PageWithHeader.png -------------------------------------------------------------------------------- /docs/assets/ui/Placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/Placeholder.png -------------------------------------------------------------------------------- /docs/assets/ui/Rating.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/Rating.png -------------------------------------------------------------------------------- /docs/assets/ui/Section.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/Section.png -------------------------------------------------------------------------------- /docs/assets/ui/Sections.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/Sections.png -------------------------------------------------------------------------------- /docs/assets/ui/Text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neSpecc/telebook/02af111d7c9b128abb958de07a9a68de4d249406/docs/assets/ui/Text.png -------------------------------------------------------------------------------- /ngrok.example.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | authtoken: 3 | tunnels: 4 | client: 5 | proto: http 6 | addr: 7 | server: 8 | proto: http 9 | addr: 10 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | # Application name 2 | APP_NAME=Telebook 3 | 4 | # Host for public API 5 | # @example: https://d668-51-158-186-93.ngrok-free.app 6 | PUBLIC_HOST= 7 | 8 | # An HTTPS URL of a Web App to be opened when the user presses the button 9 | # @example: https://01d0-51-158-186-93.ngrok-free.app 10 | WEB_APP_URL= 11 | 12 | # Port to run the application on 13 | PORT=8888 14 | 15 | # Telegram bot token got from @BotFather 16 | BOT_TOKEN= 17 | 18 | # Telegram provider token got from @BotFather, see root-readme for details 19 | PROVIDER_TOKEN= 20 | 21 | # Set true if you use Telegram test environment 22 | IS_TEST_ENVIRONMENT=true 23 | 24 | # From which domain requests are allowed 25 | ALLOWED_ORIGINS="*" 26 | 27 | # Webhook for loggin what happened in a bot. Uses @codex_bot 28 | # See https://github.com/codex-bot/notify 29 | NOTIFY_WEBHOOK= 30 | -------------------------------------------------------------------------------- /server/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'extends': [ 3 | 'codex/ts', 4 | ], 5 | parserOptions: { 6 | project: [ 7 | './tsconfig.json', 8 | ], 9 | tsconfigRootDir: __dirname, 10 | sourceType: 'module', 11 | }, 12 | include: [ 13 | '.eslintrc.cjs', 14 | ], 15 | 'rules': { 16 | /** Prefer semicolons */ 17 | semi: 'off', 18 | '@typescript-eslint/semi': ['error', 'never'], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Telegram Mini App + Bot server 2 | 3 | You can use this project as an example of how to implement your own backend for Telegram Mini App 4 | 5 | ## Features 6 | 7 | - 🤖 `/start` and `/help` commands handlers 8 | - 🎹 Inline Keyboard 9 | - 💰 Payments support 10 | - ✨ Hot Reloading 11 | -  ▲  Serverless Vercel Deployment setup 12 | 13 | ## Getting Started 14 | 15 | 1. Install dependencies 16 | 17 | ``` 18 | yarn install 19 | ``` 20 | 21 | 2. Create config. Copy .env.example to .env and fill it with your data 22 | 23 | ``` 24 | cp .env.example .env 25 | ``` 26 | 27 | | Variable | Example | Description | Where to get | 28 | | -- | -- | -- | -- | 29 | | APP_NAME | `Telebook` | Your Web App name. Used in bot messages | Just invent by yourself | 30 | | PUBLIC_HOST | `https://d668-51-158-186-93.ngrok-free.app` | Backend public host | Get from ngrok on development, or use production deployment host | 31 | | WEB_APP_URL | `https://01d0-51-158-186-93.ngrok-free.app` | Frontend public host | Get from ngrok on development, or use production deployment host | 32 | | PORT | `8888` | Server listening port (for local development) | Any number | 33 | | BOT_TOKEN | `1234567890:AsdApjfxhQJtdoRFU187NOir76rocvxxxxx` | Your bot token | Get from @BotFather | 34 | | PROVIDER_TOKEN | `12345678:TEST:xxxxxxxxxxxxx` | Payment API provider token | See [Payments](../docs/Payments.md) docs | 35 | | IS_TEST_ENVIRONMENT | `true` | Do we need to use [Telegram Test Environment](https://core.telegram.org/bots/webapps#using-bots-in-the-test-environment) | Set `true` on your local machine and `false` on production | 36 | | ALLOWED_ORIGINS | `*` | From which origins server should accept requests | Set "*" for local, for production you can restrict origins for security reasons | 37 | 38 | 3. Run 39 | 40 | | Command | Description | 41 | | -- | -- | 42 | | `yarn dev` | Start dev server with Hot Reloading | 43 | | `yarn build` | Compile TS and prepare bundle for production | 44 | | `yarn start` | Run production server | 45 | 46 | ## Development 47 | 48 | During development it is useful to use [Ngrok](https://ngrok.com) for forwarding your local port to the Internet. 49 | 50 | 1. Run `yarn dev` and see what port is occupied 51 | 2. In a separate terminal session run `ngrok http YOUR_PORT` 52 | 3. Insert given link to the Frontend app's `.env` 53 | 54 | ## Tech Stack and credits 55 | 56 | - Node.js 57 | - TypeScript 58 | - [Fastify](https://fastify.dev) — web server framework 59 | - [node-telegram-bot-api](https://github.com/yagop/node-telegram-bot-api) - Node.js module to interact with the official Telegram Bot API 60 | -------------------------------------------------------------------------------- /server/api/index.ts: -------------------------------------------------------------------------------- 1 | import { VercelRequest, VercelResponse } from '@vercel/node'; 2 | 3 | import api from '../src/index.js' 4 | 5 | /** 6 | * Serverless function handler 7 | */ 8 | export default async function handler(request: VercelRequest, response: VercelResponse): Promise { 9 | await api.ready(); 10 | 11 | api.emit(request, response); 12 | } 13 | 14 | /** 15 | * Handler for 'error' event that can be emitted by worker 16 | */ 17 | process.on('error', (err) => { 18 | console.error('\n\n❌ Process error: \n', err); 19 | }); 20 | 21 | /** 22 | * Catch unhandled exceptions 23 | */ 24 | process.on('uncaughtException', (err) => { 25 | console.error('\n\n❌ Uncaught Exception: \n', err); 26 | }); 27 | 28 | /** 29 | * Catch unhandled rejections 30 | */ 31 | process.on('unhandledRejection', (error) => { 32 | console.error('\n\n❌ Unhandled promise rejection: \n', error); 33 | }); 34 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "telebook-bot", 3 | "version": "0.0.1", 4 | "main": "dist/index.js", 5 | "repository": "https://github.com/neSpecc/telebook", 6 | "author": "Specc", 7 | "license": "MIT", 8 | "private": false, 9 | "type": "module", 10 | "scripts": { 11 | "build": "tsc -p tsconfig.json", 12 | "postbuild": "copyfiles -u 1 public/**/* dist/public", 13 | "start": "node ./dist/index.js", 14 | "start:dev": "yarn build && node dist/src/index.js", 15 | "dev": "nodemon --watch './src/**/*.ts' --ext ts --exec 'yarn start:dev'", 16 | "lint": "eslint . --ext .ts", 17 | "lint:fix": "eslint . --ext .ts --fix" 18 | }, 19 | "engines": { 20 | "node": ">=18.0.0" 21 | }, 22 | "dependencies": { 23 | "@fastify/cors": "^8.4.0", 24 | "@fastify/static": "^7.0.4", 25 | "copyfiles": "^2.4.1", 26 | "dotenv": "^16.3.1", 27 | "fastify": "^4.23.2", 28 | "node-telegram-bot-api": "^0.63.0", 29 | "nodemon": "^3.0.1" 30 | }, 31 | "devDependencies": { 32 | "@types/node": "^20.8.0", 33 | "@types/node-telegram-bot-api": "^0.61.8", 34 | "@vercel/node": "^3.0.7", 35 | "eslint": "^8.50.0", 36 | "eslint-config-codex": "^1.8.3", 37 | "typescript": "^5.2.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/src/api/bot.ts: -------------------------------------------------------------------------------- 1 | import TelegramBot from 'node-telegram-bot-api' 2 | import Config from '../config.js' 3 | 4 | /** 5 | * Class for working with Telegram Bot aAPI 6 | */ 7 | export default class Bot { 8 | /** 9 | * Telegram bot instance 10 | */ 11 | private bot: TelegramBot | null = null; 12 | 13 | /** 14 | * @param config - Config instance 15 | */ 16 | constructor(private readonly config: typeof Config) {} 17 | 18 | /** 19 | * Listen for messages from Telegram 20 | */ 21 | public async run(): Promise { 22 | this.bot = new TelegramBot(this.config.botToken, { 23 | // @ts-ignore — undocumented option 24 | testEnvironment: this.config.isTestEnvironment, 25 | }) 26 | 27 | console.log(`🤖 Bot is running...`); 28 | 29 | /** 30 | * Check if webhook is set 31 | * If not — set it 32 | */ 33 | try { 34 | const whInfo = await this.bot.getWebHookInfo() 35 | 36 | if ('url' in whInfo && whInfo.url !== '') { 37 | console.log('🤖 WebHook info: ', whInfo); 38 | } else { 39 | await this.setWebhook(); 40 | } 41 | } catch (e) { 42 | console.log('getWebHookInfo error', e); 43 | } 44 | 45 | /** 46 | * Listen for messages from Telegram 47 | */ 48 | this.bot.on('message', (msg) => { 49 | this.onMessage(msg); 50 | }) 51 | 52 | /** 53 | * Listen for pre_checkout_query event 54 | */ 55 | this.bot.on('pre_checkout_query', (update) => { 56 | this.preCheckoutQuery(update); 57 | }) 58 | 59 | return this.bot; 60 | } 61 | 62 | /** 63 | * Handler for messages from Telegram 64 | * 65 | * @param msg - object that the bot got from Telegram 66 | */ 67 | private async onMessage(msg: TelegramBot.Message): Promise { 68 | const chatId = msg.chat.id 69 | 70 | console.log('📥', msg); 71 | 72 | switch (msg.text) { 73 | case '/start': 74 | await this.replyStartMessage(chatId) 75 | return 76 | case '/help': 77 | await this.replyHelpMessage(chatId) 78 | return 79 | } 80 | 81 | if (msg.successful_payment) { 82 | console.log('💰 successful_payment', msg.successful_payment); 83 | 84 | await this.bot!.sendMessage(chatId, '*Your order was accepted! Have a nice trip! 🎉* \n\nIt is not a real payment, so you\'re not charged. The hotel exists only in our imagination. Thanks for testing! \n\nDiscover the source code and documentation: \nhttps://github.com/neSpecc/telebook', { 85 | parse_mode: 'Markdown', 86 | reply_markup: { 87 | inline_keyboard: [ 88 | [{ 89 | text: `🦄 Open ${this.config.appName}`, 90 | web_app: { 91 | url: this.config.webAppUrl, 92 | }, 93 | }], 94 | ], 95 | } 96 | }) 97 | 98 | return; 99 | } 100 | 101 | /** 102 | * Send message with inline query containing a link to the mini-app 103 | */ 104 | this.sendAppButton(chatId) 105 | } 106 | 107 | /** 108 | * Reply to the /start command 109 | * 110 | * @param chatId - chat id to send message to 111 | */ 112 | private async replyStartMessage(chatId: number): Promise { 113 | await this.bot!.sendMessage(chatId, 'Welcome to the hotel booking bot! Hope you enjoy the application I have 🏨', { 114 | reply_markup: { 115 | inline_keyboard: [ 116 | [{ 117 | text: `🦄 Open ${this.config.appName}`, 118 | web_app: { 119 | url: this.config.webAppUrl, 120 | }, 121 | }], 122 | ], 123 | } 124 | }); 125 | } 126 | 127 | /** 128 | * Reply to the /help command 129 | * 130 | * @param chatId - chat id to send message to 131 | */ 132 | private async replyHelpMessage(chatId: number): Promise { 133 | await this.bot!.sendMessage(chatId, 'Actually I\'m just an example bot, so all I can do is to send you a link to the mini-app 🤖', { 134 | reply_markup: { 135 | inline_keyboard: [ 136 | [{ 137 | text: `🦄 Open ${this.config.appName}`, 138 | web_app: { 139 | url: this.config.webAppUrl, 140 | }, 141 | }], 142 | ], 143 | } 144 | }); 145 | } 146 | 147 | /** 148 | * Send message with inline query containing a link to the mini-app 149 | * 150 | * @param chatId - chat id to send message to 151 | */ 152 | private async sendAppButton(chatId: number): Promise { 153 | await this.bot!.sendMessage(chatId, 'Click the button below to launch an app', { 154 | reply_markup: { 155 | inline_keyboard: [ 156 | [{ 157 | text: `🦄 Open ${this.config.appName}`, 158 | web_app: { 159 | url: this.config.webAppUrl, 160 | }, 161 | }], 162 | ], 163 | }, 164 | }) 165 | } 166 | 167 | 168 | /** 169 | * Handler for pre_checkout_query event 170 | * 171 | * Got when user clicks on "Pay" button 172 | * We need to validate order here and answer with answerPreCheckoutQuery() in 10sec 173 | * 174 | * @see https://core.telegram.org/bots/payments#7-pre-checkout 175 | * 176 | * @param update - object that the bot got from Telegram 177 | */ 178 | private preCheckoutQuery(update: TelegramBot.PreCheckoutQuery): void { 179 | console.log('🤖 pre_checkout_query: ', update); 180 | 181 | /** 182 | * @todo validate order here: get order from database and compare with update 183 | */ 184 | 185 | this.bot!.answerPreCheckoutQuery(update.id, true) 186 | } 187 | 188 | /** 189 | * Set webhook for Telegram bot 190 | */ 191 | private async setWebhook(): Promise { 192 | try { 193 | console.log('🤖 setting a webhook to @BotFather: ', `${this.config.publicHost}/bot`); 194 | 195 | const setHookResponse = await this.bot!.setWebHook(`${this.config.publicHost}/bot`); 196 | 197 | if (setHookResponse === true){ 198 | console.log('🤖 webhook set ᕕ( ᐛ )ᕗ'); 199 | } else { 200 | console.warn('🤖 webhook not set (╯°□°)╯︵ ┻━┻'); 201 | console.warn('setHookResponse', setHookResponse); 202 | } 203 | } catch (e) { 204 | console.warn('Can not set Telegram Webhook') 205 | console.warn(e) 206 | } 207 | } 208 | 209 | /** 210 | * To send several messages at once we need to add a small timeout between them 211 | * 212 | * @param chatId - chat id to send message to 213 | * @param messages - array of messages to send 214 | */ 215 | private async sendMessageQueue(chatId: TelegramBot.ChatId, messages: {text: string, options?: TelegramBot.SendMessageOptions}[]): Promise { 216 | messages.forEach((message, index) => { 217 | setTimeout(async () => { 218 | await this.bot!.sendMessage(chatId, message.text, message.options) 219 | }, index * 500); 220 | }) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /server/src/api/http.ts: -------------------------------------------------------------------------------- 1 | import Config from '../config.js' 2 | import Fastify from 'fastify' 3 | import cors from '@fastify/cors'; 4 | import TelegramBot from 'node-telegram-bot-api' 5 | import Router from './router/index.js' 6 | import fastifyStatic from '@fastify/static'; 7 | import path from 'node:path'; 8 | import { fileURLToPath } from 'node:url'; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | 13 | /** 14 | * HTTP server implementation using Fastify. 15 | */ 16 | export default class HttpApi { 17 | private server = Fastify() 18 | /** 19 | * Constructor 20 | * 21 | * @param config - Config instance 22 | */ 23 | constructor(private readonly config: typeof Config, private readonly bot: TelegramBot) {} 24 | 25 | /** 26 | * Method used to know when the fastify instance is ready 27 | */ 28 | public async ready(): Promise { 29 | return this.server.ready() 30 | } 31 | 32 | /** 33 | * Run HTTP server 34 | */ 35 | public async run(): Promise { 36 | this.server.register(Router, { 37 | config: this.config, 38 | bot: this.bot, 39 | }) 40 | 41 | this.server.register(fastifyStatic, { 42 | root: path.join(__dirname, '../../public'), 43 | }); 44 | 45 | this.server.setErrorHandler((error, request, reply) => { 46 | console.error(error) 47 | 48 | reply.status(500).send({ 49 | error: 'Internal Server Error', 50 | }) 51 | }) 52 | 53 | /** 54 | * In test environment we listen for requests as usual 55 | * In production we don't listen for requests, because we use serverless deployment 56 | */ 57 | if (this.config.isTestEnvironment) { 58 | this.listen() 59 | } 60 | 61 | /** 62 | * Allow cors for allowed origins from config 63 | */ 64 | await this.server.register(cors, { 65 | origin: this.config.allowedOrigins, 66 | }); 67 | } 68 | 69 | /** 70 | * Listen for requests 71 | * 72 | * Used only in test environment 73 | */ 74 | private listen(): void { 75 | this.server.listen({ 76 | port: parseInt(this.config.port), 77 | }, (err: Error | null, address: string) => { 78 | if (err) { 79 | console.error(err) 80 | process.exit(1) 81 | } 82 | console.log(`🫡 ${this.config.appName || 'Server'} API listening at ${address}`) 83 | }) 84 | } 85 | 86 | /** 87 | * Emit request to the server 88 | * Used only in serverless environment 89 | * 90 | * @param request - Request object 91 | * @param response - Response object 92 | */ 93 | emit(request: any, response: any): void { 94 | this.server.server.emit('request', request, response) 95 | } 96 | } 97 | 98 | -------------------------------------------------------------------------------- /server/src/api/router/index.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance, FastifyReply, FastifyRequest, FastifyServerOptions } from 'fastify' 2 | import { notify } from '../../infra/utils/notify/index.js'; 3 | import Config from '../../config.js'; 4 | import type TelegramBot from 'node-telegram-bot-api'; 5 | 6 | /** 7 | * Router options. Our custom plus Fastify's. 8 | */ 9 | interface RouterOptions extends FastifyServerOptions { 10 | /** 11 | * Application config instance 12 | */ 13 | config: typeof Config; 14 | 15 | /** 16 | * Telegram bot instance 17 | */ 18 | bot: TelegramBot; 19 | } 20 | 21 | /** 22 | * All routes handlers are defined as a Fastify plugin. 23 | * 24 | * @see https://www.fastify.io/docs/latest/Plugins/ 25 | * 26 | * @param fastify - Fastify instance 27 | * @param opts - Server options 28 | */ 29 | export default async function router(fastify: FastifyInstance, opts: RouterOptions) { 30 | 31 | const { bot, config } = opts; 32 | 33 | /** 34 | * Home route for the API: GET / 35 | */ 36 | fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => { 37 | return reply 38 | .send({ 39 | message: 'Telebook API is ready', 40 | }); 41 | }) 42 | 43 | /** 44 | * Route for receiving Telegram bot updates (set via setWebHook, @see bot.ts) 45 | */ 46 | fastify.post(`/bot`, (req, res) => { 47 | try { 48 | const update = req.body as TelegramBot.Update; 49 | 50 | if ('message' in update && update.message !== undefined && 'from' in update.message && update.message.from !== undefined) { 51 | console.log('🤖 ← ', `@${update.message.from.username || (update.message.from.first_name + update.message.from.last_name) }`, update.message.text || ''); 52 | } else { 53 | console.log('🤖 ← ', update); 54 | } 55 | 56 | bot.processUpdate(req.body as TelegramBot.Update); 57 | } catch (e) { 58 | console.log('error while update processing', e); 59 | } 60 | 61 | res.code(200).send("OK") 62 | }); 63 | 64 | /** 65 | * Create invoice route: POST /createInvoice 66 | */ 67 | fastify.post('/createInvoice', async (request: FastifyRequest, reply: FastifyReply) => { 68 | const logPrefix = '🛍️ POST /createInvoice: '; 69 | 70 | console.log(`${logPrefix}`, request.body) 71 | 72 | const { 73 | title, 74 | description, 75 | prices, 76 | need_name, 77 | photo_url, 78 | photo_size, 79 | photo_width, 80 | photo_height, 81 | } = request.body as any; 82 | 83 | const payload = 'Order #' + Math.random().toString(36).substring(7); 84 | 85 | notify(`${logPrefix} \n\n **${payload}: ${title} ${description}`); 86 | 87 | try { 88 | // @ts-ignore — 'createInvoiceLink supported by the library, but does not defined in types 89 | const invoiceLink = await bot.createInvoiceLink(title, description, payload, config.providerToken, 'USD', prices, { 90 | need_name, 91 | photo_url, 92 | photo_size, 93 | photo_width, 94 | photo_height, 95 | }) 96 | 97 | notify(`${logPrefix} Success ${invoiceLink} `) 98 | 99 | return reply 100 | .send({ 101 | invoiceLink 102 | }); 103 | } catch (e) { 104 | console.log('error', (e as Error).message); 105 | 106 | notify(`Failed to create invoice: ${(e as Error).message}`) 107 | 108 | reply 109 | .code(500) 110 | .send({ 111 | message: (e as Error).message, 112 | }); 113 | } 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /server/src/config.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | 3 | if (!process.env.BOT_TOKEN) { 4 | throw new Error('BOT_TOKEN is not defined. Please set it in .env file.') 5 | } 6 | 7 | if (!process.env.PROVIDER_TOKEN) { 8 | throw new Error('PROVIDER_TOKEN is not defined. Please set it in .env file.') 9 | } 10 | 11 | if (!process.env.WEB_APP_URL) { 12 | throw new Error('WEB_APP_URL is not defined. Please set it in .env file.') 13 | } 14 | 15 | /** 16 | * We use .env for configuration. 17 | */ 18 | export default { 19 | appName: process.env.APP_NAME || 'Server', 20 | publicHost: process.env.PUBLIC_HOST || 'http://localhost:3000', 21 | webAppUrl: process.env.WEB_APP_URL, 22 | botToken: process.env.BOT_TOKEN, 23 | providerToken: process.env.PROVIDER_TOKEN, 24 | allowedOrigins: process.env.ALLOWED_ORIGINS || '*', 25 | isTestEnvironment: process.env.IS_TEST_ENVIRONMENT === 'true', 26 | port: process.env.PORT || '3000', 27 | } 28 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import Config from './config.js' 2 | import HttpApi from './api/http.js' 3 | import Bot from './api/bot.js' 4 | 5 | /** 6 | * Create Telegram Bot backend 7 | */ 8 | const bot = new Bot(Config) 9 | 10 | /** 11 | * Listen for messages from Telegram 12 | */ 13 | const botApi = await bot.run(); 14 | 15 | /** 16 | * Create HTTP interface 17 | */ 18 | const api = new HttpApi(Config, botApi) 19 | 20 | /** 21 | * Listen for HTTP requests 22 | */ 23 | api.run() 24 | 25 | /** 26 | * Export HTTP server for serverless calls 27 | */ 28 | export default api 29 | 30 | /** 31 | * Handler for 'error' event that can be emitted by worker 32 | */ 33 | process.on('error', (err) => { 34 | console.error('\n\n❌ Process error: \n', err); 35 | }); 36 | 37 | /** 38 | * Catch unhandled exceptions 39 | */ 40 | process.on('uncaughtException', (err) => { 41 | console.error('\n\n❌ Uncaught Exception: \n', err); 42 | }); 43 | 44 | /** 45 | * Catch unhandled rejections 46 | */ 47 | process.on('unhandledRejection', (error) => { 48 | console.error('\n\n❌ Unhandled promise rejection: \n', error); 49 | }); 50 | -------------------------------------------------------------------------------- /server/src/infra/utils/notify/index.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path" 2 | import { fileURLToPath } from 'node:url' 3 | import { dirname } from 'node:path' 4 | import { fork } from 'node:child_process' 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)) 7 | 8 | /** 9 | * Send a message to telegram 10 | * 11 | * @param text The text to send 12 | */ 13 | export function notify(text: string): void { 14 | /** 15 | * Call internal notify in child process 16 | */ 17 | // const child = fork(resolve(__dirname, 'notify.js')) 18 | 19 | // child.send(text) 20 | } 21 | -------------------------------------------------------------------------------- /server/src/infra/utils/notify/notify.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Listen to messages from the parent process 3 | */ 4 | process.on('message', (text: string) => { 5 | /** 6 | * Send a message to telegram 7 | * 8 | * @param text The text to send 9 | */ 10 | _notify(text) 11 | }) 12 | 13 | /** 14 | * Send a message to telegram 15 | * 16 | * @param text The text to send 17 | */ 18 | function _notify(text: string): void { 19 | /** 20 | * Send curl -X POST https://notify.bot.ifmo.su/u/ABCD1234 -d "message=Hello world" 21 | */ 22 | const url = process.env.NOTIFY_WEBHOOK 23 | 24 | if (!url) { 25 | console.info('Notify webhook is not set') 26 | console.info('💌 ' + text) 27 | return 28 | } 29 | 30 | const options = { 31 | method: 'POST', 32 | headers: { 33 | 'Content-Type': 'application/x-www-form-urlencoded' 34 | }, 35 | body: `message=${text}&parse_mode=Markdown` 36 | } 37 | 38 | fetch(url, options).then(response => response.text()).then((response) => { 39 | console.info('💌 Sent', response) 40 | }).catch((error) => { 41 | console.error('💌 ❌ Sending failed', error) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /server/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/(.*)", 5 | "destination": "/api/index.ts" 6 | } 7 | ] 8 | } 9 | --------------------------------------------------------------------------------