diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a01e8e..f40a28e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,25 +11,59 @@ permissions: jobs: build: - runs-on: ubuntu-latest + env: + TEST_DB_NAME: cafe_test + DB_USER: root # do not change + DB_PASSWORD: root # do not change + steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: mbstring, intl + ini-values: post_max_size=256M, max_execution_time=180, variables_order="EGPCS" + coverage: xdebug + tools: php-cs-fixer, phpunit + + - name: Setup database + run: | + sudo /etc/init.d/mysql start + mysql -e "CREATE DATABASE IF NOT EXISTS $TEST_DB_NAME;" -u$DB_USER -p$DB_PASSWORD + mysql -D$TEST_DB_NAME -u$DB_USER -p$DB_PASSWORD -hlocalhost -P3306 < "resources/database/dump/cafe.sql" + mysql -e "USE cafe_test; SHOW TABLES;" -u$DB_USER -p$DB_PASSWORD + + - name: Create .env file + env: + ENV: | + PUBLIC_ROOT="http://localhost/steamy-sips/public" + DB_HOST="localhost" + DB_USERNAME="root" + DB_PASSWORD="root" + TEST_DB_NAME="cafe_test" + BUSINESS_GMAIL="" + BUSINESS_GMAIL_PASSWORD="" + run: | + echo "$ENV" > src/core/.env + cat src/core/.env - name: Validate composer.json and composer.lock run: composer validate --strict - name: Cache Composer packages id: composer-cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: vendor key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} restore-keys: | ${{ runner.os }}-php- - - name: Install dependencies + - name: Install Composer dependencies run: composer install --prefer-dist --no-progress - name: Run test suite diff --git a/docs/CODING_STANDARDS.md b/docs/CODING_STANDARDS.md index da22831..827e5ef 100644 --- a/docs/CODING_STANDARDS.md +++ b/docs/CODING_STANDARDS.md @@ -4,16 +4,19 @@ Adhering to a standard helps maintain code quality, readability, and consistency ## PHP -All PHP files follow the [PSR-12](https://www.php-fig.org/psr/psr-12/) coding style. +- All PHP files follow the [PSR-12](https://www.php-fig.org/psr/psr-12/) coding style. +- `phpDocs` is used for documentation. ## JavaScript -`Prettier` is used for formatting and `ESLint` is used for linting. +- [`jsDocs`](https://en.wikipedia.org/wiki/JSDoc) is used for documentation. +- `Prettier` is used for formatting +- `ESLint` is used for linting. ## CSS -`Prettier` is used for formatting. +- `Prettier` is used for formatting. ## SQL -https://www.sqlstyle.guide \ No newline at end of file +- Some guidelines from the [SQL Style Guide](https://www.sqlstyle.guide) have been followed. \ No newline at end of file diff --git a/docs/DB_DESIGN.md b/docs/DB_DESIGN.md index 280ec32..53245ca 100644 --- a/docs/DB_DESIGN.md +++ b/docs/DB_DESIGN.md @@ -32,11 +32,11 @@ ### administrator -| Attribute | Description | Data Type | Constraints | -| ------------- | ------------------------------------------------- | ------------ | ------------------------------------------------- | -| user_id | ID of administrator | INT(11) | PRIMARY KEY, FOREIGN KEY REFERENCES user(user_id) | -| job_title | Job title of administrator | VARCHAR(255) | NOT NULL, Must have length > 0 | -| is_superadmin | Whether the administrator is a super admin or not | TINYINT(1) | DEFAULT false | +| Attribute | Description | Data Type | Constraints | +|----------------| ------------------------------------------------- | ------------ | ------------------------------------------------- | +| user_id | ID of administrator | INT(11) | PRIMARY KEY, FOREIGN KEY REFERENCES user(user_id) | +| job_title | Job title of administrator | VARCHAR(255) | NOT NULL, Must have length > 0 | +| is_super_admin | Whether the administrator is a super admin or not | TINYINT(1) | DEFAULT false | ### client diff --git a/docs/FILESYSTEM.md b/docs/FILESYSTEM.md index f99a664..1d58a0f 100644 --- a/docs/FILESYSTEM.md +++ b/docs/FILESYSTEM.md @@ -1,6 +1,6 @@ # Filesystem -The filesystem for this project was adapted from https://github.com/php-pds/skeleton. +The filesystem for this project was adapted from the [`pds/skeleton package`](https://github.com/php-pds/skeleton). ## Summary @@ -9,7 +9,6 @@ A package MUST use these names for these root-level directories: | If a package has a root-level directory for ... | ... then it MUST be named: | |-------------------------------------------------|----------------------------| | command-line executables | `bin/` | -| configuration files | `config/` | | documentation files | `docs/` | | web server files | `public/` | | other resource files (eg. sql files) | `resources/` | @@ -38,14 +37,6 @@ files, it MUST be named `bin/`. This publication does not otherwise define the structure and contents of the directory. -### config/ - -If the package provides a root-level directory for configuration files, it MUST -be named `config/`. - -This publication does not otherwise define the structure and contents of the -directory. - ### docs/ If the package provides a root-level directory for documentation files, it MUST @@ -59,6 +50,12 @@ directory. If the package provides a root-level directory for web server files, it MUST be named `public/`. **This directory is intended as a web server document root.** +| Subdirectory Name | Description | +|-------------------|--------------------------------------------------------------------------| +| assets/ | Contains static assets such as images, fonts, or other resources. | +| js/ | Stores JavaScript files responsible for client-side scripting behaviors. | +| styles/ | Holds CSS files defining styles and layouts for the application's views. | + ### resources/ If the package provides a root-level directory for other resource files, it MUST @@ -67,35 +64,17 @@ be named `resources/`. This publication does not otherwise define the structure and contents of the directory. -#### resources/database -All SQL files must be placed inside the `database` sub-folder: - -``` -|-- /database -| |-- /schema -| | |-- schema.sql # Database schema definition -| | -| |-- /stored_procedures -| | |-- procedure1.sql # Stored procedure scripts -| | |-- procedure2.sql -| | -| |-- /views -| | |-- view1.sql # Database view scripts -| | |-- view2.sql -| | -| |-- /triggers -| | |-- trigger1.sql # Database trigger scripts -| | |-- trigger2.sql -| | -| |-- /sqldump -| |-- backup.sql # SQL dump of the database -| -``` - ### src/ If the package provides a root-level directory for **PHP source code files**, it -MUST be named `src/`. The structure of this directory follows the MVC pattern. +MUST be named `src/`. The structure of this directory follows the Model-View-Controller (MVC) pattern. + +| Subdirectory Name | Description | +|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| controllers/ | Houses PHP files responsible for handling incoming requests, processing data, and orchestrating the flow of the application. | +| core/ | Encapsulates essential classes, functions, and utilities forming the foundational elements of the application. | +| models/ | Contains PHP files representing the application's data structures and business logic, facilitating interaction with the database and enforcing business rules. | +| views/ | Contains PHP templates or files responsible for presenting data to the user, focusing on rendering user interfaces and facilitating user interaction. | ### tests/ diff --git a/docs/INSTALLATION_GUIDE.md b/docs/INSTALLATION_GUIDE.md index fb5ed64..2e7ffec 100644 --- a/docs/INSTALLATION_GUIDE.md +++ b/docs/INSTALLATION_GUIDE.md @@ -10,8 +10,9 @@ some commands may differ. Please adapt accordingly. - MySQL (v15.1 preferred) - Composer with its executable on your $PATH - Git +- NPM (optional) -## Setup project +## Project setup Navigate to the document root of your server: @@ -56,14 +57,14 @@ BUSINESS_GMAIL_PASSWORD="" Some important notes: - Update the values assigned to `DB_USERNAME` and `DB_PASSWORD` with your MySQL login details. -- If your Apache server is serving from a port other than the default one, add the new port number to `PUBLIC_ROOT` ( - eg., `http://localhost:443/steamy-sips/public`) . +- If your Apache server is serving from a port other than the default one, include the port number to `PUBLIC_ROOT` ( + e.g., `http://localhost:443/steamy-sips/public`) . - `BUSINESS_GMAIL` and `BUSINESS_GMAIL_PASSWORD` are the credentials of the Gmail account from which emails will be sent whenever a client places an order. It is recommended to use a [Gmail App password](https://knowledge.workspace.google.com/kb/how-to-create-app-passwords-000009237) for `BUSINESS_GMAIL_PASSWORD` instead of your actual gmail account password. -## Setup production database +## Database setup Start your MySQL server and connect to its monitor: @@ -76,32 +77,37 @@ mysql -u -p Create a database `cafe`: -```bash +```sql create database cafe; -``` - -Select the database: - -``` use cafe; +source resources/database/dump/cafe.sql; +exit; ``` -Import data to the database from the SQL dump: +The path to the SQL dump might must be modified if you are not in the root directory of the project. -```bash -source resources/database/dump/cafe.sql -``` +If you want to run unit tests with composer, you must first set up a separate database for testing. To do so, repeat the +same +instructions as above except name the testing database `cafe_test`: -The path to the SQL dump might must be modified if you are not in the root directory of the project. +```sql +create database cafe_test; +use cafe_test; +source resources/database/dump/cafe.sql; +exit; +``` -## Setup testing database +## PHP setup -If you want to run tests for the application, you must setup a database for testing. To do so, repeat the same -instructions as the setup for the production database except name the testing database `cafe_test`. +Ensure that the [`variables_order`](https://www.php.net/manual/en/ini.core.php#ini.variables-) directive in +your `php.ini` +file is set to `"EGPCS"`. Without this, the application will +not be able to load environment variables properly in `src/core/config.php` and you will get an array key error. +You can use `php --ini` to find the location of your `php.ini` file. -## Setup linting and formatting +## Linting and formatting setup -This step is optional if you do not plan on editing the JS and CSS files. Node.js is required to install the linter and +This step is optional if you do not plan on editing the JS and CSS files. NPM is required to install the linter and formatter for JS and CSS files. For more details on the linters and formatters used, see our [coding standards](CODING_STANDARDS.md). diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index 98dc589..c778dbf 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -17,9 +17,9 @@ Optionally, you can display a live error log: sudo tail -f /var/log/apache2/error.log ``` -Go to http://localhost/steamy-sips/public/ (or the value you have set for `PUBLIC_ROOT` in -your [`src/core/.env`](../src/core/.env) -in your browser to see the website. +Enter the `PUBLIC_ROOT` value (e.g., http://localhost/steamy-sips/public/) from [`src/core/.env`](../src/core/.env) in +your browser +to access the client website. ## Run tests @@ -34,5 +34,5 @@ composer test To export database with `mysqldump`: ```bash -mysqldump -u root -p cafe > cafe.sql +mysqldump -u root -p cafe > resources/database/dump/cafe.sql ``` \ No newline at end of file diff --git a/public/assets/img/product/americano.jpeg b/public/assets/img/product/americano.jpeg deleted file mode 100644 index 5b2caa5..0000000 Binary files a/public/assets/img/product/americano.jpeg and /dev/null differ diff --git a/public/assets/img/product/americano.png b/public/assets/img/product/americano.png new file mode 100644 index 0000000..04133a5 Binary files /dev/null and b/public/assets/img/product/americano.png differ diff --git a/public/assets/img/product/americano.webp b/public/assets/img/product/americano.webp new file mode 100644 index 0000000..b3a42b6 Binary files /dev/null and b/public/assets/img/product/americano.webp differ diff --git a/public/assets/img/product/cappuccino.jpg b/public/assets/img/product/cappuccino.jpg deleted file mode 100644 index 7108b3a..0000000 Binary files a/public/assets/img/product/cappuccino.jpg and /dev/null differ diff --git a/public/assets/img/product/cappuccino.webp b/public/assets/img/product/cappuccino.webp new file mode 100644 index 0000000..2f3d4dc Binary files /dev/null and b/public/assets/img/product/cappuccino.webp differ diff --git a/public/assets/img/product/espresso.jpg b/public/assets/img/product/espresso.jpg deleted file mode 100644 index adb0889..0000000 Binary files a/public/assets/img/product/espresso.jpg and /dev/null differ diff --git a/public/assets/img/product/espresso.webp b/public/assets/img/product/espresso.webp new file mode 100644 index 0000000..8fcbcc9 Binary files /dev/null and b/public/assets/img/product/espresso.webp differ diff --git a/public/assets/img/product/latte.avif b/public/assets/img/product/latte.avif new file mode 100644 index 0000000..e42c2e4 Binary files /dev/null and b/public/assets/img/product/latte.avif differ diff --git a/public/assets/img/product/latte.jpeg b/public/assets/img/product/latte.jpeg deleted file mode 100644 index 1866fd0..0000000 Binary files a/public/assets/img/product/latte.jpeg and /dev/null differ diff --git a/public/assets/img/product/mocha.png b/public/assets/img/product/mocha.png index 922eef1..3005aaa 100644 Binary files a/public/assets/img/product/mocha.png and b/public/assets/img/product/mocha.png differ diff --git a/public/js/add-to-cart.js b/public/js/add-to-cart.js new file mode 100644 index 0000000..92c962c --- /dev/null +++ b/public/js/add-to-cart.js @@ -0,0 +1,32 @@ +import { Cart, CartItem } from "./cart"; +import ModalManager from "./modal"; + +const modal = ModalManager("my-modal"); + +function handleAddToCart(e) { + // capture form submission + e.preventDefault(); + + // extract form data + const formData = new FormData(e.target); + const formProps = Object.fromEntries(formData); + + const item = CartItem( + parseInt(formProps.product_id, 10), + parseInt(formProps.quantity, 10), + formProps.cupSize, + formProps.milkType, + ); + Cart().addItem(item); + + // open modal to display success + modal.openModal(); +} + +window.addEventListener("DOMContentLoaded", function () { + document + .getElementById("product-customization-form") + .addEventListener("submit", handleAddToCart); +}); + +modal.init(); diff --git a/public/js/cart-uploader.js b/public/js/cart-uploader.js new file mode 100644 index 0000000..c9d4b06 --- /dev/null +++ b/public/js/cart-uploader.js @@ -0,0 +1,26 @@ +import { Cart } from "./cart"; + +/** + * This script is executed when user accesses /cart. It sends the cart + * data from localStorage to the server and then reloads the page. + * @returns {Promise} + */ +async function uploadCart() { + // send cart data to server + const items = Cart().getItems(); + console.log(items); + const request = await fetch(window.location.href, { + method: "POST", + // redirect: "follow", + body: JSON.stringify(items), + }); + // console.log(request); + + // add loading delay of 1s + await new Promise((r) => setTimeout(r, 1000)); + + // reload page so that server can display the order details + location.reload(); +} + +window.addEventListener("DOMContentLoaded", uploadCart); diff --git a/public/js/cart-view.js b/public/js/cart-view.js new file mode 100644 index 0000000..f8bedac --- /dev/null +++ b/public/js/cart-view.js @@ -0,0 +1,51 @@ +/** + * This script is executed on /cart page. It allows users to modify their cart in real-time and view the updated totals. + */ + +import { Cart, CartItem } from "./cart"; + +function updateCart(e) { + const sectionNode = e.target.parentNode.parentNode; + + // get cart item original attributes (before update) + const currentCartItem = CartItem( + parseInt(sectionNode.getAttribute("data-productid"), 10), + parseInt(sectionNode.getAttribute("data-quantity"), 10), + sectionNode.getAttribute("data-cupsize"), + sectionNode.getAttribute("data-milktype"), + ); + + // console.log("Old item", currentCartItem); + + // calculate new subtotal + const newQuantity = parseInt(e.target.value, 10); + const unitPrice = parseFloat(sectionNode.getAttribute("data-unitprice")); + const newSubTotal = Math.round(newQuantity * unitPrice * 100) / 100; + + // display new subtotal + const priceNode = sectionNode.querySelector(".container > strong"); + priceNode.textContent = "Rs " + newSubTotal; + + // update quantity on actual node + sectionNode.setAttribute("data-quantity", newQuantity); + + // update localstorage + const currentCart = Cart(); + currentCart.removeItem(currentCartItem); + + // a quantity of 0 means to remove the item from cart + if (newQuantity > 0) { + currentCartItem.quantity = newQuantity; + currentCart.addItem(currentCartItem); + } +} + +window.addEventListener("DOMContentLoaded", function () { + const quantityInputs = [ + ...document.querySelectorAll("section input[type='number']"), + ]; + + quantityInputs.forEach((input) => { + input.addEventListener("change", updateCart); + }); +}); diff --git a/public/js/cart.js b/public/js/cart.js index 307e948..b0d265d 100644 --- a/public/js/cart.js +++ b/public/js/cart.js @@ -1,33 +1,135 @@ -function myCart() { - function setItem(productID, size) { - // get all cart contents from localStorage +/** + * A cart item object. + * @typedef {{productID, quantity, cupSize, milkType}} CartItem + */ + +/** + * Factory function for cart item objects + * @param {number} productID Product ID + * @param {number} quantity Number of times the product with the specific customizations is ordered + * @param {string} cupSize Size of drink (small, medium, large, ...) + * @param {string} milkType Type of milk (almond, coconut, ...) + * @returns {{productID, quantity, cupSize, milkType}} + */ +const CartItem = (productID, quantity, cupSize, milkType) => { + return { productID, quantity, cupSize, milkType }; +}; + +/** + * A function for managing the cart in localStorage. + * @returns {{getItems: (function(): CartItem[]), removeItem: (function(CartItem): void), isEmpty: (function(): boolean), + * clear: (function(): void), addItem: (function(CartItem): void)}} + */ +function Cart() { + /** + * Adds a new item to shopping cart + * @param {CartItem} item + */ + function addItem(item) { + // get all cart items from localStorage const currentCart = getItems(); - // check if product ID already exists in array - // and make changes + // check if there is already an identical item (ignoring quantity) in the cart + let duplicateFound = false; + for (let i = 0; i < currentCart.length; i++) { + const currentItem = currentCart[i]; + + if ( + currentItem.productID === item.productID && + currentItem.cupSize === item.cupSize && + currentItem.milkType === item.milkType + ) { + duplicateFound = true; - // add new product id to array - currentCart.push(productID); + // increment quantity + currentItem.quantity += item.quantity; + } + } + + if (!duplicateFound) currentCart.push(item); // save final cart back to localStorage localStorage.setItem("cart", JSON.stringify(currentCart)); } + /** + * Check if the shopping cart contains no items + * @returns {boolean} True if cart is empty + */ function isEmpty() { return getItems().length === 0; } + /** + * An array of all items in the cart + * @returns {Array} + */ function getItems() { return JSON.parse(localStorage.getItem("cart") || "[]"); } - function removeProduct(productID) { + /** + * Checks if 2 cart items are identical. To be identical, they must have + * the same product ID, quantity, size, and milk. + * @param {CartItem} item1 + * @param {CartItem} item2 + * @returns {boolean} + */ + function compareCartItems(item1, item2) { + return JSON.stringify(item1) === JSON.stringify(item2); + } + + /** + * Remove a product and its associated information from the shopping cart + * @param {CartItem} itemToBeRemoved product ID of product to be removed + */ + function removeItem(itemToBeRemoved) { const currentCart = getItems(); - const newCart = currentCart.filter((id) => id !== productID); + + const newCart = currentCart.filter( + (item) => !compareCartItems(item, itemToBeRemoved), + ); localStorage.setItem("cart", JSON.stringify(newCart)); } + /** + * Empties the shopping cart + */ function clear() { localStorage.setItem("cart", "[]"); } + + return { addItem, isEmpty, getItems, removeItem, clear }; } + +// function testCart() { +// const myCart = cart(); +// myCart.clear(); +// console.log("Initial cart = ", myCart.getItems()); +// +// const order1 = CartItem(1, 2, "small", "almond"); +// const order2 = CartItem(2, 2, "large", "almond"); +// +// console.log("Add 2 orders"); +// myCart.addItem(order1); +// myCart.addItem(order2); +// console.log("Final cart = ", myCart.getItems()); +// +// console.log("Add a duplicate order"); +// myCart.addItem(CartItem(1, 1, "small", "almond")); +// console.log("Final cart = ", myCart.getItems()); +// +// console.log("Remove first order"); +// myCart.removeItem(order1); +// console.log("Final cart = ", myCart.getItems()); +// +// console.log("Remove non-existent order"); +// myCart.removeItem(CartItem(999, 2, "small", "almond")); +// console.log("Final cart = ", myCart.getItems()); +// +// console.log("Clear cart"); +// myCart.clear(); +// console.log("Cart == empty ?", myCart.isEmpty()); +// } + +export { Cart, CartItem }; diff --git a/public/js/modal.js b/public/js/modal.js index a23d121..82fff09 100644 --- a/public/js/modal.js +++ b/public/js/modal.js @@ -1,113 +1,119 @@ -/* - * Modal +/** + * A function for managing a modal * - * Reference: https://github.com/picocss/examples/blob/master/v1-preview/js/modal.js - * Pico.css - https://picocss.com - * Copyright 2019-2023 - Licensed under MIT + * @param {string} modalID ID of modal in document + * @returns {{ init: (function(): void), openModal: (function(): void), closeModal: (function(): void)}} + * @constructor + * + * Adapted from: https://github.com/picocss/examples/blob/master/v1-preview/js/modal.js + * Pico.css - https://picocss.com + * Copyright 2019-2023 - Licensed under MIT */ +function ModalManager(modalID) { + const isOpenClass = "modal-is-open"; + const openingClass = "modal-is-opening"; + const closingClass = "modal-is-closing"; + const animationDuration = 600; // ms + let visibleModal = null; + const modal = document.getElementById(modalID); + + // Toggle modal + const toggleModal = (event) => { + event.preventDefault(); + + typeof modal != "undefined" && modal != null && isModalOpen(modal) + ? closeModal(modal) + : openModal(modal); + }; + + // Is modal open + const isModalOpen = () => { + return modal.hasAttribute("open") && modal.getAttribute("open") !== "false"; + }; + + // Open modal + const openModal = () => { + if (isScrollbarVisible()) { + document.documentElement.style.setProperty( + "--scrollbar-width", + `${getScrollbarWidth()}px`, + ); + } + document.documentElement.classList.add(isOpenClass, openingClass); + setTimeout(() => { + visibleModal = modal; + document.documentElement.classList.remove(openingClass); + }, animationDuration); + modal.setAttribute("open", true); + }; + + // Close modal + const closeModal = () => { + visibleModal = null; + document.documentElement.classList.add(closingClass); + setTimeout(() => { + document.documentElement.classList.remove(closingClass, isOpenClass); + document.documentElement.style.removeProperty("--scrollbar-width"); + modal.removeAttribute("open"); + }, animationDuration); + }; + + // Get scrollbar width + const getScrollbarWidth = () => { + // Creating invisible container + const outer = document.createElement("div"); + outer.style.visibility = "hidden"; + outer.style.overflow = "scroll"; // forcing scrollbar to appear + outer.style.msOverflowStyle = "scrollbar"; // needed for WinJS apps + document.body.appendChild(outer); + + // Creating inner element and placing it in the container + const inner = document.createElement("div"); + outer.appendChild(inner); + + // Calculating difference between container's full width and the child width + const scrollbarWidth = outer.offsetWidth - inner.offsetWidth; + + // Removing temporary elements from the DOM + outer.parentNode.removeChild(outer); + + return scrollbarWidth; + }; + + // Is scrollbar visible + const isScrollbarVisible = () => { + return document.body.scrollHeight > screen.height; + }; + + const init = () => { + // Close modal with a click outside + document.addEventListener("click", (event) => { + if (visibleModal != null) { + const modalContent = visibleModal.querySelector("article"); + const isClickInside = modalContent.contains(event.target); + !isClickInside && closeModal(visibleModal); + } + }); + + // Close modal with Esc key + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && visibleModal != null) { + closeModal(visibleModal); + } + }); + + // Add event listeners to modal components + document + .querySelector(`#${modalID} article > a[data-target="${modalID}"]`) + .addEventListener("click", (e) => toggleModal(e)); // close button in top right corner + document + .querySelector(`#${modalID} article > footer a[data-target="${modalID}"]`) + .addEventListener("click", (e) => toggleModal(e)); // close button in footer + + // console.log("Event listeners added for modal"); + }; + + return { openModal, closeModal, init }; +} -// Config -const isOpenClass = "modal-is-open"; -const openingClass = "modal-is-opening"; -const closingClass = "modal-is-closing"; -const animationDuration = 600; // ms -let visibleModal = null; - -// Toggle modal -const toggleModal = (event) => { - event.preventDefault(); - const modal = document.getElementById( - event.currentTarget.getAttribute("data-target"), - ); - typeof modal != "undefined" && modal != null && isModalOpen(modal) - ? closeModal(modal) - : openModal(modal); -}; - -// Is modal open -const isModalOpen = (modal) => { - return modal.hasAttribute("open") && modal.getAttribute("open") !== "false"; -}; - -// Open modal -const openModal = (modal) => { - if (isScrollbarVisible()) { - document.documentElement.style.setProperty( - "--scrollbar-width", - `${getScrollbarWidth()}px`, - ); - } - document.documentElement.classList.add(isOpenClass, openingClass); - setTimeout(() => { - visibleModal = modal; - document.documentElement.classList.remove(openingClass); - }, animationDuration); - modal.setAttribute("open", true); -}; - -// Close modal -const closeModal = (modal) => { - visibleModal = null; - document.documentElement.classList.add(closingClass); - setTimeout(() => { - document.documentElement.classList.remove(closingClass, isOpenClass); - document.documentElement.style.removeProperty("--scrollbar-width"); - modal.removeAttribute("open"); - }, animationDuration); -}; - -// Close with a click outside -document.addEventListener("click", (event) => { - if (visibleModal != null) { - const modalContent = visibleModal.querySelector("article"); - const isClickInside = modalContent.contains(event.target); - !isClickInside && closeModal(visibleModal); - } -}); - -// Close with Esc key -document.addEventListener("keydown", (event) => { - if (event.key === "Escape" && visibleModal != null) { - closeModal(visibleModal); - } -}); - -// Get scrollbar width -const getScrollbarWidth = () => { - // Creating invisible container - const outer = document.createElement("div"); - outer.style.visibility = "hidden"; - outer.style.overflow = "scroll"; // forcing scrollbar to appear - outer.style.msOverflowStyle = "scrollbar"; // needed for WinJS apps - document.body.appendChild(outer); - - // Creating inner element and placing it in the container - const inner = document.createElement("div"); - outer.appendChild(inner); - - // Calculating difference between container's full width and the child width - const scrollbarWidth = outer.offsetWidth - inner.offsetWidth; - - // Removing temporary elements from the DOM - outer.parentNode.removeChild(outer); - - return scrollbarWidth; -}; - -// Is scrollbar visible -const isScrollbarVisible = () => { - return document.body.scrollHeight > screen.height; -}; - -// Add event listeners to modal components -document - .querySelector('#cart-modal article > a[data-target="cart-modal"]') - .addEventListener("click", (e) => toggleModal(e)); -document - .querySelector('#cart-modal article > footer a[data-target="cart-modal"]') - .addEventListener("click", (e) => toggleModal(e)); - -// add event listener to shopping cart icon in nav bar -document - .querySelector('nav li[data-target="cart-modal"]') - .addEventListener("click", (e) => toggleModal(e)); +export default ModalManager; diff --git a/public/styles/global.css b/public/styles/global.css index 8601929..2ac51ec 100644 --- a/public/styles/global.css +++ b/public/styles/global.css @@ -7,7 +7,8 @@ body > nav { position: sticky; top: 0; z-index: 3; - background-color: var(--background-color); + /*noinspection CssUnresolvedCustomProperty*/ + background-color: var(--background-color); /* defined in picocss */ } body > nav .icon { @@ -62,6 +63,7 @@ body > nav [data-tooltip]:not(a,button,input){ } #cart-items article button { - background-color: var(--del-color); + /*noinspection CssUnresolvedCustomProperty*/ + background-color: var(--del-color); /* defined in picocss */ border: 0; } \ No newline at end of file diff --git a/public/styles/views/Cart.css b/public/styles/views/Cart.css new file mode 100644 index 0000000..8d72fba --- /dev/null +++ b/public/styles/views/Cart.css @@ -0,0 +1,26 @@ +section { + margin-bottom: 0; +} + +.cart-item { + padding-top: 10px; + display: flex; + gap: 1em; + border-bottom: 1px solid gray; +} + +section:nth-child(2) { + border-top: 1px solid gray; +} + +.cart-item > img { + flex: 1; +} + +.cart-item > .container { + flex: 2; +} + +.cart-item > label { + flex: 1; +} diff --git a/public/styles/views/Dashboard.css b/public/styles/views/Dashboard.css deleted file mode 100644 index 29fdf00..0000000 --- a/public/styles/views/Dashboard.css +++ /dev/null @@ -1,3 +0,0 @@ -h1 { - margin-bottom: 0; -} diff --git a/public/styles/views/Register.css b/public/styles/views/Register.css deleted file mode 100644 index e69de29..0000000 diff --git a/resources/database/dump/cafe.sql b/resources/database/dump/cafe.sql index 87c0bc2..a4e1ae7 100644 --- a/resources/database/dump/cafe.sql +++ b/resources/database/dump/cafe.sql @@ -57,8 +57,8 @@ CREATE TABLE `client` ( KEY `client_district_district_id_fk` (`district_id`), CONSTRAINT `client_district_district_id_fk` FOREIGN KEY (`district_id`) REFERENCES `district` (`district_id`) ON UPDATE CASCADE, CONSTRAINT `client_fk` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT `city_length` CHECK (char_length(`city`) > 2), - CONSTRAINT `street_length` CHECK (char_length(`street`) > 3) + CONSTRAINT `client_city_length` CHECK (char_length(`city`) > 2), + CONSTRAINT `client_street_length` CHECK (char_length(`street`) > 3) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -151,9 +151,9 @@ CREATE TABLE `order_product` ( KEY `order_product_2fk` (`product_id`), CONSTRAINT `order_product_1fk` FOREIGN KEY (`order_id`) REFERENCES `order` (`order_id`) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT `order_product_2fk` FOREIGN KEY (`product_id`) REFERENCES `product` (`product_id`) ON UPDATE CASCADE, - CONSTRAINT `quantity_range` CHECK (`quantity` >= 0), CONSTRAINT `cup_size` CHECK (`cup_size` in ('small','medium','large')), - CONSTRAINT `milk_type` CHECK (`milk_type` in ('almond','coconut','oat','soy')) + CONSTRAINT `milk_type` CHECK (`milk_type` in ('almond','coconut','oat','soy')), + CONSTRAINT `quantity_range` CHECK (`quantity` > 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -238,7 +238,7 @@ CREATE TABLE `product` ( CONSTRAINT `name_length` CHECK (char_length(`name`) > 2), CONSTRAINT `img_alt_text_length` CHECK (char_length(`img_alt_text`) between 5 and 150), CONSTRAINT `category_length` CHECK (char_length(`category`) > 2), - CONSTRAINT `img_url_format` CHECK (`img_url` like '%.png' or `img_url` like '%.jpeg' or `img_url` like '%.avif' or `img_url` like '%.jpg') + CONSTRAINT `img_url_format` CHECK (`img_url` like '%.png' or `img_url` like '%.jpeg' or `img_url` like '%.avif' or `img_url` like '%.jpg' or `img_url` like '%.webp') ) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -248,7 +248,7 @@ CREATE TABLE `product` ( LOCK TABLES `product` WRITE; /*!40000 ALTER TABLE `product` DISABLE KEYS */; -INSERT INTO `product` VALUES (1,'Espresso',5,100,'espresso.jpg','Espresso in a white cup. Source: Coffee Hero','Espresso',2.99,'A strong and concentrated coffee drink.'),(2,'Cappuccino',120,75,'cappuccino.jpg','Close-up of a steaming cup of freshly brewed Espresso with frothy milk on top. Source: Wikipedia','Cappuccino',4.99,'An Italian coffee drink made with espresso, hot milk, and steamed milk foam.'),(3,'Latte',150,60,'latte.jpeg','A latte with a spoon. Source: Food Network.','Latte',3.99,'A coffee drink made with espresso and steamed milk.'),(4,'Americano',5,80,'americano.jpeg','Close-up of a clear glass mug filled with hot, black Americano coffee, topped with a thin layer of creme. Source: Bean Box.','Americano',3.49,'A coffee drink prepared by diluting espresso with hot water.'),(5,'Mocha',200,70,'mocha.png','Rich and indulgent mocha served in a ceramic mug, topped with whipped cream and a dusting of cocoa powder. Source: Olive Magazine','Mocha',4.49,'A chocolate-flavored variant of a latte, often with whipped cream on top.'); +INSERT INTO `product` VALUES (1,'Espresso',5,100,'espresso.webp','Espresso in a white cup. Source: Dolce Gusto','Espresso',2.99,'A strong and concentrated coffee drink.'),(2,'Cappuccino',120,75,'cappuccino.webp','Close-up of a steaming cup of freshly brewed Espresso with frothy milk on top. Source: Discount Coffee','Cappuccino',4.99,'An Italian coffee drink made with espresso, hot milk, and steamed milk foam.'),(3,'Latte',150,60,'latte.avif','A latte with a spoon. Source: Peet\'s Coffee.','Latte',3.99,'A coffee drink made with espresso and steamed milk.'),(4,'Americano',5,80,'americano.webp','Close-up of a clear glass mug filled with hot, black Americano coffee, topped with a thin layer of creme. Source: Peet\'s Coffee.','Americano',3.49,'A coffee drink prepared by diluting espresso with hot water.'),(5,'Mocha',200,70,'mocha.png','Rich and indulgent mocha served in a ceramic mug, topped with whipped cream and a dusting of cocoa powder. Source: Starbucks','Mocha',4.49,'A chocolate-flavored variant of a latte, often with whipped cream on top.'); /*!40000 ALTER TABLE `product` ENABLE KEYS */; UNLOCK TABLES; @@ -330,4 +330,4 @@ UNLOCK TABLES; /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2024-03-31 11:11:46 +-- Dump completed on 2024-04-18 13:18:52 diff --git a/src/controllers/Cart.php b/src/controllers/Cart.php new file mode 100644 index 0000000..fafd9ae --- /dev/null +++ b/src/controllers/Cart.php @@ -0,0 +1,87 @@ + []]; + + private function displayCart(): void + { + // loop through each cart item + foreach ($_SESSION['cart'] as $item) { + // fetch corresponding product based on product ID + $product_id = filter_var($item['productID'], FILTER_VALIDATE_INT); + $cart_item['product'] = Product::getByID($product_id); + + // ignore invalid cart items with invalid product IDs + if (empty($cart_item['product'])) { + continue; + } + + $cart_item['quantity'] = filter_var($item['quantity'], FILTER_VALIDATE_INT); + $cart_item['milkType'] = strtolower($item['milkType']); + $cart_item['cupSize'] = strtolower($item['cupSize']); + $cart_item['subtotal'] = $cart_item['quantity'] * $cart_item['product']->getPrice(); + + $this->view_data['cart_items'][] = $cart_item; + } + + $this->view( + 'cart', + $this->view_data, + template_title: "Review order", + template_meta_description: "Your ultimate shopping cart at Steamy Sips. + Review your chosen items, adjust quantities, and proceed to checkout seamlessly. + Savor the convenience of online shopping with us." + ); + } + + public function index(): void + { + // check if the latest cart data is available + if (isset($_SESSION['cart'])) { + $this->displayCart(); + + // unset variable for next request to ensure that the latest cart is always fetched from client + unset($_SESSION['cart']); + + return; + } + + // check if client has sent his latest cart data + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + // client sent his cart data from localstorage + // load cart to session + + // Parse json data and save it to session + // Reference: https://stackoverflow.com/a/39508364/17627866 + $_SESSION['cart'] = json_decode(file_get_contents('php://input'), true); + + return; + } + + // send script to browser to fetch cart from localstorage + $script_src = ROOT . "/js/cart-uploader.js"; + + $cart_script_tag = <<< EOL + + EOL; + + $this->view( + 'loading', + template_title: "Review order", + template_tags: $cart_script_tag, + template_meta_description: "Experience anticipation as your journey begins at Steamy Sips. + Our loading page sets the stage for your flavorful adventure. Sit back, relax, + and prepare for a tantalizing experience ahead." + ); + } +} diff --git a/src/controllers/Home.php b/src/controllers/Home.php index 7f590e6..a31237f 100644 --- a/src/controllers/Home.php +++ b/src/controllers/Home.php @@ -12,7 +12,12 @@ class Home public function index(): void { - $carousel_script = ""; - $this->view('Home', template_title: 'Home', template_tags: $carousel_script); + $this->view( + 'Home', + template_title: 'Home', + template_tags: $this->getLibrariesTags(['aos', 'splide']), + template_meta_description: "Welcome to Steamy Sips Café, where every sip is an experience. + Step into our cozy world of aromatic delights, where the perfect brew meets community and conversation." + ); } } diff --git a/src/controllers/Login.php b/src/controllers/Login.php index 34db275..8bd7a8a 100644 --- a/src/controllers/Login.php +++ b/src/controllers/Login.php @@ -12,64 +12,79 @@ class Login { use Controller; - private array $data; + private array $view_data; public function __construct() { - // initialize default values - $this->data['defaultEmail'] = ""; - $this->data['defaultPassword'] = ""; + // initialize default form value + $this->view_data['defaultEmail'] = ""; } - private function validateUser(): bool + /** + * Checks if user record exists in database + * @param string $email + * @param string $password + * @return bool True if it exists, false otherwise + */ + private function validateUser(string $email, string $password): bool { - // default error - $this->data['errors']['other'] = 'You have entered a wrong email or password'; - // fetch client record - $client = Client::getByEmail($this->data['defaultEmail']); + $client = Client::getByEmail($email); if (!$client) { return false; } // validate password - if (!$client->verifyPassword($this->data['defaultPassword'])) { + if (!$client->verifyPassword($password)) { return false; } // no errors - unset($this->data['errors']['other']); return true; } - public function index(): void + private function handleFormSubmission(): void { - if (isset($_POST['login_submit'])) { - // TODO: sanitize values + $entered_email = trim($_POST['email'] ?? ""); + + // leave password unchanged as leading/trailing spaces can be part of password + // Ref: https://stackoverflow.com/a/7240898/17627866 + $entered_password = $_POST['password'] ?? ""; + + + // check if credentials are correct + if ($this->validateUser($entered_email, $entered_password)) { + // store user email in session + $_SESSION['user'] = $entered_email; - // update default form values - $this->data['defaultEmail'] = trim($_POST['email'] ?? ""); - $this->data['defaultPassword'] = trim($_POST['password'] ?? ""); + // regenerate session id for security purposes + // Ref: https://stackoverflow.com/a/34206189/17627866 + session_regenerate_id(); - // check if credentials are correct - if ($this->validateUser()) { - // store user email in session - $_SESSION['user'] = $this->data['defaultEmail']; + // redirect user to his profile + Utility::redirect('profile'); + } else { + // user entered invalid credentials - // regenerate session id for security purposes - // Reference: https://stackoverflow.com/a/34206189/17627866 - session_regenerate_id(); + // update default form value + $this->view_data['defaultEmail'] = $entered_email; + } + } - // redirect user to his profile - Utility::redirect('profile'); - } + public function index(): void + { + if (isset($_POST['login_submit'])) { + $this->handleFormSubmission(); } $this->view( 'Login', - $this->data, - 'Login' + $this->view_data, + 'Login', + template_tags: $this->getLibrariesTags(['aos']), + template_meta_description: "Sign in to Steamy Sips and unlock a world of aromatic delights. + Access your account, manage orders, and enjoy a seamless shopping experience tailored just for you." ); } } diff --git a/src/controllers/Product.php b/src/controllers/Product.php index 4d41b4f..aa1352a 100644 --- a/src/controllers/Product.php +++ b/src/controllers/Product.php @@ -26,8 +26,9 @@ public function __construct() // initialize some view data $this->view_data["default_review"] = ""; $this->view_data["default_rating"] = ""; - $this->view_data["signed_in_user"] = null; + $this->view_data["signed_in_user"] = false; $this->view_data["product"] = null; + $this->view_data["rating_distribution"] = "[]"; // get product id from URL $product_id = filter_var(Utility::splitURL()[2], FILTER_VALIDATE_INT); @@ -39,7 +40,7 @@ public function __construct() $user_account = Client::getByEmail($reviewer_email); if (!empty($user_account)) { $this->signed_user = $user_account; - $this->view_data["signed_in_user"] = $user_account; + $this->view_data["signed_in_user"] = true; } // if product id valid fetch product from db @@ -55,14 +56,14 @@ public function __construct() private function handleReviewSubmission(): void { - $new_comment = trim($_POST['review_text'] ?? ""); - $rating = filter_var($_POST['review_rating'], FILTER_VALIDATE_INT); - // ignore requests from users who are not logged in if (empty($this->signed_user)) { return; } + $new_comment = trim($_POST['review_text'] ?? ""); + $rating = filter_var($_POST['review_rating'] ?? -1, FILTER_VALIDATE_INT); + $review = new Review( $this->signed_user->getUserID(), $this->product->getProductID(), @@ -90,6 +91,25 @@ private function handleReviewSubmission(): void } } + /** + * Converts the output of getRatingDistribution into a comma separated list + * of numbers + * @return string + */ + private function formatRatingDistribution(): string + { + $percents = $this->product->getRatingDistribution(); + $str = ""; + + for ($x = 5; $x > 0; $x--) { + $str .= $percents[$x] ?? 0; + if ($x != 1) { + $str .= ','; + } + } + return "[" . $str . "]"; + } + public function index(): void { // if product was not found, display error page @@ -106,10 +126,14 @@ public function index(): void $this->handleReviewSubmission(); } + $this->view_data['rating_distribution'] = $this->formatRatingDistribution(); + $this->view( 'Product', $this->view_data, - $this->product->getName() . ' | Steamy Sips' + $this->product->getName() . ' | Steamy Sips', + template_tags: $this->getLibrariesTags(['chartjs']), + template_meta_description: $this->product->getName() . " - " . $this->product->getDescription() ); } } \ No newline at end of file diff --git a/src/controllers/Profile.php b/src/controllers/Profile.php index 692c22e..ae32664 100644 --- a/src/controllers/Profile.php +++ b/src/controllers/Profile.php @@ -99,7 +99,8 @@ public function index(): void $this->view( 'Profile', $this->data, - 'Profile' + 'Profile', + template_meta_description: "Welcome to your personalized corner at Steamy Sips. Manage your orders, update your preferences, and track your coffee journey effortlessly. Your satisfaction is our priority." ); } } diff --git a/src/controllers/Register.php b/src/controllers/Register.php index 407cabc..084baf5 100644 --- a/src/controllers/Register.php +++ b/src/controllers/Register.php @@ -28,38 +28,69 @@ public function __construct() $this->view_data['defaultPassword'] = ""; $this->view_data['defaultConfirmPassword'] = ""; $this->view_data['errors'] = []; + + // get list of districts to be displayed on form $this->view_data['districts'] = District::getAll(); } + /** + * Returns the un-sanitized version of the form data. Form data attributes are guaranteed to have the right + * data types. + * @return array An array indexed by attribute name. It contains all the required attributes. + */ + private function getFormData(): array + { + $form_data = []; + // $form_data will store all attributes and missing attributes are set to empty string + + $form_data['first_name'] = trim($_POST['first_name'] ?? ""); + $form_data['last_name'] = trim($_POST['last_name'] ?? ""); + $form_data['phone_no'] = trim($_POST['phone_no'] ?? ""); + + // get district id as an integer. If districtID is missing, set it to -1 + $form_data['district'] = (int)filter_var( + trim($_POST['district'] ?? "-1"), + FILTER_SANITIZE_NUMBER_INT + ); + + $form_data['street'] = trim($_POST['street'] ?? ""); + $form_data['city'] = trim($_POST['city'] ?? ""); + $form_data['email'] = filter_var(trim($_POST['email'] ?? ""), FILTER_VALIDATE_EMAIL); + + // do not make any modifications to the submitted passwords because they may contain special + // chars and spaces + $form_data['password'] = $_POST['password'] ?? ""; + $form_data['confirm_password'] = $_POST['confirmPassword'] ?? ""; + + return $form_data; + } + private function handleFormSubmission(): void { - // set view data so that submitted values are displayed back to form - - // TODO: add more sanitization - $this->view_data['defaultFirstName'] = trim($_POST['first_name'] ?? ""); - $this->view_data['defaultLastName'] = trim($_POST['last_name'] ?? ""); - $this->view_data['defaultPhoneNumber'] = trim($_POST['phone_no'] ?? ""); - $this->view_data['defaultDistrictID'] = (int)filter_var(trim($_POST['district']), FILTER_SANITIZE_NUMBER_INT); - $this->view_data['defaultStreet'] = trim($_POST['street'] ?? ""); - $this->view_data['defaultCity'] = trim($_POST['city'] ?? ""); - $this->view_data['defaultEmail'] = filter_var(trim($_POST['email']), FILTER_VALIDATE_EMAIL); - $this->view_data['defaultPassword'] = trim($_POST['password'] ?? ""); - $this->view_data['defaultConfirmPassword'] = trim($_POST['confirmPassword'] ?? ""); - - // TODO: If district ID is invalid, handle + $form_data = $this->getFormData(); + + // get the district object corresponding to the submitted district id + $submitted_district = District::getByID($form_data['district']); + + // if district does not exist, create a temporary invalid district object to pass to Client constructor + // Note: A District object (invalid or not) must be passed to the Client constructor + if (empty($submitted_district)) { + $submitted_district = new District(-1, ""); + } + // create a new client object $client = new Client( - email: $this->view_data['defaultEmail'], - first_name: $this->view_data['defaultFirstName'], - last_name: $this->view_data['defaultLastName'], - plain_password: $this->view_data['defaultPassword'], - phone_no: $this->view_data['defaultPhoneNumber'], - district: District::getByID($this->view_data['defaultDistrictID']), - street: $this->view_data['defaultStreet'], - city: $this->view_data['defaultCity'] + email: $form_data['email'], + first_name: $form_data['first_name'], + last_name: $form_data['last_name'], + plain_password: $form_data['password'], + phone_no: $form_data['phone_no'], + district: $submitted_district, + street: $form_data['street'], + city: $form_data['city'] ); - // validate all attributes, except password + // validate all attributes (except password) and store errors $this->view_data['errors'] = $client->validate(); // check if email already exists in database @@ -68,23 +99,49 @@ private function handleFormSubmission(): void } // validate plain text password - $password_errors = Client::validatePlainPassword($this->view_data['defaultPassword']); + $password_errors = Client::validatePlainPassword($form_data['password']); if (!empty($password_errors)) { $this->view_data['errors']['password'] = $password_errors [0]; } // check if passwords do not match - if ($this->view_data['defaultConfirmPassword'] !== $this->view_data['defaultPassword']) { + if ($form_data['confirm_password'] !== $form_data['password']) { $this->view_data['errors']['confirmPassword'] = 'Passwords do not match'; } - // if all data valid, save new record and redirect to login page + // if all data valid, save record and redirect to login page if (empty($this->view_data['errors'])) { - $client->save(); - Utility::redirect('login'); + $success = $client->save(); + + if ($success) { + Utility::redirect('login'); + } + + // TODO: redirect to some error page + Utility::redirect('home'); + } else { + $this->loadDataToForm($form_data); } } + /** + * Updates view data with data from form. Invalid data entered by user persists. + * @param array $form_data + * @return void + */ + private function loadDataToForm(array $form_data): void + { + $this->view_data['defaultFirstName'] = $form_data['first_name']; + $this->view_data['defaultLastName'] = $form_data['last_name']; + $this->view_data['defaultPhoneNumber'] = $form_data['phone_no']; + $this->view_data['defaultStreet'] = $form_data['street']; + $this->view_data['defaultCity'] = $form_data['city']; + $this->view_data['defaultEmail'] = $form_data['email']; + $this->view_data['defaultDistrictID'] = $form_data['district']; + $this->view_data['defaultPassword'] = $form_data['password']; + $this->view_data['defaultConfirmPassword'] = $form_data['confirm_password']; + } + public function index(): void { if (isset($_POST['register_submit'])) { @@ -94,7 +151,10 @@ public function index(): void $this->view( 'Register', $this->view_data, - 'Register' + 'Register', + template_meta_description: "Join the Steamy Sips community today. Register for exclusive offers, + personalized recommendations, and a richer coffee experience. Start your journey towards + flavorful indulgence." ); } } diff --git a/src/controllers/Shop.php b/src/controllers/Shop.php index 74a26fa..9e36697 100644 --- a/src/controllers/Shop.php +++ b/src/controllers/Shop.php @@ -9,7 +9,7 @@ use Steamy\Model\Product; /** - * Displays all products when URL is /shop or /shop/products + * Displays all products when URL is /shop */ class Shop { @@ -50,7 +50,10 @@ private function match_keyword(Product $product): bool // else accept only products within a levenshtein distance of 3 $search_keyword = strtolower(trim($_GET['keyword'])); $similarity_threshold = 3; - return Utility::levenshteinDistance($search_keyword, strtolower($product->getName())) <= $similarity_threshold; + return Utility::levenshteinDistance( + $search_keyword, + strtolower($product->getName()) + ) <= $similarity_threshold; } private function sort_product(Product $a, Product $b): int @@ -81,14 +84,14 @@ private function sort_product(Product $a, Product $b): int public function index(): void { // check if URL follows format /shop/products/ - if (preg_match("/^shop\/products\/[0-9]+/", $_GET['url'])) { + if (preg_match("/^shop\/products\/[0-9]+$/", $_GET['url'])) { // let Product controller handle this (new \Steamy\Controller\Product())->index(); return; } - // check if URL is not /shop or /shop/products - if (!($_GET['url'] == "shop" || $_GET['url'] == "shop/products")) { + // check if URL is not /shop + if ($_GET['url'] !== "shop") { // let 404 controller handle this (new _404())->index(); return; @@ -114,7 +117,10 @@ public function index(): void $this->view( 'Shop', $this->data, - 'Shop' + 'Shop', + template_tags: $this->getLibrariesTags(['aos']), + template_meta_description: "Explore a delightful selection of aromatic coffees, teas, and delectable + treats at Steamy Sips. Discover your perfect brew and elevate your coffee experience today." ); } } diff --git a/src/controllers/_404.php b/src/controllers/_404.php index 654a1d1..3a6656e 100644 --- a/src/controllers/_404.php +++ b/src/controllers/_404.php @@ -12,6 +12,12 @@ class _404 public function index(): void { - $this->view('404', template_title: "Page not found"); + $this->view( + '404', + template_title: "Page not found", + template_meta_description: "Oops! It seems you've wandered off the beaten path. + Let us guide you back to the aromatic world of Steamy Sips. + Return to our delightful offerings or explore anew. Flavorful surprises await your next click." + ); } } diff --git a/src/core/Controller.php b/src/core/Controller.php index 62abaea..2ad7c73 100644 --- a/src/core/Controller.php +++ b/src/core/Controller.php @@ -6,23 +6,67 @@ trait Controller { + + /** + * Returns the required HTML code to load JS libraries. + * @param string[] $required_libraries An array of strings representing the names of the libraries that must be + * @return string HTML tags to load the library. + */ + private function getLibrariesTags(array $required_libraries): string + { + $library_tags = []; + + $library_tags['aos'] = <<< EOL + + + + EOL; + + $library_tags['splide'] = <<< EOL + + + + EOL; + + $library_tags['chartjs'] = <<< EOL + + + EOL; + + // concatenate all tags for the required libraries + $script_str = ""; + foreach (array_keys($library_tags) as $library) { + if (in_array($library, $required_libraries, true)) { + $script_str .= $library_tags[$library]; + } + } + return $script_str; + } + /** * Renders a view and links its respective CSS file if any. * * @param string $view_name File name of view file in `views` folder WITHOUT the `.php` extension. - * @param array $view_data Additional data defined in the view. + * @param array $view_data Values for the placeholder data defined in the view. * @param string $template_title Page title. Default value is `Steamy Sips`. * @param string $template_tags Additional tags to be included in head. Examples can be - * script tags and links to other stylesheets. - * - * ! Any links used inside $template_tags should be absolute (include ROOT). + * script tags and links to other stylesheets. Any links used inside $template_tags should be absolute (include ROOT). + * @param string $template_meta_description Meta description of page * @return void */ - public function view( + public + function view( string $view_name, array $view_data = [], string $template_title = 'Steamy Sips', - string $template_tags = '' + string $template_tags = '', + string $template_meta_description = '', ): void { // import data to be placed in view file if (!empty($view_data)) { diff --git a/src/core/Database.php b/src/core/Database.php index 6104741..410fe56 100644 --- a/src/core/Database.php +++ b/src/core/Database.php @@ -5,6 +5,7 @@ namespace Steamy\Core; use PDO; +use PDOException; use stdClass; trait Database @@ -17,7 +18,19 @@ trait Database private static function connect(): PDO { $string = "mysql:hostname=" . DB_HOST . ";dbname=" . DB_NAME; - return new PDO($string, DB_USERNAME, DB_PASSWORD); + try { + return new PDO($string, DB_USERNAME, DB_PASSWORD); + } catch (PDOException $e) { + // if PHPUnit is not running, handle the exception + if (!defined('PHPUNIT_STEAMY_TESTSUITE')) { + // TODO: Display a user-friendly error message + Utility::show("Sorry, we're unable to process your request at the moment. Please try again later."); + die(); + } else { + // if PHPUnit is running, re-throw the exception to allow it to propagate + throw $e; + } + } } /** diff --git a/src/models/Client.php b/src/models/Client.php index b524f37..31fde7d 100644 --- a/src/models/Client.php +++ b/src/models/Client.php @@ -69,8 +69,8 @@ private static function getClientByCondition(?string $email, ?int $user_id): ?Cl ); // Set the user ID and password hash - $client->setUserID($result->user_id); - $client->setPassword($result->password); + $client->user_id = $result->user_id; + $client->password = $result->password; return $client; } @@ -83,6 +83,11 @@ private static function getClientByCondition(?string $email, ?int $user_id): ?Cl */ public static function getByEmail(string $email): ?Client { + if (strlen($email) < 3) { + // email must have at least 3 characters + // Ref: https://stackoverflow.com/a/1423203/17627866 + return null; + } return self::getClientByCondition($email, null); } @@ -94,6 +99,10 @@ public static function getByEmail(string $email): ?Client */ public static function getByID(int $user_id): ?Client { + if ($user_id < 0) { + // user id cannot be negative + return null; + } return self::getClientByCondition(null, $user_id); } @@ -112,15 +121,20 @@ public function deleteUser(): void } /** - * Saves user to database if user attributes are valid + * Saves client to database * - * @return void + * @return bool Whether client was successfully saved to database */ - public function save(): void + public function save(): bool { - // if attributes of object are invalid, exit + // if attributes are invalid, exit if (count($this->validate()) > 0) { - return; + return false; + } + + // check if email already exists in database + if (!empty(Client::getByEmail($this->email))) { + return false; } // get data to be inserted to user table @@ -133,7 +147,7 @@ public function save(): void $inserted_record = self::first($user_data, 'user'); if (!$inserted_record) { - return; + return false; } // get data to be inserted to client table @@ -146,6 +160,8 @@ public function save(): void // perform insertion to client table $this->insert($client_data, $this->table); + + return true; // insertion was successful } /** @@ -166,9 +182,10 @@ public function validate(): array { $errors = parent::validate(); // list of errors - // perform existence checks - if (empty($this->district->getName())) { - $errors['district'] = 'District name is required'; + // verify existence of district + $valid_district = District::getByID($this->district->getID()); // fetch corresponding district in database + if (empty($valid_district) || $valid_district->getName() !== $this->district->getName()) { + $errors['district'] = 'District does not exist'; } if (strlen($this->city) < 3) { diff --git a/src/models/District.php b/src/models/District.php index 7d919b8..7afd4ff 100644 --- a/src/models/District.php +++ b/src/models/District.php @@ -20,9 +20,13 @@ public function __construct(int $id, string $name) $this->name = $name; } - public static function getByID(int $id): ?District + public static function getByID(int $district_id): ?District { - $record = self::query("SELECT * FROM district WHERE district_id = :id", ['id' => $id]); + if ($district_id < 0) { + return null; + } + + $record = self::query("SELECT * FROM district WHERE district_id = :id", ['id' => $district_id]); if (!$record) { return null; } @@ -39,13 +43,23 @@ public function getID(): int return $this->district_id; } + /** + * Returns all districts from the database as an object + * @return District[] Array of District objects + */ public static function getAll(): array { - $districts = self::query("SELECT * FROM district"); - $districtNames = []; - foreach ($districts as $district) { - $districtNames[] = $district->name; + $results = self::query("SELECT * FROM district;"); + + if (empty($results)) { + return []; } - return $districtNames; + + $districts = []; + foreach ($results as $district) { + $districts[] = new District($district->district_id, $district->name); + } + + return $districts; } } diff --git a/src/models/Order.php b/src/models/Order.php index 4873a83..8c73cea 100644 --- a/src/models/Order.php +++ b/src/models/Order.php @@ -24,27 +24,24 @@ class Order private array $products = []; // Each element of this array contains the following columns: product, milk_type, quantity, cup_size. - /** - * @throws Exception - */ public function __construct(Client $client, array $products) { - // Set default values - $this->order_id = -1; - $this->status = "pending"; - $this->created_date = new DateTime(); - $this->pickup_date = null; - $this->total_price = 0; - $this->street = ""; - $this->city = ""; - - // Set client attribute - $this->client = $client; - - // Set products attribute - $this->setProducts($products); - } - + // Set default values + $this->order_id = -1; + $this->status = "pending"; + $this->created_date = new DateTime(); + $this->pickup_date = null; + $this->total_price = 0; + $this->street = ""; + $this->city = ""; + + // Set client attribute + $this->client = $client; + + // Set products attribute + $this->setProducts($products); + } + public function setProducts(array $products): void { $this->products = $products; // Updated attribute name @@ -56,7 +53,7 @@ public function toArray(): array 'order_id' => $this->order_id, 'status' => $this->status, 'created_date' => $this->created_date->format('Y-m-d H:i:s'), - 'pickup_date' => $this->pickup_date ? $this->pickup_date->format('Y-m-d H:i:s') : null, + 'pickup_date' => $this->pickup_date?->format('Y-m-d H:i:s'), 'street' => $this->street, 'city' => $this->city, 'district' => $this->district->getID(), // Return the district ID @@ -64,7 +61,7 @@ public function toArray(): array 'client_id' => $this->client->getUserID() // Return the client ID ]; } - + public function save(): void { @@ -87,52 +84,67 @@ public function getProducts(): array { return $this->products; } - + public static function getByID(int $order_id): ?Order { + if ($order_id < 0) { + return null; + } + // Perform query to fetch order from the database $query = "SELECT * FROM `order` WHERE order_id = :order_id"; $orderData = self::query($query, ['order_id' => $order_id]); - + // Check if order with the specified ID exists if (empty($orderData)) { return null; } - + // Extract order details from the query result $orderData = $orderData[0]; - + // Fetch client associated with the order $client = Client::getByID($orderData->client_id); - + if (!$client) { return null; } - + // Fetch products associated with the order $products = self::getOrderProducts($order_id); - + // Create Order object with retrieved data $order = new Order($client, $products); $order->order_id = $orderData->order_id; $order->status = $orderData->status; - $order->created_date = new DateTime($orderData->created_date); - $order->pickup_date = $orderData->pickup_date ? new DateTime($orderData->pickup_date) : null; + + try { + $order->created_date = new DateTime($orderData->created_date); + } catch (Exception $e) { + error_log('Error converting date: ' . $e->getMessage()); + } + + try { + $order->pickup_date = $orderData->pickup_date ? new DateTime($orderData->pickup_date) : null; + } catch (Exception $e) { + error_log('Error converting date: ' . $e->getMessage()); + } + $order->street = $orderData->street; $order->city = $orderData->city; $order->total_price = $orderData->total_price; - + return $order; } - + private static function getOrderProducts(int $order_id): array { - $query = "SELECT product, milk_type, quantity, cup_size FROM order_product WHERE order_id = :order_id"; + $query = "SELECT product_id, milk_type, quantity, cup_size FROM order_product WHERE order_id = :order_id"; $productsData = self::query($query, ['order_id' => $order_id]); - + // Initialize an empty array to store products $products = []; - + // Iterate through each product data and create Product objects foreach ($productsData as $productData) { // Create a product array with necessary information @@ -142,14 +154,14 @@ private static function getOrderProducts(int $order_id): array 'quantity' => $productData->quantity, 'cup_size' => $productData->cup_size ]; - + // Add the product array to the products array $products[] = $product; } - + return $products; } - + public function getOrderID(): int { @@ -261,57 +273,57 @@ public function validate(): array */ public function loadProducts(): array { - // Initialize an empty array to store Product objects - $products = []; + // Initialize an empty array to store Product objects + $products = []; - // Query the database for products related to this order - $query = <<query($query, ['order_id' => $this->order_id]); - - // Iterate through the retrieved product records and create Product objects - foreach ($productRecords as $record) { - // Create a new Product object and add it to the products array - $product = new Product( - $record->name, - $record->calories, - $record->stock_level, - $record->img_url, - $record->img_alt_text, - $record->category, - (float)$record->price, - $record->description - ); - $product->setProductID($record->product_id); // Set the product ID - $products[] = $product; - } + // Execute the query and fetch the product records + $productRecords = $this->query($query, ['order_id' => $this->order_id]); + + // Iterate through the retrieved product records and create Product objects + foreach ($productRecords as $record) { + // Create a new Product object and add it to the products array + $product = new Product( + $record->name, + $record->calories, + $record->stock_level, + $record->img_url, + $record->img_alt_text, + $record->category, + (float)$record->price, + $record->description + ); + $product->setProductID($record->product_id); // Set the product ID + $products[] = $product; + } - return $products; + return $products; } - /** - * Adds a product to the order. - * - * @param Product $product The product to add. - * @param string $milk_type The type of milk. - * @param int $quantity The quantity of the product. - * @param string $cup_size The cup size. - * @return void - */ + /** + * Adds a product to the order. + * + * @param Product $product The product to add. + * @param string $milk_type The type of milk. + * @param int $quantity The quantity of the product. + * @param string $cup_size The cup size. + * @return void + */ public function addProduct(Product $product, string $milk_type, int $quantity, string $cup_size): void { $this->products[] = [ - 'product' => $product, - 'milk_type' => $milk_type, - 'quantity' => $quantity, - 'cup_size' => $cup_size - ]; - } + 'product' => $product, + 'milk_type' => $milk_type, + 'quantity' => $quantity, + 'cup_size' => $cup_size + ]; + } public function removeProduct(int $index): void @@ -337,7 +349,7 @@ public function calculateTotalPrice(): float public function toHTML(): string { $html = << + @@ -348,7 +360,7 @@ public function toHTML(): string HTML; - + // Iterate through each product in the order foreach ($this->products as $product) { // Get the product details @@ -356,7 +368,7 @@ public function toHTML(): string $quantity = $product['quantity']; $pricePerUnit = $product['product']->getPrice(); $totalPrice = $quantity * $pricePerUnit; - + // Add a row for the product in the HTML table $html .= << @@ -367,34 +379,32 @@ public function toHTML(): string HTML; } - + // Close the HTML table $html .= <<
Product
HTML; - + return $html; } - - - private function getQuantityForProduct(Product $product): int + + + public function getQuantityForProduct(Product $product): int { // Query the order_product table to get the quantity for the specified product $query = "SELECT quantity FROM order_product WHERE order_id = :order_id AND product_id = :product_id"; $params = ['order_id' => $this->getOrderID(), 'product_id' => $product->getProductID()]; $result = $this->query($query, $params); - + // If there are no results, return 0 if (empty($result)) { return 0; } - + // Otherwise, return the quantity return $result[0]->quantity; } - - } diff --git a/src/models/Product.php b/src/models/Product.php index 330b4ef..fb16124 100644 --- a/src/models/Product.php +++ b/src/models/Product.php @@ -46,6 +46,10 @@ public function __construct( public static function getByID(int $product_id): ?Product { + if ($product_id < 0) { + return null; + } + $query = "SELECT * FROM product where product_id = :product_id"; $record = self::get_row($query, ['product_id' => $product_id]); @@ -436,13 +440,17 @@ public function getRatingDistribution(): array return []; // Return empty array on error } + if (empty($result)) { + return []; + } + // Initialize the distribution array $distribution = []; // Populate the distribution array with rating and percentage foreach ($result as $row) { $rating = $row->rating; - $percentage = round($row->percentage, 1); // Round to 1 decimal place + $percentage = round((float)$row->percentage, 1); // Round to 1 decimal place $distribution[$rating] = $percentage; } diff --git a/src/models/Review.php b/src/models/Review.php index 5dec6d0..7b3c6f2 100644 --- a/src/models/Review.php +++ b/src/models/Review.php @@ -21,6 +21,9 @@ class Review private int $rating; private Datetime $date; + public const MAX_RATING = 5; + public const MIN_RATING = 1; + public function __construct( int $user_id, int $product_id, @@ -33,7 +36,7 @@ public function __construct( $this->user_id = $user_id; $this->product_id = $product_id; $this->parent_review_id = $parent_review_id; - $this->text = htmlspecialchars_decode(strip_tags($text)); + $this->text = $text; $this->rating = $rating; $this->date = $date; } @@ -90,14 +93,18 @@ public function toArray(): array /** * Retrieves a review by its ID. * - * @param int $id The ID of the review to retrieve. + * @param int $review_id The ID of the review to retrieve. * @return Review|null The review object if found, otherwise null. * @throws Exception If an error occurs during the database query. */ - public static function getByID(int $id): ?Review + public static function getByID(int $review_id): ?Review { + if ($review_id < 0) { + return null; + } + $query = "SELECT * FROM review WHERE review_id = :id"; - $params = ['id' => $id]; + $params = ['id' => $review_id]; try { $result = Review::query($query, $params); // Execute the query @@ -111,7 +118,7 @@ public static function getByID(int $id): ?Review $result[0]->rating, $result[0]->date ); - $review->setReviewID($id); // Set the review ID + $review->setReviewID($review_id); // Set the review ID return $review; } } catch (Exception $e) { @@ -212,15 +219,24 @@ public function save(): void public function validate(): array { $errors = []; + if (strlen($this->text) < 2) { $errors['text'] = "Review text must have at least 2 characters"; } - if ($this->rating < 1 || $this->rating > 5) { - $errors['rating'] = "Rating must be between 1 and 5"; + + if (!filter_var($this->rating, FILTER_VALIDATE_INT, [ + "options" => [ + "min_range" => Review::MIN_RATING, + "max_range" => Review::MAX_RATING + ] + ])) { + $errors['rating'] = sprintf("Review must be between %d and %d", Review::MIN_RATING, Review::MAX_RATING); } + if ($this->date > new DateTime()) { $errors['date'] = "Review date cannot be in the future"; } + return $errors; } diff --git a/src/models/User.php b/src/models/User.php index 5b35b22..0179b67 100644 --- a/src/models/User.php +++ b/src/models/User.php @@ -98,33 +98,40 @@ public static function validatePlainPassword(string $plain_password): array return $errors; } + /** + * Validates all attributes of user through existence checks, length checks, format checks, ... + * + * Note: This function does not check if an email is unique. + * + * @return array Array of errors indexed by attribute name + */ public function validate(): array { $errors = []; // List of errors - + // Perform email format check if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) { $errors['email'] = "Invalid email format"; } - + // Perform first name length check if (strlen($this->first_name) < 3) { $errors['first_name'] = "First name must be at least 3 characters long"; } - + // Perform last name length check if (strlen($this->last_name) < 3) { $errors['last_name'] = "Last name must be at least 3 characters long"; } - + // Perform phone number length check if (strlen($this->phone_no) < 7) { $errors['phone_no'] = "Phone number must be at least 7 characters long"; } - + return $errors; } - + public function setEmail(string $email): void { diff --git a/src/views/Cart.php b/src/views/Cart.php new file mode 100644 index 0000000..eb7a779 --- /dev/null +++ b/src/views/Cart.php @@ -0,0 +1,82 @@ + + +
+

Shopping Cart

+ + + + + Your cart is empty 😥

"; + } + foreach ($cart_items as $item) { + $product = $item['product']; + $product_id = filter_var($product->getProductID(), FILTER_SANITIZE_NUMBER_INT); + $product_name = htmlspecialchars($product->getName()); + $product_link = htmlspecialchars(ROOT . "/shop/products/" . $product_id); + $image_url = htmlspecialchars($product->getImgAbsolutePath()); + $image_alt = htmlspecialchars($product->getImgAltText()); + $unit_price = filter_var( + $product->getPrice(), + FILTER_SANITIZE_NUMBER_FLOAT, + FILTER_FLAG_ALLOW_FRACTION + ); + + $cupSize = htmlspecialchars($item['cupSize']); + $milkType = htmlspecialchars($item['milkType']); + $quantity = filter_var($item['quantity'], FILTER_SANITIZE_NUMBER_INT); + $subtotal = filter_var( + $item['subtotal'], + FILTER_SANITIZE_NUMBER_FLOAT, + FILTER_FLAG_ALLOW_FRACTION + ); + // Note: cupSize and milkType must be in lowercase because this information + // is stored in lowercase in localstorage. + + + // capitalize first letter of cup size and milk type when displayed on page + $uc_cupsize = ucfirst($cupSize); + $uc_milktype = ucfirst($milkType); + + echo <<< EOL +
+ $image_alt +
+
+

$product_name

+ $uc_cupsize | $uc_milktype | In stock +
+ Rs $subtotal +
+ +
+ EOL; + } + ?> + + + +
+ + \ No newline at end of file diff --git a/src/views/Loading.php b/src/views/Loading.php new file mode 100644 index 0000000..0bd5976 --- /dev/null +++ b/src/views/Loading.php @@ -0,0 +1,48 @@ + + + +
+
+ + + + + + + + + +
+
\ No newline at end of file diff --git a/src/views/Login.php b/src/views/Login.php index 84f9aa3..3ebd235 100644 --- a/src/views/Login.php +++ b/src/views/Login.php @@ -2,11 +2,10 @@ declare(strict_types=1); /** - * Variables below are defined in Login controller - * @var string $defaultEmail - * @var string $defaultPassword + * View variables defined in Login controller: + * + * @var string $defaultEmail Email set in form */ - ?>
@@ -15,24 +14,14 @@

Sign in

+ aria-invalid="" + value="" required/> + aria-invalid="" + required/> -
- -
- - - Don't have an account yet? Register
diff --git a/src/views/Product.php b/src/views/Product.php index 2a9efd2..77e46fc 100644 --- a/src/views/Product.php +++ b/src/views/Product.php @@ -4,110 +4,26 @@ /** * @var $product Product product information - * @var $signed_in_user User + * @var $signed_in_user bool is current user signed in? * @var $default_review string default review text in form * @var $default_rating int default rating in form + * @var $rating_distribution string An array containing the percentages of ratings */ use Steamy\Model\Client; use Steamy\Model\Product; use Steamy\Model\Review; -use Steamy\Model\User; -?> - -
-
- <?= $product->getImgAltText() ?> -
-
-

getName() ?>

-

Rs getPrice() ?>

-

360 calories

-
-

- getDescription() ?> -

-

Size options

-
- - - -
-

Customizations

- - - - - -
-
-

Customer Reviews (getReviews()) ?>)

-
- - - -
- - - -
-
    - getProductID(), $review->getReviewID())) { - return <<< BADGE +/** + * Returns the HTML code for a badge indicating the status of a review (verified or not) + * @param Review $review Review + * @return string HTML code of badge + */ +function getBadge(Review $review): string +{ + if (Review::isVerified($review->getProductID(), $review->getReviewID())) { + return <<< BADGE
    BADGE; - } - return <<< BADGE + } + return <<< BADGE
    - +
    BADGE; - } +} - /** - * Returns the HTML code for the rating in terms of stars - * @param Review $review - * @return string - */ - function getStars(Review $review): string - { - $checked_stars = $review->getRating(); - $unchecked_stars = 5 - $checked_stars; - $html = ""; - - while ($checked_stars > 0) { - $html .= <<< EOL +/** + * Returns the HTML code for stars showing the rating of a review + * @param Review $review + * @return string HTML for stars + */ +function getStars(Review $review): string +{ + $checked_stars = filter_var($review->getRating(), FILTER_VALIDATE_INT); // number of shaded stars + $unchecked_stars = Review::MAX_RATING - $checked_stars; // number of empty stars + $html = ""; + + while ($checked_stars > 0) { + $html .= <<< EOL - + stroke-linejoin="round" class="fill-star icon icon-tabler icons-tabler + -outline icon-tabler-star"> + EOL; - $checked_stars--; - } + $checked_stars--; + } - while ($unchecked_stars > 0) { - $html .= <<< EOL + while ($unchecked_stars > 0) { + $html .= <<< EOL - + EOL; - $unchecked_stars--; - } - return $html; - } + $unchecked_stars--; + } + return $html; +} + +/** + * Outputs sanitized HTML code to display a review and its children. + * @param Review $review + * @return void + */ +function recurse(Review $review): void +{ + $reply_link = ROOT . "/reply/" . "id=?"; + $date = htmlspecialchars($review->getDate()->format('d M Y')); + $text = htmlspecialchars($review->getText()); + $author = htmlspecialchars(Client::getByID($review->getUserID())->getFullName()); + $verified_badge = getBadge($review); + $rating_stars = getStars($review); - /** - * Returns the HTML code to display a review and its children. - * @param Review $review - * @return void - */ - function recurse(Review $review): void - { - $reply_link = ROOT . "/reply/" . "id=?"; - $date = $review->getDate()->format('d M Y'); - $text = $review->getText(); - $author = Client::getByID($review->getUserID())->getFullName(); - $verified_badge = getBadge($review); - $rating_stars = getStars($review); - - - echo <<
    $verified_badge @@ -189,7 +115,8 @@ function recurse(Review $review): void + fill="none"/> @@ -197,18 +124,141 @@ function recurse(Review $review): void
    EOL; - // print child comments if any - if (isset($review->children)) { - foreach ($review->children as $child_comment) { - echo "
      "; - recurse($child_comment); - echo "
    "; - } - } + // print child comments if any + if (isset($review->children)) { + foreach ($review->children as $child_comment) { + echo "
      "; + recurse($child_comment); + echo "
    "; + } + } - echo ""; - } + echo ""; +} + +?> + + + + + +
    +
    + <?= htmlspecialchars($product->getImgAltText()) ?> +
    +
    +

    getName()) ?>

    +

    Rs getPrice(), + FILTER_SANITIZE_NUMBER_FLOAT, + FILTER_FLAG_ALLOW_FRACTION + ) ?>

    +

    360 calories

    +
    +

    + getDescription()) ?> +

    +
    + + +

    Size options

    +
    + + + +
    +

    Customizations

    + + + +
    + +
    +
    +

    Customer Reviews (getReviews()) ?>)

    +
    + + + +
    + +
    + +
    + + + +
    +
      + getReviews(); foreach ($reviews as $review) { @@ -218,4 +268,37 @@ function recurse(Review $review): void
    -
    \ No newline at end of file +
+ + + + \ No newline at end of file diff --git a/src/views/Profile.php b/src/views/Profile.php index f8904fa..0f0ffa4 100644 --- a/src/views/Profile.php +++ b/src/views/Profile.php @@ -14,24 +14,24 @@ ?>
-

Your account

+

My account

Personal details

@@ -49,12 +49,16 @@ date); + $id = filter_var($order->id, FILTER_SANITIZE_NUMBER_INT); + $cost = filter_var($order->cost, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION); + $status = htmlspecialchars($order->status); echo <<< EOL - $order->date - $order->id - $order->cost - $order->status + $date + $id + $cost + $status @@ -72,8 +76,8 @@
Log out
-
Log out from website. You will lose access to your profile and will have to enter your login details - again.
+
Log out from website. You will lose access to your profile and will have to enter your login + details again.
diff --git a/src/views/Register.php b/src/views/Register.php index 8099cbb..b5983ee 100644 --- a/src/views/Register.php +++ b/src/views/Register.php @@ -5,7 +5,8 @@ use Steamy\Model\District; /** - * Variables below are defined and initialized in Register controller. + * View for registration form. + * * @var string $defaultFirstName * @var string $defaultLastName * @var string $defaultPhoneNumber @@ -56,7 +57,9 @@
/> + value="" required /> @@ -67,7 +70,9 @@
/> + value="" required /> @@ -81,14 +86,16 @@ Phone Number (eg., +230-5-123-4567) - />
- /> - /> District + + + @@ -126,7 +143,8 @@
Account information - /> Password /> Confirm password /> + value="" + required /> diff --git a/src/views/Shop.php b/src/views/Shop.php index c4d8950..b52407d 100644 --- a/src/views/Shop.php +++ b/src/views/Shop.php @@ -3,6 +3,8 @@ declare(strict_types=1); /** + * View for shop page + * * @var Product[] $products Array of all products as fetched from database * @var String[] $categories Array of all product category names * @var $search_keyword string keyword used to filter products @@ -10,19 +12,39 @@ use Steamy\Model\Product; +/** + * Outputs sanitized HTML to display a product + * @param $product + * @return void + */ +function displayProduct($product): void +{ + $product_href = htmlspecialchars( + ROOT . '/shop/products/' . $product->getProductID() + ); // link to product page + $product_img_src = htmlspecialchars($product->getImgAbsolutePath()); // url of image + $img_alt_text = htmlspecialchars($product->getImgAltText()); + $description = htmlspecialchars($product->getDescription()); + $name = htmlspecialchars($product->getName()); + echo << + $img_alt_text + +
$name
+
+

$description

+
+ EOL; +} + ?>
- - - - - EOL; @@ -61,21 +84,7 @@
getProductID(); // link to product page - $product_img_src = $product->getImgAbsolutePath(); // url of image - $img_alt_text = $product->getImgAltText(); - $description = $product->getDescription(); - $name = $product->getName(); - - echo << - $img_alt_text - -
$name
-
-

$description

- - EOL; + displayProduct($product); } ?>
diff --git a/src/views/Template.php b/src/views/Template.php index 4046628..1e7939a 100644 --- a/src/views/Template.php +++ b/src/views/Template.php @@ -2,10 +2,11 @@ declare(strict_types=1); /** - * Variables below are defined in controllers/Controller.php. - * @var string $template_tags Additional tags for template - * @var string $template_title Title of web page - * @var string $template_content HTML content of web page + * Template for all web pages. Variables below are defined in src/core/Controller.php. + * @var string $template_title Title of page + * @var string $template_content HTML of the main content of page + * @var string $template_meta_description Meta description of page + * @var string $template_tags Additional tags (script, link, ...) for page */ ?> @@ -17,91 +18,24 @@ - + - - - - - - - + - - - - - - - + <?= $template_title ?> - - - -
- - -

Your shopping cart (3 items)

-
-
-

Cappucino

-
Total = Rs 434
-
- - -
-
-
-

Cappucino

-
Total = Rs 434
-
- - -
-
-
-

Cappucino

-
Total = Rs 434
-
- - -
-
- -
- -
-
-