├── backend ├── app │ ├── __init__.py │ ├── auth.py │ ├── bot.py │ └── main.py ├── requirements.txt ├── data │ ├── info.json │ ├── categories.json │ └── menu │ │ ├── popular.json │ │ ├── ice-cream.json │ │ ├── pasta.json │ │ ├── burgers.json │ │ ├── coffee.json │ │ └── pizza.json └── .gitignore ├── .gitignore ├── screenshots └── laurel-cafe-mini-app.png ├── frontend ├── js │ ├── index.js │ ├── utils │ │ ├── array.js │ │ ├── currency.js │ │ ├── dom.js │ │ └── snackbar.js │ ├── routing │ │ ├── route.js │ │ └── router.js │ ├── requests │ │ └── requests.js │ ├── telegram │ │ └── telegram.js │ ├── jquery │ │ └── extensions.js │ ├── pages │ │ ├── category.js │ │ ├── main.js │ │ ├── details.js │ │ └── cart.js │ └── cart │ │ └── cart.js ├── icons │ ├── icon-transparent.svg │ ├── icon-star.svg │ ├── icon-time.svg │ ├── icon-coffee.svg │ ├── icon-icecream.svg │ ├── icon-pasta.svg │ ├── icon-pizza.svg │ ├── icon-burger.svg │ └── logo-laurel.svg ├── pages │ ├── cart.html │ ├── details.html │ ├── category.html │ └── main.html ├── index.html ├── lottie │ └── empty-cart.json └── css │ └── index.css ├── LICENSE.md └── README.md /backend/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local Netlify folder 2 | .netlify 3 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuzefovichalex/tma-cafe/HEAD/backend/requirements.txt -------------------------------------------------------------------------------- /screenshots/laurel-cafe-mini-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuzefovichalex/tma-cafe/HEAD/screenshots/laurel-cafe-mini-app.png -------------------------------------------------------------------------------- /frontend/js/index.js: -------------------------------------------------------------------------------- 1 | import { handleLocation } from "./routing/router.js"; 2 | 3 | // Load root route on app load. 4 | handleLocation(); -------------------------------------------------------------------------------- /frontend/icons/icon-transparent.svg: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /frontend/js/utils/array.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Removes all found items match provided predicate. 3 | * @param {Array} array Target array. 4 | * @param {*} predicate Condition for item selection. 5 | */ 6 | export function removeIf(array, predicate) { 7 | for (var i = 0; i < array.length; i++) { 8 | if (predicate(array[i])) { 9 | array.splice(i, 1); 10 | i--; 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /backend/data/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "coverImage": "https://images.unsplash.com/photo-1554118811-1e0d58224f24?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 3 | "logoImage": "icons/logo-laurel.svg", 4 | "name": "Laurel", 5 | "kitchenCategories": "American Barbeque, Dinner, Italian", 6 | "rating": "4.3 (212)", 7 | "cookingTime": "5-15 mins", 8 | "status": "Open" 9 | } -------------------------------------------------------------------------------- /frontend/js/utils/currency.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create display (user-friendly) string cost for the cost in minimal currency unit. 3 | * For this app calculations are in USD. 4 | * Example: 1000 => $10.00 5 | * @param {*} costInMinimalUnit Cost in minimal unit (cents). 6 | * @returns Display cost string that may be used in the UI. 7 | */ 8 | export function toDisplayCost(costInMinimalUnit) { 9 | return `\$${(costInMinimalUnit / 100.0).toFixed(2)}`; 10 | } -------------------------------------------------------------------------------- /frontend/icons/icon-star.svg: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /frontend/icons/icon-time.svg: -------------------------------------------------------------------------------- 1 | 6 | 9 | -------------------------------------------------------------------------------- /backend/data/categories.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "burgers", 4 | "icon": "icons/icon-burger.svg", 5 | "name": "Burgers", 6 | "backgroundColor": "#F79683" 7 | }, 8 | { 9 | "id": "pizza", 10 | "icon": "icons/icon-pizza.svg", 11 | "name": "Pizza", 12 | "backgroundColor": "#F5DEB3" 13 | }, 14 | { 15 | "id": "pasta", 16 | "icon": "icons/icon-pasta.svg", 17 | "name": "Pasta", 18 | "backgroundColor": "#8FBC8F" 19 | }, 20 | { 21 | "id": "ice-cream", 22 | "icon": "icons/icon-icecream.svg", 23 | "name": "Ice Cream", 24 | "backgroundColor": "#E6E6FA" 25 | }, 26 | { 27 | "id": "coffee", 28 | "icon": "icons/icon-coffee.svg", 29 | "name": "Coffee", 30 | "backgroundColor": "#D2B48C" 31 | } 32 | ] -------------------------------------------------------------------------------- /frontend/icons/icon-coffee.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/icons/icon-icecream.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/js/routing/route.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Route is a representation of navigation destination. It holds such data as 3 | * dest (e.g. 'cart') and contentPath (e.g. '/pages/cart.html'). 4 | * This is a base class that does nothing itself. It should be overriden by passing 5 | * dest and contentPath values, as well as overriding method load(params). 6 | */ 7 | export class Route { 8 | constructor(dest, contentPath) { 9 | this.dest = dest; 10 | this.contentPath = contentPath; 11 | } 12 | 13 | /** 14 | * Load content. This method is called when the HTML of the route defined by 15 | * contentPath param is loaded and added to DOM. 16 | * @param {string} params Params that are needed for content load (e.g. some ID). 17 | * You can pass it by calling navigateTo(dest, params). 18 | * In JSON format. 19 | */ 20 | load(params) { } 21 | 22 | /** 23 | * This method is called before the route is changed. It's a good place 24 | * to remove listeners or cancel the request. 25 | */ 26 | onClose() { } 27 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Alexander Yuzefovich 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /frontend/icons/icon-pasta.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/pages/cart.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |

Your cart is empty

6 |

It's time to order something delicious!

7 |
8 | 9 | 24 |
-------------------------------------------------------------------------------- /frontend/icons/icon-pizza.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/js/requests/requests.js: -------------------------------------------------------------------------------- 1 | // Set base URL depending on your environment. 2 | // Don't forget to add it to allowed origins on backend. 3 | const baseUrl = ''; 4 | 5 | /** 6 | * Performs GET request. 7 | * @param {string} endpoint API endpoint path, e.g. '/info'. 8 | * @param {*} onSuccess Callback on successful request. 9 | */ 10 | export function get(endpoint, onSuccess) { 11 | $.ajax({ 12 | url: baseUrl + endpoint, 13 | dataType: "json", 14 | success: result => onSuccess(result) 15 | }); 16 | } 17 | 18 | /** 19 | * Performs POST request. 20 | * @param {string} endpoint API endpoint path, e.g. '/order'. 21 | * @param {string} data Request body in JSON format. 22 | * @param {*} onResult Callback on request result. In case of success, returns 23 | * result = { ok: true, data: }, otherwise 24 | * result = { ok: false, error: 'Something went wrong' }. 25 | */ 26 | export function post(endpoint, data, onResult) { 27 | $.ajax({ 28 | type: 'POST', 29 | url: baseUrl + endpoint, 30 | data: data, 31 | contentType: 'application/json; charset=utf-8', 32 | dataType: 'json', 33 | success: result => onResult({ ok: true, data: result}), 34 | error: xhr => onResult({ ok: false, error: 'Something went wrong.'}) 35 | }) 36 | } -------------------------------------------------------------------------------- /backend/app/auth.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | from operator import itemgetter 4 | from urllib.parse import parse_qsl 5 | 6 | def validate_auth_data(bot_token: str, auth_data: str) -> bool: 7 | """Validates initData from the Telegram Mini App. 8 | You can find more info here: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app. 9 | 10 | Args: 11 | bot_token: The token you received (will receive) when creating a bot in BotFather. 12 | auth_data: Chain of all received fields, sorted alphabetically, in the format key= 13 | with a line feed character ('\\n', 0x0A) used as separator - 14 | e.g., 'auth_date=\\nquery_id=\\nuser='. 15 | 16 | Returns: 17 | True if the provided auth_data valid, False otherwise. 18 | """ 19 | try: 20 | parsed_data = dict(parse_qsl(auth_data, strict_parsing=True)) 21 | except ValueError: 22 | return False 23 | 24 | if "hash" not in parsed_data: 25 | return False 26 | hash_ = parsed_data.pop("hash") 27 | 28 | data_check_string = "\n".join( 29 | f"{k}={v}" for k, v in sorted(parsed_data.items(), key=itemgetter(0)) 30 | ) 31 | secret_key = hmac.new( 32 | key=b"WebAppData", 33 | msg=bot_token.encode(), 34 | digestmod=hashlib.sha256 35 | ) 36 | calculated_hash = hmac.new( 37 | key=secret_key.digest(), 38 | msg=data_check_string.encode(), 39 | digestmod=hashlib.sha256 40 | ).hexdigest() 41 | return calculated_hash == hash_ -------------------------------------------------------------------------------- /frontend/icons/icon-burger.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/pages/details.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 |

7 |

500g

8 |
9 |

10 |

Price

11 |
12 |
13 |

14 |
15 |
16 | 17 |
18 | 19 |

1

20 | 21 |
22 |
23 | 24 | 27 |
-------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Laurel Cafe 12 | 13 | 14 | 15 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
32 | 33 |
34 | 35 | 36 | -------------------------------------------------------------------------------- /frontend/js/telegram/telegram.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Wrapper for simplifying usage of Telegram.WebApp class and it's methods. 3 | */ 4 | export class TelegramSDK { 5 | 6 | static #mainButtonClickCallback 7 | static #backButtonClickCallback 8 | 9 | static getInitData() { 10 | return Telegram.WebApp.initData || ''; 11 | } 12 | 13 | static showMainButton(text, onClick) { 14 | // $('#telegram-button').text(text); 15 | // $('#telegram-button').off('click').on('click', onClick); 16 | // $('#telegram-button').show(); 17 | Telegram.WebApp.MainButton 18 | .offClick(this.#mainButtonClickCallback) 19 | .setParams({ 20 | text: text, 21 | is_visible: true 22 | }) 23 | .onClick(onClick); 24 | this.#mainButtonClickCallback = onClick; 25 | } 26 | 27 | static setMainButtonLoading(isLoading) { 28 | if (isLoading) { 29 | Telegram.WebApp.MainButton.showProgress(false); 30 | } else { 31 | Telegram.WebApp.MainButton.hideProgress(); 32 | } 33 | } 34 | 35 | static hideMainButton() { 36 | //$('#telegram-button').hide(); 37 | Telegram.WebApp.MainButton.hide(); 38 | } 39 | 40 | static showBackButton(onClick) { 41 | Telegram.WebApp.BackButton 42 | .offClick(this.#backButtonClickCallback) 43 | .onClick(onClick) 44 | .show(); 45 | this.#backButtonClickCallback = onClick; 46 | } 47 | 48 | static hideBackButton() { 49 | Telegram.WebApp.BackButton.hide(); 50 | } 51 | 52 | static impactOccured(style) { 53 | if (Telegram.WebApp.isVersionAtLeast('6.1')) { 54 | Telegram.WebApp.HapticFeedback.impactOccurred(style); 55 | } 56 | } 57 | 58 | static notificationOccured(style) { 59 | if (Telegram.WebApp.isVersionAtLeast('6.1')) { 60 | Telegram.WebApp.HapticFeedback.notificationOccurred(style); 61 | } 62 | } 63 | 64 | static openInvoice(url, callback) { 65 | Telegram.WebApp.openInvoice(url, callback); 66 | } 67 | 68 | static expand() { 69 | Telegram.WebApp.expand(); 70 | } 71 | 72 | static close() { 73 | Telegram.WebApp.close(); 74 | } 75 | 76 | } -------------------------------------------------------------------------------- /frontend/js/jquery/extensions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Extension function for jQuery. May be used instead of the standard 3 | * $('#button').on('click') function to show ripple effect on the clicked 4 | * element. Provides single click listener: that means it disables all the 5 | * previously set click listener before setting its own. 6 | * 7 | * Note: The target element should have position: relative property for the 8 | * correct behavior. 9 | * 10 | * @param {*} callback Callback called on element click. It's called after attaching 11 | * and running ripple animation. 12 | * @returns jQuery object. 13 | */ 14 | $.fn.clickWithRipple = function(callback) { 15 | return this.off('click').on('click', (e) => { 16 | const clickableTarget = $(this); 17 | const ripple = $(""); 18 | const rect = clickableTarget[0].getBoundingClientRect(); 19 | const size = Math.max(rect.width, rect.height); 20 | const x = e.clientX - rect.left - size / 2; 21 | const y = e.clientY - rect.top - size / 2; 22 | 23 | ripple.css({ 24 | width: `${size}px`, 25 | height: `${size}px`, 26 | left: `${x}px`, 27 | top: `${y}px` 28 | }); 29 | 30 | clickableTarget.append(ripple); 31 | 32 | ripple.transition( 33 | { opacity: 0, scale: 4}, 34 | 600, 35 | 'linear', 36 | () => ripple.remove() 37 | ); 38 | 39 | if (callback != null) { 40 | callback(); 41 | } 42 | }); 43 | } 44 | 45 | /** 46 | * Runs 'boop' animation on target element. Boop animation is an animation 47 | * of scaling to some point and returning back. For example, it may be used 48 | * for animating some kind of counters. 49 | * @returns jQuery object. 50 | */ 51 | $.fn.boop = function() { 52 | return this.transition({ scale: 1.05 }, 75, () => { 53 | this.transition({ scale: 1 }, 75); 54 | }); 55 | } 56 | 57 | /** 58 | * Changes the text of target element with 'boop' animation. If current text 59 | * equals the new one, the animation won't be started. 60 | * @param {*} text Text to set. 61 | * @returns jQuery object. 62 | */ 63 | $.fn.textBoop = function(text) { 64 | if (this.text() != text) { 65 | this.text(text).boop(); 66 | } 67 | return this; 68 | } -------------------------------------------------------------------------------- /frontend/js/utils/dom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create items from the provided data and template and append them to container, 3 | * when all child image are loaded. This function was developed specifically for the lists 4 | * with images. 5 | * @param {string} containerSelector The selector of the parent container, where items should be placed. 6 | * @param {string} templateSelector The selector of the item's