├── 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 | 10 | 11 | 12 | 13 | 14 | Small 15 | $2.00 16 | 17 | 18 | remove 19 | 1 20 | add 21 | 22 | 23 | 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 | remove 19 | 1 20 | add 21 | 22 | 23 | 24 | 25 | 26 | 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 . 7 | * @param {string} loadableImageSelector The selector for the image placed somewher in . 8 | * @param {Array} data Array of items. 9 | * @param {*} templateSetup Lambda for custom template filling, e.g. setting CSS, text, etc. 10 | */ 11 | export function replaceShimmerContent(containerSelector, templateSelector, loadableImageSelector, data, templateSetup) { 12 | let templateHtml = $(templateSelector).html(); 13 | var imageLoaded = 0; 14 | let imageShouldBeLoaded = data.length; 15 | let filledTemplates = []; 16 | data.forEach(dataItem => { 17 | let filledTemplate = $(templateHtml); 18 | templateSetup(filledTemplate, dataItem); 19 | filledTemplate.find(loadableImageSelector).on('load', () => { 20 | imageLoaded++; 21 | if (imageLoaded == imageShouldBeLoaded) { 22 | fillContainer(containerSelector, filledTemplates); 23 | } 24 | }); 25 | filledTemplates.push(filledTemplate); 26 | }); 27 | } 28 | 29 | /** 30 | * Replace existing container elements with the new ones. 31 | * @param {string} selector Parent container selector. 32 | * @param {*} elements Instances of elements in any format, supported by jQuery.append() method. 33 | */ 34 | export function fillContainer(selector, elements) { 35 | let container = $(selector); 36 | container.empty(); 37 | elements.forEach(el => container.append(el)); 38 | } 39 | 40 | /** 41 | * Load image with shimmer effect while loading. 42 | * @param {*} imageElement jQuery element of the image. 43 | * @param {*} imageUrl Image URL to load. 44 | */ 45 | export function loadImage(imageElement, imageUrl) { 46 | if (imageElement != null) { 47 | if (!imageElement.hasClass('shimmer')) { 48 | imageElement.addClass('shimmer'); 49 | } 50 | imageElement.attr('src', imageUrl); 51 | imageElement.on('load', () => imageElement.removeClass('shimmer')); 52 | } 53 | } -------------------------------------------------------------------------------- /frontend/js/pages/category.js: -------------------------------------------------------------------------------- 1 | import { Route } from "../routing/route.js"; 2 | import { navigateTo } from "../routing/router.js"; 3 | import { get } from "../requests/requests.js"; 4 | import { TelegramSDK } from "../telegram/telegram.js"; 5 | import { replaceShimmerContent } from "../utils/dom.js"; 6 | import { Cart } from "../cart/cart.js"; 7 | 8 | /** 9 | * Page for displaying menu list for selected category. 10 | */ 11 | export class CategoryPage extends Route { 12 | constructor() { 13 | super('category', '/pages/category.html') 14 | } 15 | 16 | load(params) { 17 | TelegramSDK.expand(); 18 | 19 | const portionCount = Cart.getPortionCount() 20 | if (portionCount > 0) { 21 | TelegramSDK.showMainButton( 22 | `MY CART • ${this.#getDisplayPositionCount(portionCount)}`, 23 | () => navigateTo('cart') 24 | ) 25 | } else { 26 | TelegramSDK.hideMainButton(); 27 | } 28 | 29 | if (params != null) { 30 | const parsedParams = JSON.parse(params); 31 | this.#loadMenu(parsedParams.id); 32 | } else { 33 | console.log('Params must not be null and must contain category ID.') 34 | } 35 | } 36 | 37 | #loadMenu(categoryId) { 38 | get('/menu/' + categoryId, (cafeItems) => { 39 | this.#fillMenu(cafeItems); 40 | }); 41 | } 42 | 43 | #fillMenu(cafeItems) { 44 | replaceShimmerContent( 45 | '#cafe-category', 46 | '#cafe-item-template', 47 | '#cafe-item-image', 48 | cafeItems, 49 | (template, cafeItem) => { 50 | template.attr('id', cafeItem.name); 51 | template.find('#cafe-item-image').attr('src', cafeItem.image); 52 | template.find('#cafe-item-name').text(cafeItem.name); 53 | template.find('#cafe-item-description').text(cafeItem.description); 54 | template.on('click', () => { 55 | const params = JSON.stringify({'id': cafeItem.id}); 56 | navigateTo('details', params); 57 | }); 58 | } 59 | ) 60 | } 61 | 62 | #getDisplayPositionCount(positionCount) { 63 | return positionCount == 1 ? `${positionCount} POSITION` : `${positionCount} POSITIONS`; 64 | } 65 | 66 | } -------------------------------------------------------------------------------- /backend/data/menu/popular.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "burger-1", 4 | "image": "https://images.unsplash.com/photo-1568901346375-23c9450c58cd?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 5 | "name": "Hamburger", 6 | "description": "From aged beef, with homemade ketchup, romaine lettuce and tomato.", 7 | "variants": [ 8 | { 9 | "id": "s", 10 | "name": "Small", 11 | "cost": "1199", 12 | "weight": "200g" 13 | 14 | }, 15 | { 16 | "id": "l", 17 | "name": "Large", 18 | "cost": "1399", 19 | "weight": "280g" 20 | } 21 | ] 22 | }, 23 | { 24 | "id": "pizza-1", 25 | "image": "https://images.unsplash.com/photo-1513104890138-7c749659a591?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 26 | "name": "Bianca Fresca", 27 | "description": "White pizza with garlic infused olive oil, goat cheese, fresh mozzarella fior di latte, prosciutto fired off then topped with arugula and shaved parmesan.", 28 | "variants": [ 29 | { 30 | "id": "s", 31 | "name": "Small", 32 | "cost": "1479", 33 | "weight": "410g" 34 | }, 35 | { 36 | "id": "m", 37 | "name": "Medium", 38 | "cost": "1699", 39 | "weight": "600g" 40 | }, 41 | { 42 | "id": "l", 43 | "name": "Large", 44 | "cost": "1799", 45 | "weight": "800g" 46 | } 47 | ] 48 | }, 49 | { 50 | "id": "pasta-1", 51 | "image": "https://images.unsplash.com/photo-1516685018646-549198525c1b?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 52 | "name": "Spaghetti alla Carbonara", 53 | "description": "Spaghetti in a creamy sauce made with egg, prosciutto, sautéed onions, Parmesan cheese and black pepper.", 54 | "variants": [ 55 | { 56 | "id": "n", 57 | "name": "Normal", 58 | "cost": "1109", 59 | "weight": "300g" 60 | }, 61 | { 62 | "id": "g", 63 | "name": "Gluten Free", 64 | "cost": "1290", 65 | "weight": "300g" 66 | } 67 | ] 68 | } 69 | ] -------------------------------------------------------------------------------- /frontend/js/utils/snackbar.js: -------------------------------------------------------------------------------- 1 | import { TelegramSDK } from "../telegram/telegram.js"; 2 | 3 | /** 4 | * Util class for showing Snackbar. It's goal to handle Snackbar instance and keep 5 | * it single for the target container. The class allow to show Snackbar without direct 6 | * definition in your HTML. 7 | */ 8 | export class Snackbar { 9 | 10 | /** 11 | * Map of pairs: Snackbar ID in format ('${parentId}-snackbar') 12 | * to it's animation timeout ID. Storing the IDs allows to not create the 13 | * Snackbar if it's already added to DOM, but update it's content and styling. 14 | */ 15 | static #snackbarIds = { }; 16 | 17 | /** 18 | * Displays Snackbar at the bottom of target container. 19 | * @param {*} parentId The id of the target (parent) container. Without '#' symbol. 20 | * @param {*} text Text for displaying in the Snackbar. 21 | * @param {*} params CSS parameters you'd like to apply to the Snackbar. 22 | */ 23 | static showSnackbar(parentId, text, params) { 24 | const currentSnackbarId = this.#snackbarIds[`${parentId}-snackbar`]; 25 | if (currentSnackbarId != null) { 26 | console.log('update'); 27 | 28 | clearTimeout(currentSnackbarId); 29 | 30 | const snackbar = $(`#${parentId}-snackbar`); 31 | 32 | if (params != null) { 33 | snackbar.css(params); 34 | }; 35 | 36 | snackbar.text(); 37 | 38 | this.#hideSnackbarWithDelay(snackbar); 39 | } else { 40 | const snackbar = $(`${text}`); 41 | 42 | if (params != null) { 43 | snackbar.css(params); 44 | }; 45 | 46 | $(`#${parentId}-snackbar`).remove(); 47 | $(`#${parentId}`).append(snackbar); 48 | 49 | snackbar 50 | .transition( 51 | { opacity: 1, scale: 1 }, 52 | 250, 53 | () => this.#hideSnackbarWithDelay(snackbar) 54 | ); 55 | } 56 | 57 | TelegramSDK.notificationOccured('success'); 58 | } 59 | 60 | /** 61 | * Hide the snackbar instance after some amount of time (2000ms). 62 | * @param {*} snackbar Showing snackbar instance. 63 | */ 64 | static #hideSnackbarWithDelay(snackbar) { 65 | this.#snackbarIds[snackbar.attr('id')] = setTimeout(() => { 66 | snackbar.transition({ 67 | opacity: 0, 68 | scale: 0.24 69 | }, 200, () => { 70 | snackbar.remove(); 71 | this.#snackbarIds[snackbar.attr('id')] = null; 72 | }); 73 | }, 2000); 74 | } 75 | 76 | } -------------------------------------------------------------------------------- /frontend/pages/category.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /backend/data/menu/ice-cream.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "ice-cream-1", 4 | "image": "https://images.unsplash.com/photo-1517093157656-b9eccef91cb1?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 5 | "name": "Black Cherry", 6 | "description": "Black cherry ice cream with whole black cherries.", 7 | "variants": [ 8 | { 9 | "id": "n", 10 | "name": "Normal", 11 | "cost": "540", 12 | "weight": "90g" 13 | } 14 | ] 15 | }, 16 | { 17 | "id": "ice-cream-2", 18 | "image": "https://images.unsplash.com/photo-1629385697093-57be2cc97fa6?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 19 | "name": "Raspberry cheesecake chunk", 20 | "description": "Cheesecake ice cream with raspberry ripple and cheesecake chunks.", 21 | "variants": [ 22 | { 23 | "id": "s", 24 | "name": "One scoop", 25 | "cost": "540", 26 | "weight": "90g" 27 | }, 28 | { 29 | "id": "m", 30 | "name": "Two scoop", 31 | "cost": "714", 32 | "weight": "180g" 33 | }, 34 | { 35 | "id": "l", 36 | "name": "Three scoop", 37 | "cost": "834", 38 | "weight": "270g" 39 | } 40 | ] 41 | }, 42 | { 43 | "id": "ice-cream-3", 44 | "image": "https://images.unsplash.com/photo-1514910539021-85477c9cdcf8?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 45 | "name": "Chocoholic peanut butter ripple", 46 | "description": "Deep, dark chocolate ice cream with Reese's Peanut Butter ripple.", 47 | "variants": [ 48 | { 49 | "id": "n", 50 | "name": "Normal", 51 | "cost": "540", 52 | "weight": "90g" 53 | } 54 | ] 55 | }, 56 | { 57 | "id": "ice-cream-4", 58 | "image": "https://images.unsplash.com/photo-1579954115563-e72bf1381629?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 59 | "name": "Dulce De Leche", 60 | "description": "Caramel ice cream with caramel ripple.", 61 | "variants": [ 62 | { 63 | "id": "s", 64 | "name": "One scoop", 65 | "cost": "540", 66 | "weight": "90g" 67 | }, 68 | { 69 | "id": "m", 70 | "name": "Two scoop", 71 | "cost": "714", 72 | "weight": "180g" 73 | }, 74 | { 75 | "id": "l", 76 | "name": "Three scoop", 77 | "cost": "834", 78 | "weight": "270g" 79 | } 80 | ] 81 | } 82 | ] -------------------------------------------------------------------------------- /backend/data/menu/pasta.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "pasta-1", 4 | "image": "https://images.unsplash.com/photo-1516685018646-549198525c1b?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 5 | "name": "Spaghetti alla Carbonara", 6 | "description": "Spaghetti in a creamy sauce made with egg, prosciutto, sauteed onions, Parmesan cheese and black pepper.", 7 | "variants": [ 8 | { 9 | "id": "n", 10 | "name": "Normal", 11 | "cost": "1109", 12 | "weight": "300g" 13 | }, 14 | { 15 | "id": "g", 16 | "name": "Gluten Free", 17 | "cost": "1290", 18 | "weight": "300g" 19 | } 20 | ] 21 | }, 22 | { 23 | "id": "pasta-2", 24 | "image": "https://images.unsplash.com/photo-1555949258-eb67b1ef0ceb?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 25 | "name": "Penne Terra E Mare", 26 | "description": "Penne sautéed with clams, mussels, scallops, shrimp, mushrooms, peas and diced tomatoes.", 27 | "variants": [ 28 | { 29 | "id": "n", 30 | "name": "Normal", 31 | "cost": "1450", 32 | "weight": "310g" 33 | }, 34 | { 35 | "id": "g", 36 | "name": "Gluten Free", 37 | "cost": "1600", 38 | "weight": "310g" 39 | } 40 | ] 41 | }, 42 | { 43 | "id": "pasta-3", 44 | "image": "https://images.unsplash.com/photo-1579684947550-22e945225d9a?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 45 | "name": "Spaghetti Alla Puttanesca", 46 | "description": "Spaghetti in a diced tomato, anchovies, capers, Kalamata olives and red pepper sauce.", 47 | "variants": [ 48 | { 49 | "id": "n", 50 | "name": "Normal", 51 | "cost": "1200", 52 | "weight": "330g" 53 | }, 54 | { 55 | "id": "g", 56 | "name": "Gluten Free", 57 | "cost": "1350", 58 | "weight": "330g" 59 | } 60 | ] 61 | }, 62 | { 63 | "id": "pasta-4", 64 | "image": "https://images.unsplash.com/photo-1563379926898-05f4575a45d8?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 65 | "name": "Linguime ai frutti di Mare", 66 | "description": "Linguine, tossed with freshly sautéed seafood in a white wine or marinara sauce.", 67 | "variants": [ 68 | { 69 | "id": "n", 70 | "name": "Normal", 71 | "cost": "1580", 72 | "weight": "290g" 73 | }, 74 | { 75 | "id": "g", 76 | "name": "Gluten Free", 77 | "cost": "1700", 78 | "weight": "290g" 79 | } 80 | ] 81 | } 82 | ] -------------------------------------------------------------------------------- /backend/data/menu/burgers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "burger-1", 4 | "image": "https://images.unsplash.com/photo-1568901346375-23c9450c58cd?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 5 | "name": "Hamburger", 6 | "description": "From aged beef, with homemade ketchup, romaine lettuce and tomato.", 7 | "variants": [ 8 | { 9 | "id": "s", 10 | "name": "Small", 11 | "cost": "1199", 12 | "weight": "200g" 13 | 14 | }, 15 | { 16 | "id": "l", 17 | "name": "Large", 18 | "cost": "1399", 19 | "weight": "280g" 20 | } 21 | ] 22 | }, 23 | { 24 | "id": "burger-2", 25 | "image": "https://images.unsplash.com/photo-1571091655789-405eb7a3a3a8?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 26 | "name": "Jalapeno & Habanero burger", 27 | "description": "From aged beef, with slices of jalapeño and habanero peppers, Cheddar cheese, homemade guacamole, arugula, romaine lettuce, spicy tomato salsa and grilled corn.", 28 | "variants": [ 29 | { 30 | "id": "s", 31 | "name": "Small", 32 | "cost": "1249", 33 | "weight": "210g" 34 | }, 35 | { 36 | "id": "l", 37 | "name": "Large", 38 | "cost": "1560", 39 | "weight": "320g" 40 | } 41 | ] 42 | }, 43 | { 44 | "id": "burger-3", 45 | "image": "https://images.unsplash.com/photo-1610970878459-a0e464d7592b?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 46 | "name": "Pulled pork smoked knee burger", 47 | "description": "Pulled smoked pork knuckle braised with black beer and thyme, homemade BBQ sauce, spicy cabbage salad, romaine lettuce and Cheddar cheese.", 48 | "variants": [ 49 | { 50 | "id": "s", 51 | "name": "Small", 52 | "cost": "1299", 53 | "weight": "254g" 54 | }, 55 | { 56 | "id": "l", 57 | "name": "Large", 58 | "cost": "1470", 59 | "weight": "370g" 60 | } 61 | ] 62 | }, 63 | { 64 | "id": "burger-4", 65 | "image": "https://images.unsplash.com/photo-1586816001966-79b736744398?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 66 | "name": "Chiken burger", 67 | "description": "Grilled chicken breast with crispy bacon, Cheddar cheese, arugula, beetroot leaves, pickles and spicy homemade mayonnaise.", 68 | "variants": [ 69 | { 70 | "id": "s", 71 | "name": "Small", 72 | "cost": "1390", 73 | "weight": "320g" 74 | }, 75 | { 76 | "id": "l", 77 | "name": "Large", 78 | "cost": "1580", 79 | "weight": "500g" 80 | } 81 | ] 82 | } 83 | ] -------------------------------------------------------------------------------- /backend/data/menu/coffee.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "coffee-1", 4 | "image": "https://images.unsplash.com/photo-1481455473976-c280ae7c10f9?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 5 | "name": "Snow Hot Chocolate", 6 | "description": "Rich chocolate with steamed milk and a small layer of foam.", 7 | "variants": [ 8 | { 9 | "id": "s", 10 | "name": "Small", 11 | "cost": "360", 12 | "weight": "150ml" 13 | }, 14 | { 15 | "id": "m", 16 | "name": "Medium", 17 | "cost": "420", 18 | "weight": "250ml" 19 | }, 20 | { 21 | "id": "l", 22 | "name": "Large", 23 | "cost": "474", 24 | "weight": "300ml" 25 | } 26 | ] 27 | }, 28 | { 29 | "id": "coffee-2", 30 | "image": "https://images.unsplash.com/photo-1479894127662-a987d1e38f82?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 31 | "name": "Chai Latte", 32 | "description": "Chai syrup with steamed milk and a small layer of foam.", 33 | "variants": [ 34 | { 35 | "id": "s", 36 | "name": "Small", 37 | "cost": "450", 38 | "weight": "150ml" 39 | }, 40 | { 41 | "id": "m", 42 | "name": "Medium", 43 | "cost": "534", 44 | "weight": "250ml" 45 | }, 46 | { 47 | "id": "l", 48 | "name": "Large", 49 | "cost": "594", 50 | "weight": "300ml" 51 | } 52 | ] 53 | }, 54 | { 55 | "id": "coffee-3", 56 | "image": "https://images.unsplash.com/photo-1489217085007-bfc28b5c6f36?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 57 | "name": "Cappuccino", 58 | "description": "A double shot of espresso with equal parts steamed milk and foam.", 59 | "variants": [ 60 | { 61 | "id": "s", 62 | "name": "Small", 63 | "cost": "420", 64 | "weight": "150ml" 65 | }, 66 | { 67 | "id": "m", 68 | "name": "Medium", 69 | "cost": "480", 70 | "weight": "250ml" 71 | }, 72 | { 73 | "id": "l", 74 | "name": "Large", 75 | "cost": "540", 76 | "weight": "300ml" 77 | } 78 | ] 79 | }, 80 | { 81 | "id": "coffee-4", 82 | "image": "https://images.unsplash.com/photo-1461023058943-07fcbe16d735?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 83 | "name": "Iced Coffee", 84 | "description": "Fresh espresso served with milk, vanilla ice-cream and fresh whipped cream.", 85 | "variants": [ 86 | { 87 | "id": "s", 88 | "name": "Small", 89 | "cost": "594", 90 | "weight": "150ml" 91 | }, 92 | { 93 | "id": "m", 94 | "name": "Medium", 95 | "cost": "654", 96 | "weight": "250ml" 97 | }, 98 | { 99 | "id": "l", 100 | "name": "Large", 101 | "cost": "714", 102 | "weight": "300ml" 103 | } 104 | ] 105 | } 106 | ] -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | .vercel 162 | -------------------------------------------------------------------------------- /backend/data/menu/pizza.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "pizza-1", 4 | "image": "https://images.unsplash.com/photo-1513104890138-7c749659a591?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 5 | "name": "Bianca Fresca", 6 | "description": "White pizza with garlic infused olive oil, goat cheese, fresh mozzarella fior di latte, prosciutto fired off then topped with arugula and shaved parmesan.", 7 | "variants": [ 8 | { 9 | "id": "s", 10 | "name": "Small", 11 | "cost": "1479", 12 | "weight": "410g" 13 | }, 14 | { 15 | "id": "m", 16 | "name": "Medium", 17 | "cost": "1699", 18 | "weight": "600g" 19 | }, 20 | { 21 | "id": "l", 22 | "name": "Large", 23 | "cost": "1799", 24 | "weight": "800g" 25 | } 26 | ] 27 | }, 28 | { 29 | "id": "pizza-2", 30 | "image": "https://images.unsplash.com/photo-1590947132387-155cc02f3212?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 31 | "name": "Sonny", 32 | "description": "San Marzano tomato sauce, fresh mozzarella, grated parmesan and Italian sausage from Il Mondo Vecchio.", 33 | "variants": [ 34 | { 35 | "id": "s", 36 | "name": "Small", 37 | "cost": "1499", 38 | "weight": "410g" 39 | }, 40 | { 41 | "id": "m", 42 | "name": "Medium", 43 | "cost": "1699", 44 | "weight": "600g" 45 | }, 46 | { 47 | "id": "l", 48 | "name": "Large", 49 | "cost": "1799", 50 | "weight": "800g" 51 | } 52 | ] 53 | }, 54 | { 55 | "id": "pizza-3", 56 | "image": "https://images.unsplash.com/photo-1534308983496-4fabb1a015ee?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 57 | "name": "Marky", 58 | "description": "San Marzano tomato sauce, fresh mozzarella, grated parmesan and pepperoni from Il Mondo Vecchio.", 59 | "variants": [ 60 | { 61 | "id": "s", 62 | "name": "Small", 63 | "cost": "1479", 64 | "weight": "410g" 65 | }, 66 | { 67 | "id": "m", 68 | "name": "Medium", 69 | "cost": "1699", 70 | "weight": "600g" 71 | }, 72 | { 73 | "id": "l", 74 | "name": "Large", 75 | "cost": "1799", 76 | "weight": "800g" 77 | } 78 | ] 79 | }, 80 | { 81 | "id": "pizza-4", 82 | "image": "https://images.unsplash.com/photo-1595854341625-f33ee10dbf94?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=center&w=1920&q=80", 83 | "name": "The Queen", 84 | "description": "San Marzano tomato sauce, fresh mozzarella fior di latte, fresh organic basil.", 85 | "variants": [ 86 | { 87 | "id": "s", 88 | "name": "Small", 89 | "cost": "1499", 90 | "weight": "410g" 91 | }, 92 | { 93 | "id": "m", 94 | "name": "Medium", 95 | "cost": "1699", 96 | "weight": "600g" 97 | }, 98 | { 99 | "id": "l", 100 | "name": "Large", 101 | "cost": "1799", 102 | "weight": "800g" 103 | } 104 | ] 105 | } 106 | ] -------------------------------------------------------------------------------- /frontend/lottie/empty-cart.json: -------------------------------------------------------------------------------- 1 | {"nm":"Cart empty","ddd":0,"h":390,"w":390,"meta":{"g":"@lottiefiles/toolkit-js 0.26.1"},"layers":[{"ty":4,"nm":"Handle Outlines","sr":1,"st":0,"op":120.0000048877,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[195,195,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[195,195,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[12.703,0],[0,12.564]],"o":[[0,12.564],[-12.703,0],[0,0]],"v":[[23,-11.375],[0,11.375],[-23,-11.375]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":4,"ix":5},"c":{"a":0,"k":[0.3765,0.6235,0.4275],"ix":3}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[195,179.125],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":1,"parent":2},{"ty":4,"nm":"SHopping bag Outlines","sr":1,"st":0,"op":120.0000048877,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[195,195,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[195,195,0],"t":36,"ti":[-1.167,0,0],"to":[-1.667,0,0]},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[185,195,0],"t":42,"ti":[-0.5,0,0],"to":[1.167,0,0]},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[202,195,0],"t":48,"ti":[1.167,0,0],"to":[0.5,0,0]},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[188,195,0],"t":54,"ti":[-1.324,0,0],"to":[-0.483,0,0]},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[198,195,0],"t":59,"ti":[-0.683,0,0],"to":[1.873,0,0]},{"s":[195,195,0],"t":63.0000025660426}],"ix":2},"r":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[0],"t":0},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[-13],"t":7},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[187],"t":30},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[180],"t":36},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[165],"t":42},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[191],"t":48},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[169],"t":54},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[188],"t":59},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[180],"t":63},{"o":{"x":0.167,"y":0},"i":{"x":0.667,"y":1},"s":[180],"t":79},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[187],"t":86},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[-13],"t":106},{"s":[0],"t":113.000004602584}],"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[35,-5.5],[-35,-5.5],[-50,5.5],[50,5.5]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.5569,0.5569,0.5569],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[194,150.5],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 2","ix":2,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[5.554,0],[0,0],[0,5.554],[0,0],[0,0],[0,0]],"o":[[0,0],[-5.554,0],[0,0],[0,0],[0,0],[0,5.554]],"v":[[39.944,43.5],[-39.943,43.5],[-50,33.444],[-50,-43.5],[50,-43.5],[50,33.444]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.8588,0.8588,0.8588],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[194,199.5],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":2}],"v":"5.9.4","fr":29.9700012207031,"op":120.0000048877,"ip":0,"assets":[]} -------------------------------------------------------------------------------- /frontend/js/pages/main.js: -------------------------------------------------------------------------------- 1 | import { Route } from "../routing/route.js"; 2 | import { navigateTo } from "../routing/router.js"; 3 | import { get } from "../requests/requests.js"; 4 | import { TelegramSDK } from "../telegram/telegram.js"; 5 | import { loadImage, replaceShimmerContent } from "../utils/dom.js"; 6 | import { Cart } from "../cart/cart.js"; 7 | 8 | /** 9 | * Page for displaying main page content, e.g. cafe info, categories, some menu sections. 10 | */ 11 | export class MainPage extends Route { 12 | constructor() { 13 | super('root', '/pages/main.html') 14 | } 15 | 16 | load(params) { 17 | const portionCount = Cart.getPortionCount() 18 | if (portionCount > 0) { 19 | TelegramSDK.showMainButton( 20 | `MY CART • ${this.#getDisplayPositionCount(portionCount)}`, 21 | () => navigateTo('cart') 22 | ) 23 | } else { 24 | TelegramSDK.hideMainButton(); 25 | } 26 | 27 | this.#loadCafeInfo() 28 | this.#loadCategories(); 29 | this.#loadPopularMenu(); 30 | } 31 | 32 | #loadCafeInfo() { 33 | get('/info', (cafeInfo) => { 34 | this.#fillCafeInfo(cafeInfo); 35 | }); 36 | } 37 | 38 | #loadCategories() { 39 | get('/categories', (categories) => { 40 | this.#fillCategories(categories); 41 | }) 42 | } 43 | 44 | #loadPopularMenu() { 45 | get('/menu/popular', (popularMenu) => { 46 | this.#fillPopularMenu(popularMenu); 47 | }); 48 | } 49 | 50 | #fillCafeInfo(cafeInfo) { 51 | loadImage($('#cafe-logo'), cafeInfo.logoImage); 52 | loadImage($('#cafe-cover'), cafeInfo.coverImage); 53 | 54 | const cafeInfoTemplate = $('#cafe-info-template').html(); 55 | const filledCafeInfoTemplate = $(cafeInfoTemplate); 56 | filledCafeInfoTemplate.find('#cafe-name').text(cafeInfo.name); 57 | filledCafeInfoTemplate.find('#cafe-kitchen-categories').text(cafeInfo.kitchenCategories); 58 | filledCafeInfoTemplate.find('#cafe-rating').text(cafeInfo.rating); 59 | filledCafeInfoTemplate.find('#cafe-cooking-time').text(cafeInfo.cookingTime); 60 | filledCafeInfoTemplate.find('#cafe-status').text(cafeInfo.status); 61 | $('#cafe-info').empty(); 62 | $('#cafe-info').append(filledCafeInfoTemplate); 63 | } 64 | 65 | #fillCategories(categories) { 66 | $('#cafe-section-categories-title').removeClass('shimmer'); 67 | replaceShimmerContent( 68 | '#cafe-categories', 69 | '#cafe-category-template', 70 | '#cafe-category-icon', 71 | categories, 72 | (template, cafeCategory) => { 73 | template.attr('id', cafeCategory.id); 74 | template.css('background-color', cafeCategory.backgroundColor); 75 | template.find('#cafe-category-icon').attr('src', cafeCategory.icon); 76 | template.find('#cafe-category-name').text(cafeCategory.name); 77 | template.on('click', () => { 78 | const params = JSON.stringify({'id': cafeCategory.id}); 79 | navigateTo('category', params); 80 | }); 81 | } 82 | ) 83 | } 84 | 85 | #fillPopularMenu(popularMenu) { 86 | $('#cafe-section-popular-title').removeClass('shimmer'); 87 | replaceShimmerContent( 88 | '#cafe-section-popular', 89 | '#cafe-item-template', 90 | '#cafe-item-image', 91 | popularMenu, 92 | (template, cafeItem) => { 93 | template.attr('id', cafeItem.name); 94 | template.find('#cafe-item-image').attr('src', cafeItem.image); 95 | template.find('#cafe-item-name').text(cafeItem.name); 96 | template.find('#cafe-item-description').text(cafeItem.description); 97 | template.on('click', () => { 98 | const params = JSON.stringify({'id': cafeItem.id}); 99 | navigateTo('details', params); 100 | }); 101 | } 102 | ) 103 | } 104 | 105 | #getDisplayPositionCount(positionCount) { 106 | return positionCount == 1 ? `${positionCount} POSITION` : `${positionCount} POSITIONS`; 107 | } 108 | 109 | } -------------------------------------------------------------------------------- /frontend/pages/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Categories 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Popular 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /frontend/js/pages/details.js: -------------------------------------------------------------------------------- 1 | import { Route } from "../routing/route.js"; 2 | import { get } from "../requests/requests.js"; 3 | import { TelegramSDK } from "../telegram/telegram.js"; 4 | import { loadImage } from "../utils/dom.js"; 5 | import { Cart } from "../cart/cart.js"; 6 | import { toDisplayCost } from "../utils/currency.js" 7 | import { Snackbar } from "../utils/snackbar.js"; 8 | 9 | /** 10 | * Page for displaying main item details, as well as selectin variant, quantity 11 | * and adding item to the Cart. 12 | */ 13 | export class DetailsPage extends Route { 14 | 15 | #selectedVariant; 16 | #selectedQuantity = 1; 17 | 18 | constructor() { 19 | super('details', '/pages/details.html') 20 | } 21 | 22 | load(params) { 23 | TelegramSDK.expand(); 24 | 25 | if (params != null) { 26 | const parsedParams = JSON.parse(params); 27 | this.#loadDetails(parsedParams.id); 28 | } else { 29 | console.log('Params must not be null and must contain category ID.') 30 | } 31 | } 32 | 33 | #loadDetails(menuItemId) { 34 | get('/menu/details/' + menuItemId, (menuItems) => { 35 | this.#fillDetails(menuItems); 36 | }); 37 | } 38 | 39 | #fillDetails(menuItem) { 40 | loadImage($('#cafe-item-details-image'), menuItem.image); 41 | 42 | $('#cafe-item-details-name') 43 | .removeClass('shimmer') 44 | .text(menuItem.name); 45 | 46 | $('#cafe-item-details-description') 47 | .removeClass('shimmer') 48 | .text(menuItem.description); 49 | 50 | $('#cafe-item-details-section-title').removeClass('shimmer'); 51 | 52 | const variants = menuItem.variants; 53 | const variantsContainer = $('#cafe-item-details-variants'); 54 | const variantTemplateHtml = $('#cafe-item-details-variant-template').html(); 55 | variants.forEach((variant) => { 56 | const filledVariantTemplate = $(variantTemplateHtml) 57 | .attr('id', variant.id) 58 | .text(variant.name) 59 | .on('click', () => this.#selectVariant(variant)); 60 | variantsContainer.append(filledVariantTemplate); 61 | }); 62 | 63 | if (variants.length > 0) { 64 | this.#selectVariant(variants[0]); 65 | } 66 | 67 | this.#resetQuantity(); 68 | $('#cafe-item-details-quantity-decrease-button').clickWithRipple(() => this.#decreaseQuantity()); 69 | $('#cafe-item-details-quantity-increase-button').clickWithRipple(() => this.#increaseQuantity()); 70 | 71 | TelegramSDK.showMainButton( 72 | 'ADD TO CART', 73 | () => { 74 | Cart.addItem(menuItem, this.#selectedVariant, this.#selectedQuantity); 75 | this.#showSuccessSnackbar(); 76 | TelegramSDK.notificationOccured('success'); 77 | } 78 | ); 79 | } 80 | 81 | #selectVariant(variant) { 82 | if (this.#selectedVariant != null) { 83 | $(`#${this.#selectedVariant.id}`).removeClass('selected'); 84 | } 85 | $(`#${variant.id}`).addClass('selected'); 86 | this.#selectedVariant = variant; 87 | this.#refreshSelectedVariantWeight(); 88 | this.#refreshSelectedVariantPrice(); 89 | TelegramSDK.impactOccured('light'); 90 | } 91 | 92 | #refreshSelectedVariantWeight() { 93 | $('#cafe-item-details-selected-variant-weight') 94 | .removeClass('shimmer') 95 | .text(this.#selectedVariant.weight); 96 | } 97 | 98 | #refreshSelectedVariantPrice() { 99 | $('#cafe-item-details-selected-variant-price') 100 | .removeClass('shimmer') 101 | .text(toDisplayCost(this.#selectedVariant.cost)); 102 | } 103 | 104 | #increaseQuantity() { 105 | this.#selectedQuantity++; 106 | this.#refreshSelectedQuantity(); 107 | TelegramSDK.impactOccured('light'); 108 | } 109 | 110 | #decreaseQuantity() { 111 | if (this.#selectedQuantity > 1) { 112 | this.#selectedQuantity--; 113 | this.#refreshSelectedQuantity(); 114 | TelegramSDK.impactOccured('light'); 115 | } 116 | } 117 | 118 | #resetQuantity() { 119 | this.#selectedQuantity = 1; 120 | this.#refreshSelectedQuantity(); 121 | } 122 | 123 | #refreshSelectedQuantity() { 124 | $('#cafe-item-details-quantity-value') 125 | .text(this.#selectedQuantity) 126 | .boop(); 127 | } 128 | 129 | #showSuccessSnackbar() { 130 | Snackbar.showSnackbar( 131 | 'cafe-item-details-container', 132 | 'Successfully added to cart!', 133 | { 134 | bottom: '80px', 135 | 'background-color': 'var(--success-color)' 136 | } 137 | ); 138 | } 139 | 140 | } -------------------------------------------------------------------------------- /frontend/js/cart/cart.js: -------------------------------------------------------------------------------- 1 | import { removeIf } from "../utils/array.js"; 2 | import { toDisplayCost } from "../utils/currency.js"; 3 | 4 | /** 5 | * Model class representing Cart item. This is combination of 6 | * Cafe (Menu) item, selected variant and quantity. 7 | */ 8 | class CartItem { 9 | constructor(cafeItem, variant, quantity) { 10 | this.cafeItem = cafeItem; 11 | this.variant = variant; 12 | this.quantity = quantity; 13 | } 14 | 15 | /** 16 | * Create new instance of the CartItem from its JSON representation (map). 17 | * @param {map} rawCartItem JSON representation (map) of class. 18 | * @returns New CartItemInstance. 19 | */ 20 | static fromRaw(rawCartItem) { 21 | return new CartItem( 22 | rawCartItem.cafeItem, 23 | rawCartItem.variant, 24 | rawCartItem.quantity 25 | ); 26 | } 27 | 28 | /** 29 | * Get CartItem ID based on Cafe item and variant. 30 | * @returns CartItem ID. 31 | */ 32 | getId() { 33 | return `${this.cafeItem.id}-${this.variant.id}`; 34 | } 35 | 36 | /** 37 | * Display representation of the total cost for selected variant and quantity. 38 | * @returns The cost in format that may be displayed on UI. 39 | */ 40 | getDisplayTotalCost() { 41 | const totalCost = this.variant.cost * this.quantity; 42 | return toDisplayCost(totalCost); 43 | } 44 | } 45 | 46 | /** 47 | * Class holds current Cart state and allows to manipulate it. 48 | * All the available methods are static, so it's safe to use them 49 | * in different parts of the app to get actual Cart state. 50 | */ 51 | export class Cart { 52 | 53 | static #cartItems = [] 54 | 55 | static onItemsChangeListener 56 | 57 | /** 58 | * Before using the Cart, we try to restore the latest state from the localStorage. 59 | */ 60 | static { 61 | const savedCartItemsJson = localStorage.getItem('cartItems'); 62 | if (savedCartItemsJson != null) { 63 | const savedRawCartItems = JSON.parse(savedCartItemsJson); 64 | const savedCartItems = savedRawCartItems.map(rawCartItem => CartItem.fromRaw(rawCartItem)); 65 | this.#cartItems = savedCartItems; 66 | } 67 | } 68 | 69 | /** 70 | * @returns Cart items. 71 | */ 72 | static getItems() { 73 | return this.#cartItems; 74 | } 75 | 76 | /** 77 | * @returns Total portion count of all the added Cart items. 78 | */ 79 | static getPortionCount() { 80 | var portionCount = 0; 81 | for (let i = 0; i < this.#cartItems.length; i++) { 82 | portionCount += this.#cartItems[i].quantity; 83 | } 84 | return portionCount; 85 | } 86 | 87 | /** 88 | * Add new Cafe item to the Cart, if the Cart item with this configuration (Cafe item + variant) 89 | * doesn't exist in the Cart, or just update the existing Cart item quantity. 90 | * @param {map} cafeItem Map representation of Cafe item. 91 | * @param {map} variant Selected variant of Cafe item. 92 | * @param {number} quantity Selected quantuty. 93 | */ 94 | static addItem(cafeItem, variant, quantity) { 95 | const addingCartItem = new CartItem(cafeItem, variant, quantity); 96 | const existingItem = this.#findItem(addingCartItem.getId()); 97 | if (existingItem != null) { 98 | existingItem.quantity += quantity; 99 | } else { 100 | this.#cartItems.push(addingCartItem); 101 | } 102 | this.#saveItems(); 103 | this.#notifyAboutItemsChanged(); 104 | } 105 | 106 | /** 107 | * Increase the quantity of desired Cart item. Do nothing, if such item is not in Cart. 108 | * @param {CartItem} cartItem Desired Cart item model. 109 | * @param {number} quantity Amount by which to increase. 110 | */ 111 | static increaseQuantity(cartItem, quantity) { 112 | const existingItem = this.#findItem(cartItem.getId()); 113 | if (existingItem != null) { 114 | existingItem.quantity += quantity; 115 | this.#saveItems(); 116 | this.#notifyAboutItemsChanged(); 117 | } 118 | } 119 | 120 | /** 121 | * Increase the quantity of desired Cart item. Do nothing, if such item is not in Cart. 122 | * If quantity is more than found Cart item's quantity, fully remove such item. 123 | * @param {CartItem} cartItem Desired Cart item model. 124 | * @param {number} quantity Amount by which to decrease. 125 | */ 126 | static decreaseQuantity(cartItem, quantity) { 127 | const existingItem = this.#findItem(cartItem.getId()); 128 | if (existingItem != null) { 129 | if (existingItem.quantity > quantity) { 130 | existingItem.quantity -= quantity; 131 | } else { 132 | removeIf(this.#cartItems, cartItem => cartItem.getId() === existingItem.getId()) 133 | } 134 | this.#saveItems(); 135 | this.#notifyAboutItemsChanged(); 136 | } 137 | } 138 | 139 | /** 140 | * Remove all the items from the Cart. 141 | */ 142 | static clear() { 143 | this.#cartItems = []; 144 | this.#saveItems(); 145 | this.#notifyAboutItemsChanged(); 146 | } 147 | 148 | /** 149 | * Find Cart item by ID. 150 | * @param {string} id ID of the desired item. 151 | * @returns Found CartItem or undefined. 152 | */ 153 | static #findItem(id) { 154 | return this.#cartItems.find(cartItem => cartItem.getId() === id); 155 | } 156 | 157 | /** 158 | * Save in-memory stored items to the localStorage. 159 | */ 160 | static #saveItems() { 161 | localStorage.setItem('cartItems', JSON.stringify(this.#cartItems)); 162 | } 163 | 164 | /** 165 | * Notify onItemsChangeListener about change in the item list. 166 | */ 167 | static #notifyAboutItemsChanged() { 168 | if (this.onItemsChangeListener != null) { 169 | this.onItemsChangeListener(this.#cartItems); 170 | } 171 | } 172 | 173 | } -------------------------------------------------------------------------------- /frontend/js/pages/cart.js: -------------------------------------------------------------------------------- 1 | import { Cart } from "../cart/cart.js" 2 | import { post } from "../requests/requests.js"; 3 | import { Route } from "../routing/route.js"; 4 | import { showSnackbar } from "../routing/router.js"; 5 | import { TelegramSDK } from "../telegram/telegram.js"; 6 | import { loadImage } from "../utils/dom.js"; 7 | 8 | /** 9 | * Page for displaying cart items, as well as changing them (quantity). 10 | */ 11 | export class CartPage extends Route { 12 | 13 | #emptyCartPlaceholderLottieAnimation 14 | 15 | constructor() { 16 | super('cart', '/pages/cart.html') 17 | } 18 | 19 | load(params) { 20 | this.#loadLottie(); 21 | // Refresh UI when Cart was updated. 22 | Cart.onItemsChangeListener = (cartItems) => this.#fillCartItems(cartItems); 23 | this.#loadCartItems() 24 | } 25 | 26 | #loadLottie() { 27 | this.#emptyCartPlaceholderLottieAnimation = lottie.loadAnimation({ 28 | container: $('#cart-empty-placeholder-icon')[0], 29 | renderer: 'svg', 30 | loop: true, 31 | autoplay: false, 32 | path: 'lottie/empty-cart.json' 33 | }); 34 | } 35 | 36 | #loadCartItems() { 37 | const cartItems = Cart.getItems(); 38 | this.#fillCartItems(cartItems); 39 | } 40 | 41 | #fillCartItems(cartItems) { 42 | this.#updateMainButton(cartItems); 43 | this.#changeEmptyPlaceholderVisibility(cartItems.length == 0); 44 | 45 | const cartItemsContainer = $('#cart-items'); 46 | const cartItemsIds = cartItems.map((cartItem) => cartItem.getId()); 47 | 48 | // Remove elements not presented in the cartItems anymore 49 | cartItemsContainer.children().each((index, element) => { 50 | const cartItemElement = $(element); 51 | if (!cartItemsIds.includes(cartItemElement.attr('id'))) { 52 | cartItemElement.remove(); 53 | } 54 | }) 55 | 56 | const cartItemTemplateHtml = $('#cart-item-template').html(); 57 | cartItems.forEach(cartItem => { 58 | const cartItemElement = cartItemsContainer.find(`#${cartItem.getId()}`); 59 | if (cartItemElement.length > 0) { 60 | // We found the existing item, just update the needed params 61 | cartItemElement.find('#cart-item-cost').textBoop(cartItem.getDisplayTotalCost()); 62 | cartItemElement.find('#cart-item-quantity').textBoop(cartItem.quantity); 63 | } else { 64 | // This is the newely added item, create new element for it 65 | const filledCartItemTemplate = $(cartItemTemplateHtml); 66 | filledCartItemTemplate.attr('id', `${cartItem.getId()}`) 67 | loadImage(filledCartItemTemplate.find('#cart-item-image'), cartItem.cafeItem.image); 68 | filledCartItemTemplate.find('#cart-item-name').text(cartItem.cafeItem.name); 69 | filledCartItemTemplate.find('#cart-item-description').text(cartItem.variant.name); 70 | filledCartItemTemplate.find('#cart-item-cost').text(cartItem.getDisplayTotalCost()); 71 | filledCartItemTemplate.find('#cart-item-quantity').text(cartItem.quantity); 72 | filledCartItemTemplate.find('#cart-item-quantity-increment').clickWithRipple(() => { 73 | Cart.increaseQuantity(cartItem, 1); 74 | TelegramSDK.impactOccured('light'); 75 | }); 76 | filledCartItemTemplate.find('#cart-item-quantity-decrement').clickWithRipple(() => { 77 | Cart.decreaseQuantity(cartItem, 1); 78 | TelegramSDK.impactOccured('light'); 79 | }); 80 | cartItemsContainer.append(filledCartItemTemplate); 81 | } 82 | }); 83 | } 84 | 85 | #updateTextWithBoop(element, text) { 86 | if (element.text() != text) { 87 | element 88 | .text(text) 89 | .boop(); 90 | } 91 | } 92 | 93 | #updateMainButton(cartItems) { 94 | if (cartItems.length > 0) { 95 | TelegramSDK.showMainButton('CHECKOUT', () => { 96 | TelegramSDK.setMainButtonLoading(true); 97 | this.#createOrder(cartItems); 98 | }); 99 | } else { 100 | TelegramSDK.setMainButtonLoading(false); 101 | TelegramSDK.hideMainButton(); 102 | } 103 | } 104 | 105 | 106 | #changeEmptyPlaceholderVisibility(isVisible) { 107 | const placeholder = $('#cart-empty-placeholder'); 108 | if (isVisible) { 109 | this.#emptyCartPlaceholderLottieAnimation.play(); 110 | placeholder.fadeIn(); 111 | } else { 112 | this.#emptyCartPlaceholderLottieAnimation.stop(); 113 | placeholder.hide(); 114 | } 115 | } 116 | 117 | #createOrder(cartItems) { 118 | const data = { 119 | _auth: TelegramSDK.getInitData(), 120 | cartItems: cartItems 121 | }; 122 | post('/order', JSON.stringify(data), (result) => { 123 | if (result.ok) { 124 | TelegramSDK.openInvoice(result.data.invoiceUrl, (status) => { 125 | this.#handleInvoiceStatus(status); 126 | }); 127 | } else { 128 | showSnackbar(result.error, 'error'); 129 | } 130 | }); 131 | } 132 | 133 | #handleInvoiceStatus(status) { 134 | if (status == 'paid') { 135 | Cart.clear(); 136 | TelegramSDK.close(); 137 | } else if (status == 'failed') { 138 | TelegramSDK.setMainButtonLoading(false); 139 | showSnackbar('Something went wrong, payment is unsuccessful :(', 'error'); 140 | } else { 141 | TelegramSDK.setMainButtonLoading(false); 142 | showSnackbar('The order was cancelled.', 'warning'); 143 | } 144 | } 145 | 146 | onClose() { 147 | // Remove listener to prevent any updates here when page is not visible. 148 | Cart.onItemsChangeListener = null; 149 | } 150 | } -------------------------------------------------------------------------------- /backend/app/bot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | import telebot 5 | from telebot import TeleBot 6 | from telebot.types import Update, WebAppInfo, Message 7 | from telebot.util import quick_markup 8 | 9 | BOT_TOKEN=os.getenv('BOT_TOKEN') 10 | PAYMENT_PROVIDER_TOKEN=os.getenv('PAYMENT_PROVIDER_TOKEN') 11 | WEBHOOK_URL=os.getenv('WEBHOOK_URL') 12 | WEBHOOK_PATH='/bot' 13 | APP_URL=os.getenv('APP_URL') 14 | 15 | bot = TeleBot(BOT_TOKEN, parse_mode=None) 16 | 17 | @bot.message_handler(content_types=['successful_payment']) 18 | def handle_successful_payment(message): 19 | """Message handler for messages containing 'successful_payment' field. 20 | This message is sent when the payment is successful and the payment flow is done. 21 | It's a good place to send the user a purchased item (if it is an electronic item, such as a key) 22 | or to send a message that an item is on its way. 23 | 24 | The message param doesn't contain info about ordered good - they should be stored separately. 25 | Find more info: https://core.telegram.org/bots/api#successfulpayment. 26 | 27 | Example of Successful Payment message: 28 | { 29 | "update_id":12345, 30 | "message":{ 31 | "message_id":12345, 32 | "date":1441645532, 33 | "chat":{ 34 | "last_name":"Doe", 35 | "id":123456789, 36 | "first_name":"John", 37 | "username":"johndoe", 38 | "type": "" 39 | }, 40 | "successful_payment": { 41 | "currency": "USD", 42 | "total_amount": 1000, 43 | "invoice_payload": "order_id", 44 | "telegram_payment_charge_id": "12345", 45 | "provider_payment_charge_id": "12345", 46 | "order_info": { 47 | "name": "John" 48 | } 49 | } 50 | } 51 | } 52 | """ 53 | user_name = message.successful_payment.order_info.name 54 | text = f'Thank you for your order, *{user_name}*! This is not a real cafe, so your card was not charged.\n\nHave a nice day 🙂' 55 | bot.send_message( 56 | chat_id=message.chat.id, 57 | text=text, 58 | parse_mode='markdown' 59 | ) 60 | 61 | @bot.pre_checkout_query_handler(func=lambda _: True) 62 | def handle_pre_checkout_query(pre_checkout_query): 63 | """Here we may check if ordered items are still available. 64 | Since this is an example project, all the items are always in stock, so we answer query is OK. 65 | For other cases, when you perform a check and find out that you can't sell the items, 66 | you need to answer ok=False. 67 | Keep in mind: The check operation should not be longer than 10 seconds. If the Telegram API 68 | doesn't receive answer in 10 seconds, it cancels checkout. 69 | """ 70 | bot.answer_pre_checkout_query(pre_checkout_query_id=pre_checkout_query.id, ok=True) 71 | 72 | @bot.message_handler(func=lambda message: re.match(r'/?start', message.text, re.IGNORECASE) is not None) 73 | def handle_start_command(message): 74 | """Message handler for start messages, including '/start' command. This is an example how to 75 | use Regex for handling desired type of message. E.g. this handlers applies '/start', 76 | '/START', 'start', 'START', 'sTaRt' and so on. 77 | """ 78 | send_actionable_message( 79 | chat_id=message.chat.id, 80 | text='*Welcome to Laurel Cafe!* 🌿\n\nIt is time to order something delicious 😋 Tap the button below to get started.' 81 | ) 82 | 83 | @bot.message_handler() 84 | def handle_all_messages(message): 85 | """Fallback message handler that is invoced if none of above aren't match. This is a good 86 | practice to handle all the messages instead of ignoring unknown ones. In our case, we let user 87 | know that we can't handle the message and just advice to explore the menu using inline button. 88 | """ 89 | send_actionable_message( 90 | chat_id=message.chat.id, 91 | text="To be honest, I don't know how to reply to messages. But I can offer you to familiarize yourself with our menu. I am sure you will find something to your liking! 😉" 92 | ) 93 | 94 | def send_actionable_message(chat_id, text): 95 | """Method allows to send the text to the chat and attach inline button to it. 96 | Inline button will open our Mini App on click. 97 | """ 98 | markup = quick_markup({ 99 | 'Explore Menu': { 100 | 'web_app': WebAppInfo(APP_URL) 101 | }, 102 | }, row_width=1) 103 | bot.send_message( 104 | chat_id=chat_id, 105 | text=text, 106 | parse_mode='markdown', 107 | reply_markup=markup 108 | ) 109 | 110 | def refresh_webhook(): 111 | """Just a wrapper for remove & set webhook ops""" 112 | bot.remove_webhook() 113 | bot.set_webhook(WEBHOOK_URL + WEBHOOK_PATH) 114 | 115 | def process_update(update_json): 116 | """Pass received Update JSON to the Bot for processing. 117 | This method should be typically called from the webhook method. 118 | 119 | Args: 120 | update_json: Update object sent from the Telegram API. See https://core.telegram.org/bots/api#update. 121 | """ 122 | update = Update.de_json(update_json) 123 | bot.process_new_updates([update]) 124 | 125 | def create_invoice_link(prices) -> str: 126 | """Just a handy wrapper for creating an invoice link for payment. Since this is an example project, 127 | most of the fields are hardcode. 128 | """ 129 | return bot.create_invoice_link( 130 | title='Order #1', 131 | description='Great choice! Last steps and we will get to cooking ;)', 132 | payload='orderID', 133 | provider_token=PAYMENT_PROVIDER_TOKEN, 134 | currency='USD', 135 | prices=prices, 136 | need_name=True, 137 | need_phone_number=True, 138 | need_shipping_address=True 139 | ) 140 | 141 | def enable_debug_logging(): 142 | """Display all logs from the Bot. May be useful while developing.""" 143 | telebot.logger.setLevel(logging.DEBUG) -------------------------------------------------------------------------------- /backend/app/main.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from . import auth, bot 4 | from dotenv import load_dotenv 5 | from flask import Flask, request 6 | from flask_cors import CORS 7 | from telebot.types import LabeledPrice 8 | 9 | # Load environment variables from .env files. 10 | # Typicall environment variables are set on the OS level, 11 | # but for development purposes it may be handy to set them 12 | # in the .env file directly. 13 | load_dotenv() 14 | 15 | 16 | 17 | app = Flask(__name__) 18 | # Handle paths like '/info/' and '/info' as the same. 19 | app.url_map.strict_slashes = False 20 | 21 | # List of allowed origins. The production 'APP_URL' is added by default, 22 | # the development `DEV_APP_URL` is added if it and `DEV_MODE` variable is present. 23 | allowed_origins = [os.getenv('APP_URL')] 24 | 25 | if os.getenv('DEV_MODE') is not None: 26 | allowed_origins.append(os.getenv('DEV_APP_URL')) 27 | bot.enable_debug_logging() 28 | 29 | CORS(app, origins=list(filter(lambda o: o is not None, allowed_origins))) 30 | 31 | 32 | 33 | @app.route(bot.WEBHOOK_PATH, methods=['POST']) 34 | def bot_webhook(): 35 | """Entry points for Bot update sent via Telegram API. 36 | You may find more info looking into process_update method. 37 | """ 38 | bot.process_update(request.get_json()) 39 | return { 'message': 'OK' } 40 | 41 | @app.route('/info') 42 | def info(): 43 | """API endpoint for providing info about the cafe. 44 | 45 | Returns: 46 | JSON data from data/info.json file or error message with 404 code if not found. 47 | """ 48 | try: 49 | return json_data('data/info.json') 50 | except FileNotFoundError: 51 | return { 'message': 'Could not find info data.' }, 404 52 | 53 | @app.route('/categories') 54 | def categories(): 55 | """API endpoint for providing available cafe categories. 56 | 57 | Returns: 58 | JSON data from data/categories.json file or error message with 404 code if not found. 59 | """ 60 | try: 61 | return json_data('data/categories.json') 62 | except FileNotFoundError: 63 | return { 'message': 'Could not find categories data.' }, 404 64 | 65 | @app.route('/menu/') 66 | def category_menu(category_id: str): 67 | """API endpoint for providing menu list of specified category. 68 | 69 | Args: 70 | category_id: Looking menu category ID. 71 | 72 | Returns: 73 | JSON data from one of data/menu/.json file or error message with 404 code if not found. 74 | """ 75 | try: 76 | return json_data(f'data/menu/{category_id}.json') 77 | except FileNotFoundError: 78 | return { 'message': f'Could not find `{category_id}` category data.' }, 404 79 | 80 | @app.route('/menu/details/') 81 | def menu_item_details(menu_item_id: str): 82 | """API endpoint for providing menu item details. 83 | 84 | Args: 85 | menu_item_id: Looking menu item ID. 86 | 87 | Returns: 88 | JSON data from one of data/menu/.json file or error message with 404 code if not found. 89 | """ 90 | try: 91 | data_folder_path = 'data/menu' 92 | for data_file in os.listdir(data_folder_path): 93 | menu_items = json_data(f'{data_folder_path}/{data_file}') 94 | desired_menu_item = next((menu_item for menu_item in menu_items if menu_item['id'] == menu_item_id), None) 95 | if desired_menu_item is not None: 96 | return desired_menu_item 97 | return { 'message': f'Could not menu item data with `{menu_item_id}` ID.' }, 404 98 | except FileNotFoundError: 99 | return { 'message': f'Could not menu item data with `{menu_item_id}` ID.' }, 404 100 | 101 | @app.route('/order', methods=['POST']) 102 | def create_order(): 103 | """API endpoint for creating an order. This method performs the following tasks: 104 | - Validation of the initData received from the Telegram Mini App. 105 | - Conversion of cart items into LabeledPrice objects for further submitting to Telegram API. 106 | As a result, we get an invoiceUrl that can be used to start the payment process in our Mini App. 107 | See: https://core.telegram.org/bots/webapps#initializing-mini-apps (Telegram.WebApp.openInvoice method). 108 | 109 | Example of request body: 110 | { 111 | "_auth": "", 112 | "cartItems": [ 113 | { 114 | "cafeItem": { 115 | "name": "Burger" 116 | }, 117 | "variant": { 118 | "name": "Small", 119 | "cost": 100 120 | }, 121 | "quantity": 3 122 | } 123 | ] 124 | } 125 | 126 | Please note: This method is the appropriate place to create an order ID and save it to some persistance storage. 127 | You can pass it then as invoice_payload parameter when creating invoiceUrl to further update the order status, and, 128 | after successful payment, get the collected information about the order items (this information is not stored by Telegram). 129 | """ 130 | request_data = request.get_json() 131 | 132 | auth_data = request_data.get('_auth') 133 | if auth_data is None or not auth.validate_auth_data(bot.BOT_TOKEN, auth_data): 134 | return { 'message': 'Request data should contain auth data.' }, 401 135 | 136 | order_items = request_data.get('cartItems') 137 | if order_items is None: 138 | return { 'message': 'Cart Items are not provided.' }, 400 139 | 140 | labeled_prices = [] 141 | for order_item in order_items: 142 | name = order_item['cafeItem']['name'] 143 | variant = order_item['variant']['name'] 144 | cost = order_item['variant']['cost'] 145 | quantity = order_item['quantity'] 146 | price = int(cost) * int(quantity) 147 | labeled_price = LabeledPrice( 148 | label=f'{name} ({variant}) x{quantity}', 149 | amount=price 150 | ) 151 | labeled_prices.append(labeled_price) 152 | 153 | invoice_url = bot.create_invoice_link( 154 | prices=labeled_prices 155 | ) 156 | 157 | return { 'invoiceUrl': invoice_url } 158 | 159 | def json_data(data_file_path: str): 160 | """Extracts data from the JSON file. 161 | 162 | Args: 163 | data_file_path: Path to desired JSON file. 164 | 165 | Returns: 166 | Data from the desired JSON file (as dict). 167 | 168 | Raises: 169 | FileNotFoundError if desired file doesn't exist. 170 | """ 171 | if os.path.exists(data_file_path): 172 | with open(data_file_path, 'r') as data_file: 173 | return json.load(data_file) 174 | else: 175 | raise FileNotFoundError() 176 | 177 | 178 | 179 | bot.refresh_webhook() -------------------------------------------------------------------------------- /frontend/js/routing/router.js: -------------------------------------------------------------------------------- 1 | import { MainPage } from "../pages/main.js"; 2 | import { CategoryPage } from "../pages/category.js"; 3 | import { DetailsPage } from "../pages/details.js"; 4 | import { TelegramSDK } from "../telegram/telegram.js"; 5 | import { CartPage } from "../pages/cart.js"; 6 | import { Snackbar } from "../utils/snackbar.js"; 7 | 8 | /** 9 | * List of available routes (pages). 10 | */ 11 | const availableRoutes = [ 12 | new MainPage(), 13 | new CategoryPage(), 14 | new DetailsPage(), 15 | new CartPage() 16 | ] 17 | 18 | /** 19 | * When we load content for the route (HTML), we save it there 20 | * in format { '/path/to/content.html': '...Loaded content' }. 21 | * When we go to the route with contentPath that exists in cache, we load 22 | * page from there. This is optimization for route content HTML only, 23 | * the Route.load(params) method is calling anyway to load new portion of the data. 24 | * 25 | * This is in-memory cache, since we want to store it only for the current app 26 | * opening. 27 | */ 28 | const pageContentCache = { } 29 | 30 | /** 31 | * Currently selected route. 32 | * Instance of Route class' child, one of the availableRoutes. 33 | */ 34 | let currentRoute 35 | 36 | /** 37 | * Currently executing route (page) content load request. 38 | * It resets (null) when page is loaded. 39 | */ 40 | let pageContentLoadRequest 41 | 42 | /** 43 | * Indicates that we have one more navigation request we get while 44 | * navigation animation was running. If true, when navigation animation finish, 45 | * there will be one more handleLocation() call. 46 | */ 47 | let pendingAnimations = false 48 | 49 | /** 50 | * Indicates currently running navigation animation. 51 | */ 52 | let animationRunning = false 53 | 54 | /** 55 | * Request for navigating to some destination. 56 | * @param {string} dest Desired destination. Should be one of availableRoutes dests. 57 | * @param {*} params Params that you'd like to pass to the new destination (route). 58 | * It will be URL encoded 'params' parameter of the current URL. 59 | */ 60 | export function navigateTo(dest, params) { 61 | let url = '?dest=' + dest; 62 | if (params != null) { 63 | url += '¶ms=' + encodeURIComponent(params); 64 | } 65 | // Keep URL hash part since it may be filled by Telegram. 66 | // This is actual, for example, when running the app 67 | // from Inline Button. 68 | window.history.pushState({}, '', url + location.hash); 69 | handleLocation(false); 70 | }; 71 | 72 | /** 73 | * Handle location defined in the current URL. The method performs: 74 | * - Find desired route or fallback to default ('root'). 75 | * - Run navigation animation (slid-in/slide-out). 76 | * - Controls Telegram's back button. 77 | * @param {boolean} reverse Navigation animation should run in reverse direction. 78 | * Typically used for back (popstate) navigations. 79 | */ 80 | export function handleLocation(reverse) { 81 | const search = window.location.search; 82 | if (search == '') { 83 | navigateTo('root') 84 | } else { 85 | if (animationRunning) { 86 | pendingAnimations = true; 87 | return; 88 | } 89 | 90 | if (currentRoute != null) { 91 | currentRoute.onClose(); 92 | } 93 | 94 | const searchParams = new URLSearchParams(search); 95 | const dest = searchParams.get('dest') || 'root'; 96 | const encodedLoadParams = searchParams.get('params'); 97 | if (encodedLoadParams != null) { 98 | var loadParams = decodeURIComponent(encodedLoadParams); 99 | } 100 | currentRoute = availableRoutes.find((route) => dest === route.dest); 101 | 102 | if (pageContentLoadRequest != null) { 103 | pageContentLoadRequest.abort(); 104 | } 105 | 106 | if ($('#page-current').contents().length > 0) { 107 | pageContentLoadRequest = loadPage('#page-next', currentRoute.contentPath, () => { 108 | pageContentLoadRequest = null; 109 | currentRoute.load(loadParams); 110 | }); 111 | animatePageChange(reverse); 112 | } else { 113 | pageContentLoadRequest = loadPage('#page-current', currentRoute.contentPath, () => { 114 | pageContentLoadRequest = null; 115 | currentRoute.load(loadParams) 116 | }); 117 | } 118 | 119 | if (currentRoute.dest != 'root') { 120 | TelegramSDK.showBackButton(() => history.back()); 121 | } else { 122 | TelegramSDK.hideBackButton(); 123 | } 124 | } 125 | }; 126 | 127 | /** 128 | * Load page content (HTML). The content may be load from the server or cache, 129 | * if previously was already loaded (see pageContentCache). 130 | * @param {string} pageContainerSelector Selector of the page container (e.g. #page-current). 131 | * @param {string} pagePath Path of the page content, typically defined in Route.contentPath (e.g. /path/main.html). 132 | * @param {*} onSuccess Callback called when page successfully loaded and added to DOM. 133 | * @returns Request object, if page is loaded from the server, or null, if from the cache. 134 | */ 135 | function loadPage(pageContainerSelector, pagePath, onSuccess) { 136 | const container = $(pageContainerSelector); 137 | const page = pageContentCache[pagePath]; 138 | if (page != null) { 139 | container.html(page); 140 | onSuccess(); 141 | return null; 142 | } else { 143 | return $.ajax({ 144 | url: pagePath, 145 | success: (page) => { 146 | pageContentCache[pagePath] = page; 147 | container.html(page); 148 | onSuccess(); 149 | } 150 | }); 151 | } 152 | } 153 | 154 | /** 155 | * Run navigation animations for outgoing and ingoing pages. 156 | * @param {boolean} reverse Navigation animation should run in reverse direction. 157 | * Typically used for back (popstate) navigations. 158 | */ 159 | function animatePageChange(reverse) { 160 | animationRunning = true; 161 | 162 | const currentPageZIndex = reverse ? '2' : '1'; 163 | const currentPageLeftTo = reverse ? '100vw' : '-25vw'; 164 | const nextPageZIndex = reverse ? '1' : '2'; 165 | const nextPageLeftFrom = reverse ? '-25vw' : '100vw'; 166 | 167 | $('#page-current') 168 | .css({ 169 | transform: '', 170 | 'z-index': currentPageZIndex 171 | }) 172 | .transition({ x: currentPageLeftTo }, 325); 173 | 174 | $('#page-next') 175 | .css({ 176 | display: '', 177 | transform: `translate(${nextPageLeftFrom})`, 178 | 'z-index': nextPageZIndex 179 | }) 180 | .transition({ x: '0px' }, 325, () => { 181 | animationRunning = false; 182 | restorePagesInitialState(); 183 | if (pendingAnimations) { 184 | pendingAnimations = false; 185 | handleLocation(reverse); 186 | } 187 | }); 188 | } 189 | 190 | /** 191 | * Reset page containers values to default ones. 192 | * It should be run when navigation animation is finished. 193 | */ 194 | function restorePagesInitialState() { 195 | const currentPage = $('#page-current'); 196 | const nextPage = $('#page-next'); 197 | 198 | currentPage 199 | .attr('id', 'page-next') 200 | .css({ 201 | display: 'none', 202 | 'z-index': '1' 203 | }) 204 | .empty(); 205 | 206 | nextPage 207 | .attr('id', 'page-current') 208 | .css({ 209 | display: '', 210 | transform: '', 211 | 'z-index': '2' 212 | }); 213 | } 214 | 215 | /** 216 | * Show snackbar on top of the page content. It attaches to the top-level '#content' container, 217 | * so it's handy to use this method instead of creating such methods in the Route instance directly. 218 | * @param {string} text Snackbar text. 219 | * @param {string} style The style of the Snackbar. It may be one of: 'success', 'warning', 'error'. 220 | * It also impacts on the Telegram's Haptic Feedback. 221 | */ 222 | export function showSnackbar(text, style) { 223 | const colorVariable = style == 'success' ? '--success-color' 224 | : style == 'warning' ? '--warning-color' 225 | : style == 'error' ? '--error-color' 226 | : '--accent-color'; 227 | 228 | Snackbar.showSnackbar( 229 | 'content', 230 | text, 231 | { 232 | 'background-color': `var(${colorVariable})` 233 | } 234 | ); 235 | 236 | TelegramSDK.notificationOccured(style); 237 | } 238 | 239 | /** 240 | * We want to handle location when page history is popped (back button click). 241 | */ 242 | window.onpopstate = () => handleLocation(true); -------------------------------------------------------------------------------- /frontend/icons/logo-laurel.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 55 | 62 | 66 | 71 | 76 | 81 | 86 | 91 | 96 | 101 | 106 | 111 | 116 | 121 | 126 | 131 | 136 | 141 | 146 | 151 | 156 | 161 | 166 | 171 | 176 | 177 | 178 | 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laurel Cafe (Telegram Mini App) 2 | 3 | Laurel Cafe is an imaginary cafe that runs on Telegram as a Mini App. The project demonstrates basic concepts and approaches of building Telegram Mini App. 4 | 5 | ## Features 6 | 7 | The functionality of the app includes the following features: 8 | - View information about the cafe, available categories and popular dishes 9 | - Display menu by category 10 | - Detailed dish information 11 | - Customization of the dish (variation, quantity) when adding to cart 12 | - Viewing and editing items in the cart 13 | - Order placement and payment using Telegram client 14 | - Saving order between sessions, so users can continue next time the app open 15 | - Basic user interaction via messages (order confirmation, launching the application using Inline Button) 16 | 17 |  18 | 19 | ## Before we start 20 | 21 | The goal of the project is to provide a simple, but at the same time functional project that will be easy to understand, replicate, and develop your own Telegram Mini App. 22 | The project deliberately avoids using different frameworks and libraries to make the project understandable for developers of different levels. 23 | 24 | ## Project overview 25 | 26 | The project consists of two modules: **backend** and **frontend**. This means that the project includes all the necessary code to run the app by yourself. The code in each of modules includes documentation describing the purpose and/or principle of operation of a particular method. In this *README* we will focus on the main concepts, while you can deep dive to the project by reading the source files. 27 | 28 | ### Backend 29 | 30 | Backend provides data displayed in the application, such as cafe information or a list of menu categories, and also acts as middleware for interacting with Telegram API (handling events, sending messages and more). 31 | 32 | #### Technologies 33 | 34 | The backend is written in **Python** (*3.11* was used for development and deploy). For the most part, the standard set of tools is used, but the project includes some third-party libraries, including: 35 | - [**Flask**](https://pypi.org/project/Flask/) - Lightweight WSGI web application micro-framework. Used for creating API for both app data (e.g. menu data) and Telegram Bot webhook handling. 36 | - [**Flask-CORS**](https://pypi.org/project/Flask-Cors/) - A Flask extension for handling Cross Origin Resource Sharing (CORS), making cross-origin AJAX possible. It's used as a development dependency. 37 | - [**pyTelegramBotAPI**](https://pypi.org/project/pyTelegramBotAPI/) - Python implementation for the Telegram Bot API. 38 | - [**python-dotenv**](https://pypi.org/project/python-dotenv/) - Reads key-value pairs from a .env file and can set them as environment variables. Allows to set environment variables for development without setting them in OS directly. 39 | 40 | #### Structure 41 | 42 | The backend folder includes two subfolders: **app** and **data**. 43 | 44 | **app** folder contains the following Python files: 45 | - `auth.py` - Mini App user validation. 46 | - `bot.py` - Initialization and interaction with bot. 47 | - `main.py` - Entry point. Includes Flask initialization and configuration, as well as supported API endpoints, including bot webhook. 48 | 49 | **data** folder сontains JSON files with data that is returned by the corresponding API requests. 50 | 51 | #### Environment variables 52 | 53 | The application gets all secrets, as well as some variables such as URLs, from environment variables. For production environment variables are set directly in OS on hosting, for development and running the backend locally you will need to create an `.env` file. Below you will find a list of all supported/used variables. 54 | 55 | `DEV_MODE` - flag indicating that the application is running in development mode. If this flag is set, TeleBot logs will be enabled and `DEV_APP_URL` (if present) will be added to the CORS exception list. 56 | `DEV_APP_URL` - usually the local address where you run the frontend application. If specified, will be added to the CORS exception list in case when `DEV_MODE` variable is presented. 57 | `APP_URL` - production URL of your Mini App (the address where the backend is hosted). This URL will be added to the CORS origin list to allow host frontend and backend on different servers. It is also used to launch the application using [Inline Button](https://core.telegram.org/bots/webapps#inline-button-mini-apps). Note: this address is the real address of your app (the host on which it is deployed). This is the address you gave BotFather when you created the app. Not to be confused with the app address generated by BotFather (like *https://t.me/mybot/myapp*). 58 | `WEBHOOK_URL` - production URL of the event handler (the address where the frontend is hosted). The webhook is re-set on each app startup. 59 | `BOT_TOKEN` - bot token issued by BotFather when creating the bot. 60 | `PAYMENT_PROVIDER_TOKEN` - payment provider token issued when connecting payments. 61 | 62 | #### Running locally 63 | 64 | To test the changes you have made to the backend code and/or the data, or just simply play with it you can deploy the backend locally. 65 | 66 | If this is your first time after you cloned the project, you need to start with initialization of [**venv**](https://code.visualstudio.com/docs/python/environments) (Python Virtual Environment): 67 | 68 | ```shell 69 | cd backend 70 | python -m venv .venv 71 | source .venv/bin/activate # For Linux/MacOS 72 | .venv/Scripts/Activate.ps1 # For Windows 73 | ``` 74 | 75 | When you activated the environment, you need to install all the dependencies: 76 | 77 | ```shell 78 | pip install -r requirements.txt 79 | ``` 80 | 81 | Now you are ready to start the development server: 82 | 83 | ```shell 84 | flask --app app.main:app run 85 | ``` 86 | 87 | You will see in your console something like that: 88 | 89 | ```shell 90 | * Serving Flask app 'app.main:app' 91 | * Debug mode: off 92 | WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. 93 | * Running on http://127.0.0.1:5000 94 | Press CTRL+C to quit 95 | ``` 96 | 97 | Here you can see the address where you can reach your API. *Memorize it, you will need it when working with the frontend part.* 98 | 99 | #### Production deploy 100 | 101 | Since the project uses Flask, you will need a WSGI server to deploy to production. You can find some examples below. 102 | 103 | ##### Phusion Passenger 104 | 105 | Current production version of Laurel Cafe Mini App works on [**Phusion Passenger**](https://www.phusionpassenger.com/) in combination with **cPanel**. To run the project in this combination, in the project root (**backend** folder) you need to create a file `passenger_wsgi.py` with the following contents: 106 | 107 | ```python 108 | from app.main import app as application 109 | ``` 110 | 111 | Then create a Python application in cPanel and specify the following params: 112 | - *Application startup file* - `passenger_wsgi.py` 113 | - *Application Entry point* - `application` 114 | 115 | ##### Vercel 116 | 117 | [**Vercel**](https://vercel.com/) provides possibility to run Python project using Serverless Functions. To do so create in the project root (**backend** folder) the `vercel.json` file: 118 | 119 | ```json 120 | { 121 | "builds": [ 122 | { 123 | "src": "/app/main.py", 124 | "use": "@vercel/python" 125 | } 126 | ], 127 | "routes": [ 128 | { 129 | "src": "/(.*)", 130 | "dest": "/app/main.py" 131 | } 132 | ] 133 | } 134 | ``` 135 | 136 | Then follow the Vercel's [instructions](https://vercel.com/docs/functions/serverless-functions/runtimes/python#web-server-gateway-interface) to deploy the app. 137 | 138 | ### Frontend 139 | 140 | The frontend is designed and works as a SPA (Single Page Application). It uses the usual approaches used in web application development, but takes into account the specifics of the target platform. Thus, first of all, it is adapted for mobile devices, and also part of the key functionality is tied to the features provided by Telegram (for example, the main action button that allows you to add a product to cart or proceed to payment). 141 | 142 | #### Technologies 143 | 144 | The application is a static site with dynamic content. It uses a standard set of **HTML** + **CSS** + **JS**. In addition, the following set of JS libraries is used: 145 | - [**telegram-web-app**](https://telegram.org/js/telegram-web-app.js) - Connector between web app and Telegram client. 146 | - [**jQuery**](https://jquery.com/) - Small, fast JS library, that makes different HTML manipulations easier. 147 | - [**Transit**](https://rstacruz.github.io/jquery.transit/) Smooth CSS transitions & transformations for jQuery. 148 | - [**lottie-web**](https://github.com/airbnb/lottie-web) - AE animations rendering natively on Web. 149 | 150 | There are also some external CSS resources used in the app: 151 | - [**Rubik Font**](https://fonts.google.com/specimen/Rubik?query=rubik) - The font used across the app. 152 | - [**Material Symbols**](https://fonts.google.com/icons) - The latest free icon set from Google. 153 | 154 | #### Structure 155 | 156 | The Frontend folder includes the following subfolders: 157 | - `css` - Contans single `index.css` file with all the styles using in the app. All styles are divided into sections, each of which relates to relevant content 158 | - `icons` - Icons used in the app. 159 | - `js` - JS files describing the app logic. All files are distributed in folders corresponding to their purpose (e.g., routing or pages). The files also contain documentation to help you understand the code. On top level of the folder you may find `index.js` - app entry point. 160 | - `lottie` - Lottie animation files in JSON format used in the app. 161 | - `pages` - Folder with HTML *pseudo-pages*. Since the application is an SPA, it has only one real page described in `index.html`. By *pseudo-pages* we mean a piece (fragment) of content displayed at a given time inside a real page, such as *home* or *cart* content. 162 | 163 | #### Running locally 164 | 165 | To run the frontend app locally, you'll need a server that knows how to host static websites. 166 | 167 | ##### VS Code Live Server 168 | 169 | One of the easiest ways to run an application locally is the [**VS Code Live Server**](https://marketplace.visualstudio.com/items?itemName=ritwickdey.LiveServer) extension. Simply install the extension to VS Code and click the **Go Live** button that will appear in the bottom right corner once the extension is installed. 170 | 171 | ##### Netlify 172 | 173 | [**Netlify**](https://www.netlify.com/) is a powerful platform that, among other things, allows you to host a static website literally in two clicks. To launch a site locally, you need to install the [**Netlify CLI**](https://www.netlify.com/products/cli/) and run the command: 174 | 175 | ```shell 176 | netlify dev 177 | ``` 178 | 179 | When running app locally, you will get the address where it's hosted. You need to set this address to `DEV_APP_URL` variable along with setting `DEV_MODE` variable in backend's `.env` file to resolve CORS issues. See backend's [Running locally](#environment-variables) section for more details. If you run the backend locally, you also need to set local server URL (you got it after running the server) update `baseUrl` variable in `js/requests/requests.js` file: 180 | ```js 181 | const baseUrl = 'http://:'; 182 | ``` 183 | 184 | #### Production deploy 185 | 186 | To host the app on production, just upload content from the `frontend` folder to your hosting. Don't forget to set the `baseUrl` of your production server as well as setting production `APP_URL` environment variable on backend's server machine, when frontend is deployed. 187 | -------------------------------------------------------------------------------- /frontend/css/index.css: -------------------------------------------------------------------------------- 1 | :root, 2 | :root.light { 3 | --bg-color: var(--tg-theme-bg-color, #FFFFFF); 4 | --popover-bg-color: var(--tg-theme-bg-color, #FFFFFF); 5 | --text-color: var(--tg-theme-text-color, #333333); 6 | --accent-color: var(--tg-theme-button-color, #609F6D); 7 | --on-accent-color: var(--tg-theme-button-text-color, #FFFFFF); 8 | --divider-color: #80808025; 9 | --ripple-color: #80808040; 10 | --success-color: #609F6D; 11 | --warning-color: #FFD775; 12 | --error-color: #F45B55; 13 | --shimmer-color-1: #555555; 14 | --shimmer-color-2: #000000; 15 | } 16 | 17 | :root.dark { 18 | --bg-color: var(--tg-theme-secondary-bg-color, var(--tg-theme-bg-color, #121212)); 19 | --popover-bg-color: var(--tg-theme-bg-color, #242424); 20 | --text-color: var(--tg-theme-text-color, #FFFFFF); 21 | --divider-color: #FFFFFF15; 22 | --ripple-color: #FFFFFF30; 23 | --shimmer-color-1: #D0D0D0; 24 | --shimmer-color-2: #FFFFFF; 25 | } 26 | 27 | body { 28 | margin: 0; 29 | color-scheme: var(--tg-color-scheme); 30 | color: var(--text-color); 31 | background-color: var(--bg-color); 32 | } 33 | 34 | h1 { 35 | width: fit-content; 36 | min-width: 45%; 37 | min-height: 1em; 38 | margin: 0; 39 | font-family: 'Rubik', sans-serif; 40 | font-weight: 500; 41 | font-size: 24px; 42 | border-radius: 8px; 43 | display: block; 44 | } 45 | 46 | h2 { 47 | width: fit-content; 48 | margin: 0; 49 | font-family: 'Rubik', sans-serif; 50 | font-size: 20px; 51 | font-weight: 500; 52 | border-radius: 8px; 53 | } 54 | 55 | h3 { 56 | width: fit-content; 57 | margin: 0; 58 | font-family: 'Rubik', sans-serif; 59 | font-size: 16px; 60 | font-weight: 500; 61 | border-radius: 8px; 62 | } 63 | 64 | h6 { 65 | width: fit-content; 66 | min-width: 45%; 67 | min-height: 1em; 68 | margin: 0; 69 | font-family: 'Rubik', sans-serif; 70 | font-size: 13px; 71 | font-weight: 500; 72 | border-radius: 8px; 73 | } 74 | 75 | p { 76 | width: fit-content; 77 | min-width: 90%; 78 | min-height: 1em; 79 | margin: 0; 80 | font-family: 'Rubik', sans-serif; 81 | font-size: 14px; 82 | white-space: wrap; 83 | opacity: 0.36; 84 | border-radius: 8px; 85 | } 86 | 87 | button { 88 | color: var(--text-color); 89 | } 90 | 91 | p.small { 92 | font-size: 11px; 93 | } 94 | 95 | .cover { 96 | width: 100%; 97 | height: calc(100vw * 3 / 4); 98 | object-fit: cover; 99 | border-bottom-left-radius: 24px; 100 | border-bottom-right-radius: 24px; 101 | } 102 | 103 | .page { 104 | width: 100vw; 105 | min-height: 100%; 106 | position: absolute; 107 | background-color: var(--bg-color); 108 | box-shadow: 0 4px 8px 0 rgba(160, 160, 160, 0.1), 0 6px 20px 0 rgba(160, 160, 160, 0.1); 109 | } 110 | 111 | .icon-button { 112 | position: relative; 113 | width: 40px; 114 | height: 40px; 115 | overflow: hidden; 116 | background-color: inherit; 117 | border: none; 118 | border-radius: 50%; 119 | cursor: pointer; 120 | font-size: 24px; 121 | outline: none; 122 | -webkit-tap-highlight-color: transparent; 123 | -webkit-user-select: none; /* Safari */ 124 | -ms-user-select: none; /* IE 10 and IE 11 */ 125 | user-select: none; 126 | } 127 | 128 | .icon-button.small { 129 | width: 32px; 130 | height: 32px; 131 | font-size: 20px; 132 | } 133 | 134 | 135 | 136 | /* Cafe info */ 137 | 138 | .cafe-logo-container { 139 | position: absolute; 140 | width: 72px; 141 | height: 72px; 142 | right: 0; 143 | margin-right: 40px; 144 | margin-top: calc(100vw * 3 / 4 - 84px); 145 | border-radius: 36px; 146 | background-color: var(--popover-bg-color); 147 | box-shadow: inset 148 | 0 0.3px 0.4px hsla(230, 13%, 9%, 0.02), 149 | 0 0.9px 1.5px hsla(230, 13%, 9%, 0.045), 150 | 0 3.5px 6px hsla(230, 13%, 9%, 0.09); 151 | z-index: 2; 152 | } 153 | 154 | .cafe-logo { 155 | width: 100%; 156 | height: 100%; 157 | object-fit: cover; 158 | border-radius: inherit; 159 | } 160 | 161 | .cafe-info-container { 162 | position: relative; 163 | z-index: 1; 164 | width: 100vw - 32px; 165 | margin-left: 16px; 166 | margin-top: -48px; 167 | margin-right: 16px; 168 | border-radius: 24px; 169 | background-color: var(--popover-bg-color); 170 | box-shadow: inset 171 | 0 0.3px 0.4px hsla(230, 13%, 9%, 0.02), 172 | 0 0.9px 1.5px hsla(230, 13%, 9%, 0.045), 173 | 0 3.5px 6px hsla(230, 13%, 9%, 0.09); 174 | padding: 24px; 175 | } 176 | 177 | .cafe-kitchen-categories { 178 | min-height: 1em; 179 | margin-top: 2px; 180 | } 181 | 182 | .cafe-parameters-container { 183 | min-height: calc(1em + 4px); 184 | display: flex; 185 | margin-top: 12px; 186 | font-size: 14px; 187 | justify-content: space-between; 188 | align-items: center; 189 | border-radius: 8px; 190 | } 191 | 192 | .cafe-parameter-container { 193 | display: flex; 194 | align-items: center; 195 | } 196 | 197 | .cafe-parameter-icon { 198 | width: 16px; 199 | height: 16px; 200 | margin-right: 4px; 201 | } 202 | 203 | .cafe-parameter-value { 204 | font-family: 'Rubik', sans-serif; 205 | font-size: 14px; 206 | opacity: 0.72; 207 | } 208 | 209 | .cafe-status { 210 | padding: 4px 16px; 211 | background-color: var(--success-color); 212 | border-radius: 6px; 213 | font-family: 'Rubik', sans-serif; 214 | font-size: 14px; 215 | color: #FFFFFF; 216 | } 217 | 218 | 219 | 220 | /* Sections (e.g. categories or popular */ 221 | 222 | .cafe-section-container { 223 | margin-top: 24px; 224 | } 225 | 226 | .cafe-section-title { 227 | margin-left: 16px; 228 | margin-right: 16px; 229 | } 230 | 231 | .cafe-section-horizontal { 232 | padding: 12px 16px; 233 | overflow-x: scroll; 234 | white-space: nowrap; 235 | display: flex; 236 | flex-wrap: nowrap; 237 | gap: 16px; 238 | -ms-overflow-style: none; 239 | scrollbar-width: none; 240 | } 241 | 242 | .cafe-section-horizontal::-webkit-scrollbar { 243 | display: none; 244 | } 245 | 246 | .cafe-section-vertical { 247 | padding: 16px; 248 | display: flex; 249 | flex-direction: row; 250 | flex-wrap: wrap; 251 | gap: 16px; 252 | } 253 | 254 | 255 | 256 | /* Category item */ 257 | 258 | .cafe-category-container { 259 | min-width: 56px; 260 | height: 56px; 261 | padding: 12px; 262 | border-radius: 16px; 263 | display: flex; 264 | flex-direction: column; 265 | align-items: center; 266 | justify-content: center; 267 | } 268 | 269 | .cafe-category-icon { 270 | width: 32px; 271 | height: 32px; 272 | opacity: 0.36; 273 | } 274 | 275 | .cafe-category-name { 276 | font-family: 'Rubik', sans-serif; 277 | font-size: 12px; 278 | color: #212427; 279 | opacity: .72; 280 | } 281 | 282 | 283 | 284 | /* Cafe item*/ 285 | 286 | .cafe-item-container { 287 | max-width: 224px; 288 | width: calc((100vw - 16px * 3) / 2); 289 | } 290 | 291 | .cafe-item-image { 292 | max-width: 224px; 293 | max-height: 168px; 294 | width: calc((100vw - 16px * 3) / 2); 295 | height: calc((100vw - 16px * 3) / 2 * 3 / 4); 296 | object-fit: cover; 297 | border-radius: 16px; 298 | } 299 | 300 | .cafe-item-name { 301 | margin-top: 8px; 302 | margin-bottom: 4px; 303 | } 304 | 305 | .cafe-item-description { 306 | margin-top: 2px; 307 | } 308 | 309 | 310 | 311 | /* Cafe item details */ 312 | 313 | .cafe-item-details-container { 314 | height: 100vh; 315 | } 316 | 317 | .cafe-item-details-content { 318 | /* Quantity selector container minmimum height x2 */ 319 | padding-bottom: 128px; 320 | } 321 | 322 | .cafe-item-details-quantity-selector-value { 323 | padding: 8px 32px; 324 | background-color: var(--divider-color); 325 | border-radius: 16px; 326 | } 327 | 328 | .cafe-item-details-title-container { 329 | margin: 16px 16px 0 16px; 330 | display: flex; 331 | flex-direction: row; 332 | flex-wrap: nowrap; 333 | align-items: center; 334 | } 335 | 336 | .cafe-item-details-name { 337 | margin-right: 16px; 338 | flex-grow: 1; 339 | } 340 | 341 | .cafe-item-details-selected-variant-weight { 342 | min-width: 2em; 343 | opacity: .72; 344 | } 345 | 346 | .cafe-item-details-description { 347 | min-width: calc(100% - 32px); 348 | min-height: 3em; 349 | margin: 16px 16px 0 16px; 350 | } 351 | 352 | .cafe-item-details-section-title { 353 | margin: 16px 16px 0 16px; 354 | } 355 | 356 | .cafe-item-details-section-price { 357 | display: flex; 358 | flex-wrap: nowrap; 359 | align-items: center; 360 | margin-top: 16px; 361 | padding: 0 16px; 362 | } 363 | 364 | .cafe-item-details-variants { 365 | display: flex; 366 | flex-wrap: wrap; 367 | flex-grow: 1; 368 | gap: 8px; 369 | } 370 | 371 | .cafe-item-details-variant { 372 | display: inline-block; 373 | position: relative; 374 | overflow: hidden; 375 | padding: 8px 16px; 376 | font-family: 'Rubik', sans-serif; 377 | font-size: 14px; 378 | background-color: var(--bg-color); 379 | border: 1px solid var(--divider-color); 380 | border-radius: 20px; 381 | transition: background-color 0.3s linear; 382 | } 383 | 384 | .cafe-item-details-variant.selected { 385 | color: var(--on-accent-color); 386 | background-color: var(--accent-color); 387 | border: 1px solid var(--accent-color); 388 | } 389 | 390 | .cafe-item-details-selected-variant-price { 391 | min-width: 3em; 392 | min-height: 1em; 393 | } 394 | 395 | .cafe-item-details-quantity-selector-container { 396 | position: fixed; 397 | bottom: 0; 398 | left: 0; 399 | width: 100%; 400 | min-height: 64px; 401 | background-color: var(--popover-bg-color); 402 | box-shadow: inset 403 | 0 0.3px 0.4px hsla(230, 13%, 9%, 0.02), 404 | 0 0.9px 1.5px hsla(230, 13%, 9%, 0.045), 405 | 0 3.5px 15px hsla(230, 13%, 9%, 0.09); 406 | border-top-left-radius: 24px; 407 | border-top-right-radius: 24px; 408 | z-index: 2; 409 | display: flex; 410 | flex-wrap: nowrap; 411 | flex-direction: row; 412 | align-items: center; 413 | justify-content: center; 414 | gap: 16px; 415 | } 416 | 417 | 418 | 419 | /* Cart item */ 420 | 421 | .cart-items-container { 422 | height: 100vh; 423 | } 424 | 425 | .cart-item-container { 426 | display: flex; 427 | flex-wrap: nowrap; 428 | padding: 8px 16px; 429 | } 430 | 431 | .cart-item-image { 432 | width: 56px; 433 | height: 56px; 434 | border-radius: 16px; 435 | object-fit: cover; 436 | } 437 | 438 | .cart-item-info-container { 439 | flex: 1; 440 | padding: 0 16px; 441 | white-space: wrap; 442 | } 443 | 444 | .cart-item-name { 445 | margin-top: 4px; 446 | } 447 | 448 | .cart-item-description { 449 | margin-top: 2px; 450 | } 451 | 452 | .cart-item-cost { 453 | margin-top: 4px; 454 | font-family: 'Rubik', sans-serif; 455 | font-weight: 500; 456 | font-size: 13px; 457 | color: var(--accent-color); 458 | } 459 | 460 | .cart-item-quantity-container { 461 | height: 56px; 462 | display: flex; 463 | flex-wrap: nowrap; 464 | flex-direction: row; 465 | align-items: center; 466 | } 467 | 468 | .cart-item-quantity { 469 | min-width: 28px; 470 | height: 28px; 471 | margin: 0 12px; 472 | font-family: 'Rubik', sans-serif; 473 | font-weight: 500; 474 | font-size: 13px; 475 | line-height: 28px; 476 | color: var(--on-accent-color); 477 | background-color: var(--accent-color); 478 | border-radius: 14px; 479 | text-align: center; 480 | } 481 | 482 | .cart-empty-placeholder { 483 | position: absolute; 484 | width: 100%; 485 | height: 100%; 486 | left: 0; 487 | top: -88px; 488 | right: 0; 489 | display: flex; 490 | flex-direction: column; 491 | align-items: center; 492 | justify-content: center; 493 | } 494 | 495 | .cart-empty-placeholder h3 { 496 | margin-top: -88px; 497 | text-align: center; 498 | } 499 | 500 | .cart-empty-placeholder p { 501 | margin-top: 4px; 502 | text-align: center; 503 | } 504 | 505 | 506 | 507 | /* Common (effect, animations, etc.) */ 508 | 509 | .snackbar { 510 | display: block; 511 | position: fixed; 512 | bottom: 16px; 513 | left: 0; 514 | right: 0; 515 | margin: 0 16px; 516 | font-family: 'Rubik', sans-serif; 517 | font-size: 14px; 518 | color: var(--on-accent-color); 519 | background-color: var(--accent-color); 520 | padding: 16px 24px; 521 | border-radius: 16px; 522 | z-index: 3; 523 | opacity: 0; 524 | transform: scale(0.24); 525 | } 526 | 527 | .ripple { 528 | position: absolute; 529 | border-radius: 50%; 530 | background: var(--ripple-color); 531 | transform: scale(0); 532 | } 533 | 534 | .shimmer { 535 | animation-duration: 2.2s; 536 | animation-fill-mode: forwards; 537 | animation-iteration-count: infinite; 538 | animation-name: shimmer; 539 | animation-timing-function: linear; 540 | background: linear-gradient(to right, var(--shimmer-color-1) 8%, var(--shimmer-color-2) 18%, var(--shimmer-color-1) 33%); 541 | background-size: 1200px 100%; 542 | color: transparent; 543 | opacity: .075; 544 | } 545 | 546 | @-webkit-keyframes shimmer { 547 | 0% { 548 | background-position: -100% 0; 549 | } 550 | 100% { 551 | background-position: 100% 0; 552 | } 553 | } 554 | 555 | @keyframes shimmer { 556 | 0% { 557 | background-position: -1200px 0; 558 | } 559 | 100% { 560 | background-position: 1200px 0; 561 | } 562 | } --------------------------------------------------------------------------------
It's time to order something delicious!
Small
500g