diff --git a/public/assets/img/empty-cart.png b/public/assets/img/empty-cart.png new file mode 100644 index 0000000..2d2a0ce Binary files /dev/null and b/public/assets/img/empty-cart.png differ diff --git a/public/js_original/cart-uploader.js b/public/js_original/cart-uploader.js deleted file mode 100644 index d45a189..0000000 --- a/public/js_original/cart-uploader.js +++ /dev/null @@ -1,26 +0,0 @@ -import Cart from "./models/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_original/cart-view.js b/public/js_original/cart-view.js index d90aae1..dc5cca2 100644 --- a/public/js_original/cart-view.js +++ b/public/js_original/cart-view.js @@ -4,6 +4,7 @@ import Cart from "./models/Cart"; import CartItem from "./models/CartItem"; +import ModalManager from "./modal"; function updateCart(e) { const sectionNode = e.target.parentNode.parentNode; @@ -23,6 +24,13 @@ function updateCart(e) { const unitPrice = parseFloat(sectionNode.getAttribute("data-unitprice")); const newSubTotal = Math.round(newQuantity * unitPrice * 100) / 100; + // update cart total + let cartTotal = parseFloat(document.querySelector("#cart-total").textContent); + cartTotal = cartTotal + unitPrice * (newQuantity - currentCartItem.quantity); + document.querySelector("#cart-total").textContent = cartTotal + .toFixed(2) + .toString(); + // display new subtotal const priceNode = sectionNode.querySelector(".container > strong"); priceNode.textContent = "Rs " + newSubTotal; @@ -41,12 +49,67 @@ function updateCart(e) { } } -window.addEventListener("DOMContentLoaded", function () { +async function checkout() { + const myCart = Cart(); + const items = myCart.getItems(); + + const data = { + items, + store_id: document.querySelector("#store_location").value, + }; + + const response = await fetch(window.location.href + "/checkout", { + method: "POST", + body: JSON.stringify(data), + }); + + if (response.ok) { + // Clear cart items from localStorage if checkout is successful + myCart.clear(); + ModalManager("my-modal").openModal(); + return; + } + const x = await response.json(); + window.alert(x.error); +} + +function preventKeyboardInput(event) { + event.preventDefault(); +} + +/** + * This function must be called after DOM has loaded. + */ +function initCartPage() { const quantityInputs = [ ...document.querySelectorAll("section input[type='number']"), ]; + ModalManager("my-modal").init(); + + document.querySelector("#checkout-btn").addEventListener("click", checkout); + quantityInputs.forEach((input) => { input.addEventListener("change", updateCart); + input.addEventListener("keydown", preventKeyboardInput); + }); +} + +async function uploadCart() { + const items = Cart().getItems(); + + const response = await fetch(window.location.href + "/upload", { + method: "POST", + body: JSON.stringify(items), }); -}); + + // add loading delay of 1s + await new Promise((r) => setTimeout(r, 1000)); + + if (response.ok) { + document.body.innerHTML = await response.text(); + initCartPage(); + } +} + +window.addEventListener("DOMContentLoaded", uploadCart); diff --git a/public/styles/global.css b/public/styles/global.css index 2ac51ec..c8fa773 100644 --- a/public/styles/global.css +++ b/public/styles/global.css @@ -1,14 +1,8 @@ -.warning { - /*noinspection CssUnresolvedCustomProperty*/ - color: var(--form-element-invalid-border-color); /* defined in picocss */ -} - body > nav { position: sticky; top: 0; z-index: 3; - /*noinspection CssUnresolvedCustomProperty*/ - background-color: var(--background-color); /* defined in picocss */ + background-color: var(--background-color); } body > nav .icon { @@ -22,7 +16,7 @@ body > nav > ul li:nth-child(1) > a { gap: 1rem; } -body > nav [data-tooltip]:not(a,button,input){ +body > nav [data-tooltip]:not(a,button,input) { border-bottom: 0; } @@ -32,38 +26,6 @@ body > nav [data-tooltip]:not(a,button,input){ justify-content: center; } -#cart-modal article { - padding: 40px 30px; -} - -#cart-modal article footer { - margin: 0; -} - -#cart-modal article footer h5 { - display: grid; - place-items: center; - margin: 0; -} - - -#cart-modal #cart-items { - height: 200px; - overflow-y: auto; - overflow-x: hidden; - padding: 5px; -} - -#cart-items article { - min-width: 40vw; - max-width: 100%; - margin: 0; - padding-bottom: 0; - padding-top: 20px; +.warning { + color: var(--form-element-invalid-border-color); } - -#cart-items article button { - /*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 index 8d72fba..0fa90de 100644 --- a/public/styles/views/Cart.css +++ b/public/styles/views/Cart.css @@ -1,16 +1,13 @@ -section { - margin-bottom: 0; -} - .cart-item { - padding-top: 10px; display: flex; gap: 1em; - border-bottom: 1px solid gray; + min-height: 270px; + box-shadow: rgba(99, 99, 99, 0.2) 0px 2px 8px 0px; + padding: 10px 20px; } -section:nth-child(2) { - border-top: 1px solid gray; +.cart-item:hover { + box-shadow: rgba(99, 99, 99, 0.2) 1px 3px 8px 1px; } .cart-item > img { diff --git a/public/styles/views/Shop.css b/public/styles/views/Shop.css index 06cb5e0..5d4c2bb 100644 --- a/public/styles/views/Shop.css +++ b/public/styles/views/Shop.css @@ -10,7 +10,17 @@ display: flex; flex-direction: column; align-content: center; - outline: 1px dotted gray; + border-radius: 10px; + box-shadow: rgba(0, 0, 0, 0.05) 0px 6px 24px 0px, rgba(0, 0, 0, 0.08) 0px 0px 0px 1px; +} + +#item-grid > a:focus{ + background-color: transparent; +} + +/*item name*/ +#item-grid > a > hgroup > h5:nth-child(1) { + margin-bottom: 0; } #item-grid > a img { diff --git a/resources/database/dump/cafe.sql b/resources/database/dump/cafe.sql index 2d7ce0f..114505c 100644 --- a/resources/database/dump/cafe.sql +++ b/resources/database/dump/cafe.sql @@ -182,7 +182,8 @@ CREATE TABLE `order_product` ( CONSTRAINT `order_product_2fk` FOREIGN KEY (`product_id`) REFERENCES `product` (`product_id`) ON UPDATE CASCADE, CONSTRAINT `cup_size` CHECK (`cup_size` in ('small','medium','large')), CONSTRAINT `milk_type` CHECK (`milk_type` in ('almond','coconut','oat','soy')), - CONSTRAINT `quantity_range` CHECK (`quantity` > 0) + CONSTRAINT `quantity_range` CHECK (`quantity` > 0), + CONSTRAINT `unit_price_range` CHECK (`unit_price` > 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -392,4 +393,4 @@ UNLOCK TABLES; /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2024-04-25 14:25:08 +-- Dump completed on 2024-05-15 16:01:14 diff --git a/resources/schema/Comment.json b/resources/schema/Comment.json new file mode 100644 index 0000000..155d450 --- /dev/null +++ b/resources/schema/Comment.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Comment", + "type": "object", + "properties": { + "comment_id": { "type": "integer" }, + "text": { "type": "string", "minLength": 1, "maxLength": 2000 }, + "created_date": { "type": "string", "format": "date-time" }, + "parent_comment_id": { "type": "integer" }, + "user_id": { "type": "integer" }, + "review_id": { "type": "integer" } + }, + "required": ["comment_id", "text", "created_date", "user_id"] +} diff --git a/resources/schema/Product.json b/resources/schema/Product.json new file mode 100644 index 0000000..d1b0bfd --- /dev/null +++ b/resources/schema/Product.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Product", + "description": "Schema for a product object", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the product", + "minLength": 3, + "maxLength": 255 + }, + "calories": { + "type": "integer", + "description": "The number of calories in the product", + "minimum": 0 + }, + "img_url": { + "type": "string", + "format": "uri", + "description": "The URL of the product image" + }, + "img_alt_text": { + "type": "string", + "description": "The alternate text for the product image", + "minLength": 5, + "maxLength": 150 + }, + "category": { + "type": "string", + "description": "The category of the product", + "minLength": 3, + "maxLength": 50 + }, + "price": { + "type": "number", + "description": "The price of the product", + "minimum": 0 + }, + "description": { + "type": "string", + "description": "Description of the product", + "minLength": 1 + }, + "created_date": { + "type": "string", + "format": "date-time", + "description": "The date and time when the product was created" + } + }, + "required": ["name", "calories", "img_url", "img_alt_text", "category", "price", "description", "created_date"], + "additionalProperties": false, + "patternProperties": { + "img_url": { + "pattern": "^.+\\.(png|jpeg|avif|jpg|webp)$", + "description": "The URL should end with one of the supported image formats: png, jpeg, avif, jpg, webp" + } + } +} diff --git a/resources/schema/Review.json b/resources/schema/Review.json new file mode 100644 index 0000000..c5e2863 --- /dev/null +++ b/resources/schema/Review.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Review", + "type": "object", + "properties": { + "review_id": { "type": "integer" }, + "rating": { "type": "integer", "minimum": 1, "maximum": 5 }, + "created_date": { "type": "string", "format": "date-time" }, + "text": { "type": "string", "minLength": 2, "maxLength": 2000 }, + "client_id": { "type": "integer" }, + "product_id": { "type": "integer" } + }, + "required": ["review_id", "rating", "created_date", "text", "client_id", "product_id"] +} diff --git a/src/controllers/Cart.php b/src/controllers/Cart.php index a65ce86..57c2e20 100644 --- a/src/controllers/Cart.php +++ b/src/controllers/Cart.php @@ -4,8 +4,12 @@ namespace Steamy\Controller; +use Exception; use Steamy\Core\Controller; use Steamy\Core\Utility; +use Steamy\Model\Mailer; +use Steamy\Model\Order; +use Steamy\Model\OrderProduct; use Steamy\Model\Product; use Steamy\Model\Store; @@ -17,8 +21,10 @@ class Cart private function displayCart(): void { + $cart = json_decode(file_get_contents('php://input'), true); + $this->view_data['cart_total'] = 0; // loop through each cart item - foreach ($_SESSION['cart'] as $item) { + foreach ($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); @@ -32,6 +38,7 @@ private function displayCart(): void $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_total'] += $cart_item['subtotal']; $this->view_data['cart_items'][] = $cart_item; } @@ -51,7 +58,7 @@ private function displayCart(): void private function validateURL(): bool { - return Utility::getURL() === 'cart'; + return in_array(Utility::getURL(), ['cart', 'cart/upload', 'cart/checkout']); } private function handleInvalidURL(): void @@ -62,44 +69,105 @@ private function handleInvalidURL(): void } } - public function index(): void + private function handleCheckout(): void { - $this->handleInvalidURL(); - // 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']); + // TODO: write appropriate errors to Cart view instead of sending response code + // check if user is logged in + $signed_client = $this->getSignedInClient(); + if (!$signed_client) { + http_response_code(401); + echo json_encode(['error' => 'You must login first']); 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 + $form_data = json_decode(file_get_contents('php://input'), true); - // 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); + if (empty($form_data)) { + http_response_code(400); + echo json_encode(['error' => 'Cart cannot be empty']); + return; + } + + // Validate store id + $store_id = filter_var($form_data['store_id'] ?? "", FILTER_VALIDATE_INT); + if (!$store_id || empty(Store::getByID($store_id))) { + http_response_code(400); + echo json_encode(['error' => 'Invalid store']); return; } - // send script to browser to fetch cart from localstorage - $cart_script_tag = <<< EOL - - EOL; + // create and populate new Order object + $new_order = new Order(store_id: $store_id, client_id: $signed_client->getUserID()); + foreach ($form_data['items'] as $item) { + $line_item = new OrderProduct( + product_id: filter_var($item['productID'], FILTER_VALIDATE_INT), + cup_size: strtolower($item['cupSize']), + milk_type: strtolower($item['milkType']), + quantity: filter_var($item['quantity'], FILTER_VALIDATE_INT) + ); + $new_order->addLineItem($line_item); + } - $this->view( - 'loading', - template_title: "Review order", - template_tags: $cart_script_tag, - template_meta_description: "Experience anticipation as your journey begins at Steamy Sips. + // save order + $success_order = false; + try { + $success_order = $new_order->save(); + http_response_code($success_order ? 201 : 400); + } catch (Exception $e) { + error_log($e->getMessage()); + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } + + // send confirmation email if order was successfully saved + if ($success_order) { + try { + (new Mailer())->sendOrderConfirmationEmail($new_order); + } catch (Exception $e) { + error_log($e->getMessage()); + } + } + } + + public function index(): void + { + $this->handleInvalidURL(); + + if ($_SERVER['REQUEST_METHOD'] === 'GET') { + // client is requesting /cart for the first time + + // display loading page first and inject a script that does 3 things: + // 1. send a POST request with cart data to server + // 2. re-render page when server sends cart page + // 3. initialize new cart page + + $this->view( + 'loading', + template_title: "Review order", + template_tags: "", + 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.", - enableIndexing: false - ); + enableIndexing: false + ); + + return; + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST' && Utility::getURL() === 'cart/upload') { + // client has sent his cart data and is requesting cart page + $this->displayCart(); + return; + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST' && Utility::getURL() === 'cart/checkout') { + // client has sent his cart data and wants to check out + $this->handleCheckout(); + return; + } + + http_response_code(400); } } diff --git a/src/controllers/Profile.php b/src/controllers/Profile.php index f707854..f7eca13 100644 --- a/src/controllers/Profile.php +++ b/src/controllers/Profile.php @@ -165,9 +165,8 @@ public function index(): void // at this point, we know that the current user was previously signed in // fetch his user details from database - $client_record = Client::getByEmail($_SESSION['user']); - if ($client_record) { - $this->signed_client = $client_record; + $this->signed_client = $this->getSignedInClient(); + if ($this->signed_client) { $this->view_data['client'] = $this->signed_client; } else { // if user record is missing from database, redirect to login page diff --git a/src/core/Controller.php b/src/core/Controller.php index 911642a..a7cb393 100644 --- a/src/core/Controller.php +++ b/src/core/Controller.php @@ -4,6 +4,8 @@ namespace Steamy\Core; +use Steamy\Model\Client; + trait Controller { @@ -36,6 +38,15 @@ private function getLibrariesTags(array $required_libraries): string return $script_str; } + private function getSignedInClient(): ?Client + { + if (empty($_SESSION['user'])) { + return null; + } + + return Client::getByEmail($_SESSION['user']); + } + /** * Renders a view and links its respective CSS file if any. * diff --git a/src/models/Mailer.php b/src/models/Mailer.php index 43849b0..7f8f3f8 100644 --- a/src/models/Mailer.php +++ b/src/models/Mailer.php @@ -65,14 +65,14 @@ public function __construct() } /** - * @param string $email Gmail address of recipient + * @param string $email Email address of recipient * @param string $subject Email subject line * @param string $html_message Message body as an HTML string * @param string $plain_message Message as plain text * @return bool false on error - See the ErrorInfo property for details of the error * @throws Exception Error when calling addAddress or msgHTML */ - public function sendMail(string $email, string $subject, string $html_message, string $plain_message): bool + public function sendMail(string $email, string $subject, string $html_message, string $plain_message = ""): bool { //Set who the message is to be sent to $this->mail->addAddress($email); @@ -85,10 +85,41 @@ public function sendMail(string $email, string $subject, string $html_message, s $this->mail->msgHTML($html_message); // Replace the plain text body with one created manually - $this->mail->AltBody = $plain_message; + if (strlen($plain_message) > 0) { + $this->mail->AltBody = $plain_message; + } // Send the message return $this->mail->send(); } + + /** + * @throws Exception + */ + public function sendOrderConfirmationEmail(Order $order): bool + { + $client = Client::getByID($order->getClientID()); + if (empty($client)) { + return false; + } + + $store = $order->getStore(); + if (empty($store)) { + return false; + } + + // fill email template and save to a variable + ob_start(); + require_once __DIR__ . '/../views/mails/OrderConfirmation.php'; + $html_message = ob_get_contents(); + ob_end_clean(); + + return $this->sendMail( + $client->getEmail(), + "Order Confirmation | Steamy Sips", + $html_message + ); + } + } diff --git a/src/models/Order.php b/src/models/Order.php index cd0ca34..5a88b5d 100644 --- a/src/models/Order.php +++ b/src/models/Order.php @@ -6,6 +6,7 @@ use DateTime; use Exception; +use PDOException; use Steamy\Core\Model; use Steamy\Core\Utility; @@ -22,9 +23,13 @@ class Order private ?DateTime $pickup_date; // ?DateTime type allows $pickup_date to be null private int $client_id; + /** @var OrderProduct[] Array of line items */ + private array $line_items; // array of order products + public function __construct( int $store_id, int $client_id, + array $line_items = [], ?int $order_id = null, ?DateTime $pickup_date = null, OrderStatus $status = OrderStatus::PENDING, // Default to 'pending', @@ -36,6 +41,7 @@ public function __construct( $this->created_date = $created_date; $this->pickup_date = $pickup_date; $this->client_id = $client_id; + $this->line_items = $line_items; } public function toArray(): array @@ -51,33 +57,149 @@ public function toArray(): array } + /** + * Saves order to database + * @throws Exception + * @throws PDOException + */ public function save(): bool { - // If attributes of the object are invalid, exit - if (count($this->validate()) > 0) { - return false; + // check if order has at least 1 line item + if (empty($this->line_items)) { + throw new Exception('Cart cannot be empty'); + } + + $conn = self::connect(); + $conn->beginTransaction(); + + // validate store + $store = Store::getByID($this->store_id); + + if (!$store) { + $conn->rollBack(); + $conn = null; + throw new Exception('Store does not exist'); } - // Get data to be inserted into the order table - $order_data = $this->toArray(); - unset($order_data['order_id']); // Remove order_id as it's auto-incremented - unset($order_data['status']); // Remove status as it's set to 'pending' by default - unset($order_data['pickup_date']); // Remove pickup_date as it's set to NULL by default - unset($order_data['created_date']); // Remove created_date as it's set by database - - Utility::show($order_data); - // Perform insertion into the order table - try { - $new_id = $this->insert($order_data); - if ($new_id === null) { + // create a new order + // Attributes missing in query are set to their default values by mysql + $query = "insert into `order` (client_id, store_id) values(?, ?)"; + $insert_line_item_stm = $conn->prepare($query); + $success = $insert_line_item_stm->execute([$this->client_id, $this->store_id]); + + if (!$success) { + $conn->rollBack(); + $conn = null; + throw new Exception('Order could not be created'); + } + + // get id of last inserted order + $new_order_id = $conn->lastInsertId(); + + if ($new_order_id === false) { + $conn->rollBack(); + $conn = null; + throw new Exception("Failed to retrieve last inserted order ID"); + } + + // cast string ID to integer + $new_order_id = (int)$new_order_id; + + // prepare a query for inserting a line item in order_product table + $query = <<< EOL + insert into `order_product` (order_id, product_id, cup_size, + milk_type, quantity, unit_price) + values(:order_id, :product_id, :cup_size, :milk_type, :quantity, :unit_price) + EOL; + $insert_line_item_stm = $conn->prepare($query); + + // prepare a query for updating stock level + $query = "update store_product + set stock_level = :new_stock_level + where store_id = :store_id + and product_id = :product_id"; + $update_stock_stm = $conn->prepare($query); + + foreach ($this->line_items as $line_item) { + if (!$line_item->validate()) { + // line item contains invalid attributes + $conn->rollBack(); + $conn = null; + throw new Exception("Invalid line item:" . json_encode($line_item)); + } + + // fetch product corresponding to line item + $product = Product::getByID($line_item->getProductID()); + + if (empty($product)) { + // product does not exist + $conn->rollBack(); + $conn = null; + throw new Exception("Product with ID " . $line_item->getProductID() . " does not exist"); + } + + // get stock level for current product + $stock_level = $store->getProductStock($product->getProductID()); + + if ($line_item->getQuantity() > $stock_level) { + // store does not have enough stock + $conn->rollBack(); + $conn = null; + throw new Exception( + "Store with ID " . $this->store_id + . " has insufficient stock for product " . $line_item->getProductID() + ); + } + + // insert into order_product table + $line_item->setOrderID($new_order_id); + $line_item->setUnitPrice($product->getPrice()); + + $success = $insert_line_item_stm->execute($line_item->toArray()); + if (!$success) { + $conn->rollBack(); + $conn = null; return false; } - $this->order_id = $new_id; - return true; - } catch (Exception $e) { - echo $e; - return false; + + // update stock level in store table + $new_stock_level = $stock_level - $line_item->getQuantity(); + $success = $update_stock_stm->execute( + [ + 'product_id' => $product->getProductID(), + 'store_id' => $this->store_id, + 'new_stock_level' => $new_stock_level + ] + ); + if (!$success) { + $conn->rollBack(); + $conn = null; + throw new Exception( + "Unable to update stock level for store with ID " . $this->store_id + ); + } } + $this->order_id = $new_order_id; + + $conn->commit(); + $conn = null; + return true; + } + + /** + * Adds a line item to the order. + * + * @param OrderProduct $orderProduct + * @return void + */ + public function addLineItem(OrderProduct $orderProduct): void + { + $this->line_items[] = $orderProduct; + } + + public function getLineItems(): array + { + return $this->line_items; } /** @@ -153,6 +275,17 @@ public function getStatus(): OrderStatus return $this->status; } + public function getStoreID(): int + { + return $this->store_id; + } + + public function getStore(): ?Store + { + return Store::getByID($this->store_id); + } + + public function setStatus(OrderStatus $status): void { $this->status = $status; @@ -190,79 +323,18 @@ public function validate(): array return $errors; } - /** - * Adds a product to the order. - * - * @param OrderProduct $newOrderProduct - * @return bool - */ - public function addOrderProduct(OrderProduct $newOrderProduct): bool - { - $newOrderProduct->setOrderID($this->order_id); - try { - return $newOrderProduct->save(); - } catch (Exception) { - return false; - } - } public function calculateTotalPrice(): float { $query = "SELECT SUM(unit_price * quantity) AS total_price FROM order_product WHERE order_id = :order_id"; - - $result = self::get_row($query, ['order_id' => $this->order_id]); - - if ($result) { - return (float) $result->total_price; - } - - return 0.0; - } - public function toHTML(): string - { - $html = << - - - Product - Quantity - Price per Unit - Total Price - - - - HTML; - - $query = "SELECT op.product_id, op.quantity, op.unit_price, p.name - FROM order_product op - JOIN product p ON op.product_id = p.product_id - WHERE op.order_id = :order_id"; - - $orderProducts = self::query($query, ['order_id' => $this->order_id]); - - foreach ($orderProducts as $orderProduct) { - $productName = $orderProduct->name; - $quantity = $orderProduct->quantity; - $pricePerUnit = $orderProduct->unit_price; - $totalPrice = $pricePerUnit * $quantity; - - $html .= << - $productName - Qty $quantity - \$$pricePerUnit - \$$totalPrice - - HTML; - } + $result = self::get_row($query, ['order_id' => $this->order_id]); - $html .= << - - HTML; + if ($result) { + return (float)$result->total_price; + } - return $html; + return 0.0; } } diff --git a/src/models/OrderProduct.php b/src/models/OrderProduct.php index 8579ebe..0048327 100644 --- a/src/models/OrderProduct.php +++ b/src/models/OrderProduct.php @@ -55,7 +55,7 @@ public function save(): bool } } - private function validate(): array + public function validate(): array { $errors = []; @@ -71,6 +71,10 @@ private function validate(): array $errors['cup_size'] = 'Cup size type invalid'; } + if ($this->unit_price <= 0) { + $errors['unit_price'] = 'Unit price cannot be negative'; + } + return $errors; } @@ -107,6 +111,11 @@ public function getProductID(): int return $this->product_id; } + public function getProductName(): string + { + return Product::getByID($this->product_id)->getName(); + } + public function getCupSize(): string { return $this->cup_size; diff --git a/src/models/Product.php b/src/models/Product.php index d4fec84..365048a 100644 --- a/src/models/Product.php +++ b/src/models/Product.php @@ -262,17 +262,13 @@ public function save(): bool public function getAverageRating(): float { - // Ensure that $product_id is initialized - if (!isset($this->product_id)) { - return 0; // Return 0 if $product_id is not set - } - // Query the database to calculate the average rating excluding unverified reviews $query = <<< EOL SELECT AVG(r.rating) AS average_rating FROM review r WHERE r.product_id = :product_id AND r.client_id IN ( + -- get IDs of all clients who purchased current product SELECT DISTINCT o.client_id FROM `order` o JOIN order_product op ON o.order_id = op.order_id diff --git a/src/models/Review.php b/src/models/Review.php index 42cf73c..fb74c64 100644 --- a/src/models/Review.php +++ b/src/models/Review.php @@ -210,15 +210,15 @@ public function validate(): array } /** - * Check if the writer of the review has purchased the product. + * Check if the review author has purchased the product. * * @return bool True if the writer has purchased the product, false otherwise. */ public function isVerified(): bool { - // Query the database to check if the review with the given review_id belongs to the user who wrote it + // Count the number of times the review author has purchased the product $query = << $this->product_id, 'review_id' => $this->review_id]); - // If result is empty, the user has written the review for the product - return empty($result); + if ($result === false) { + return false; + } + + return $result->purchase_count > 0; } /** diff --git a/src/views/Cart.php b/src/views/Cart.php index b28ff47..e29a73d 100644 --- a/src/views/Cart.php +++ b/src/views/Cart.php @@ -7,33 +7,72 @@ * * @var array $cart_items Represents an array of cart items, where each item is an object containing information * about a product, including its quantity, cupSize and milkType attributes. + * @var float $cart_total * @var Store[] $stores All stores */ use Steamy\Model\Store; ?> + + + -
-

Shopping Cart

+
+

Shopping Cart 🛒

- - + getStoreID(), FILTER_SANITIZE_NUMBER_INT); + $address = htmlspecialchars($store->getAddress()->getFormattedAddress()); + echo <<< EOL EOL; - } - ?> - + } + ?> + + + + +
+ Empty cart logo. Source: kerismaker from Flaticon +
+ Your cart is empty 😥

"; - } foreach ($cart_items as $item) { $product = $item['product']; $product_id = filter_var($product->getProductID(), FILTER_SANITIZE_NUMBER_INT); @@ -85,8 +124,17 @@ } ?> + + Total = Rs + + + - -
- - \ No newline at end of file +
\ No newline at end of file diff --git a/src/views/Product.php b/src/views/Product.php index fdd01f8..1e5d247 100644 --- a/src/views/Product.php +++ b/src/views/Product.php @@ -27,29 +27,29 @@ function getBadge(Review $review): string { if ($review->isVerified()) { return <<< BADGE -
- - -
- BADGE; +
+ + +
+ BADGE; } - return <<< BADGE -
- -
- BADGE; + return <<< UNVERIFIED_BADGE +
+ +
+ UNVERIFIED_BADGE; } /** @@ -65,25 +65,25 @@ function getStars(Review $review): string while ($checked_stars > 0) { $html .= <<< EOL - - - EOL; + + + EOL; $checked_stars--; } while ($unchecked_stars > 0) { $html .= <<< EOL - - - EOL; + + + EOL; $unchecked_stars--; } return $html; diff --git a/src/views/Profile.php b/src/views/Profile.php index a9369ae..10d5ad1 100644 --- a/src/views/Profile.php +++ b/src/views/Profile.php @@ -13,56 +13,91 @@ ?> + +
-

My account

-

Personal details

- - - - - - - - - - - - - - -

Orders summary

- -
- - - - - - - - - - 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 +

My profile

+ +
+ + + +
+ +
+ + + + + + + + + + + + + + +
+ + +
+ +

Orders summary

+ +
+
DateOrder IDTotal costStatusActions
+ + + + + + + + + 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 @@ -73,57 +108,98 @@ EOL; - } - - ?> - - -
DateOrder IDTotal costStatusActions
$date $id
-
-

Settings

-
-
-
-
Log out
-
Log out from website. You will lose access to your profile and will have to enter your login - details again.
-
-
- -
-
-
-
-
Theme
-
Change the theme of the website.
-
-
-
- - -
+ } + + ?> -
- - -
-
- - + + + +
+ +
+

Settings

+
+
+
+
Log out
+
Log out from the website. You will lose access to your profile and will have to enter your + login details again.
+
+
+ +
+
+
+
+
Theme
+
Change the theme of the website.
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
-
-
-
-
-
Delete account
-
Permanently delete your account with all its associated information. This action is - irreversible.
-
-
- -
-
+ +
+
+
Delete account
+
Permanently delete your account with all its associated information. This action is + irreversible.
+
+
+ +
+
+
+
+ + - \ No newline at end of file diff --git a/src/views/Shop.php b/src/views/Shop.php index 2cff0bc..ea4cf34 100644 --- a/src/views/Shop.php +++ b/src/views/Shop.php @@ -17,10 +17,10 @@ /** * Outputs sanitized HTML to display a product - * @param $product + * @param Product $product * @return void */ -function displayProduct($product): void +function displayProduct(Product $product): void { $product_href = htmlspecialchars( '/shop/products/' . $product->getProductID() @@ -28,10 +28,14 @@ function displayProduct($product): void $product_img_src = htmlspecialchars($product->getImgAbsolutePath()); // url of image $img_alt_text = htmlspecialchars($product->getImgAltText()); $name = htmlspecialchars($product->getName()); + $price = filter_var($product->getPrice(), FILTER_VALIDATE_FLOAT, FILTER_FLAG_ALLOW_FRACTION); echo << $img_alt_text -
$name
+
+
$name
+
Rs $price
+
EOL; } @@ -84,7 +88,7 @@ function displayProduct($product): void -
+
+ + + + + + + Order Confirmation + + + +

Order Confirmation

+ +

Dear getFirstName()) . " " . ucfirst($client->getLastName())) ?>,

+ +

Thank you for your purchase at Steamy Sips! We've received your order successfully. You can find your purchase + information + below.

+ +

Order summary

+ +

Order ID: getOrderID() ?>

+

Order Date: getCreatedDate()->format('Y-m-d H:i') ?>

+

Store address: getAddress()->getFormattedAddress() ?>

+ + + + + + + + + + + + + + + getLineItems(); + $total = 0; + foreach ($orderProducts as $orderProduct) { + $name = htmlspecialchars($orderProduct->getProductName()); + $quantity = $orderProduct->getQuantity(); + $pricePerUnit = $orderProduct->getUnitPrice(); + $subtotal = $pricePerUnit * $quantity; + $size = htmlspecialchars(ucfirst($orderProduct->getCupSize())); + $total += $subtotal; + $milk = htmlspecialchars( + ucfirst($orderProduct->getMilkType()) + ); + + echo <<< HTML + + + + + + + + + HTML; + } + ?> + + + + + +
ProductUnit price (Rs)SizeMilkQuantitySubtotal (Rs)
$name$pricePerUnit$size$milk$quantity$subtotal
Total
+ +

Your order is now being processed and you will receive a notification once your order is ready. If you have any + questions, feel free to call our store at getPhoneNo() ?>.

+ +

Best Regards,

+

Steamy Sips

+ + diff --git a/tests/ClientTest.php b/tests/ClientTest.php index d8f4d1b..61865f9 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -5,10 +5,12 @@ use PHPUnit\Framework\TestCase; use Steamy\Model\Client; use Steamy\Model\Location; +use Steamy\Core\Database; final class ClientTest extends TestCase { + use Database; private ?Client $dummy_client; public function setUp(): void @@ -17,17 +19,24 @@ public function setUp(): void $this->dummy_client = new Client( "john_u@gmail.com", "john", "johhny", "abcd", "13213431", $address); + + $success = $this->dummy_client->save(); + if (!$success) { + throw new Exception('Unable to save client'); + } } public function tearDown(): void { $this->dummy_client = null; + + // Clear all data from client and user tables + self::query('DELETE FROM client; DELETE FROM user;'); } public function testConstructor(): void { // check if fields were correctly set - self::assertEquals(-1, $this->dummy_client->getUserID()); self::assertEquals("john_u@gmail.com", $this->dummy_client->getEmail()); self::assertEquals("john", $this->dummy_client->getFirstName()); self::assertEquals("johhny", $this->dummy_client->getLastName()); diff --git a/tests/CommentTest.php b/tests/CommentTest.php index d105fa5..cdbba2d 100644 --- a/tests/CommentTest.php +++ b/tests/CommentTest.php @@ -8,6 +8,7 @@ use Steamy\Model\Location; use Steamy\Model\Client; use Steamy\Core\Database; +use Steamy\Model\Product; Class CommentTest extends TestCase { @@ -15,9 +16,26 @@ private ?Comment $dummy_comment; private ?Review $dummy_review; private ?Client $reviewer; + private ?Product $dummy_product; public function setUp(): void { + // Create a dummy product for testing + $this->dummy_product = new Product( + "Velvet Bean", + 70, + "Velvet.jpeg", + "Velvet Bean Image", + "Velvet", + 6.50, + "Each bottle contains 90% Pure Coffee powder and 10% Velvet bean Powder", + new DateTime() + ); + + $success = $this->dummy_product->save(); + if (!$success) { + throw new Exception('Unable to save product'); + } // create a client object and save to database $this->reviewer = new Client( @@ -33,7 +51,7 @@ public function setUp(): void // create a review object and save to database $this->dummy_review = new Review( 3, - 3, + $this->dummy_product->getProductID(), $this->reviewer->getUserID(), "This is a test test review.", 5 @@ -63,9 +81,10 @@ public function tearDown(): void $this->dummy_review = null; $this->reviewer = null; $this->dummy_comment = null; + $this->dummy_product = null; // clear all data from review and client tables - self::query('DELETE FROM comment; DELETE FROM review; DELETE FROM client; DELETE FROM user;'); + self::query('DELETE FROM comment; DELETE FROM review; DELETE FROM client; DELETE FROM user; DELETE FROM store_product; DELETE FROM product;'); } public function testConstructor(): void diff --git a/tests/ProductTest.php b/tests/ProductTest.php index d67b9e8..f821e8c 100644 --- a/tests/ProductTest.php +++ b/tests/ProductTest.php @@ -4,10 +4,13 @@ use PHPUnit\Framework\TestCase; use Steamy\Model\Product; -use Steamy\Model\Review; // Import Review class if not already imported +use Steamy\Model\Review; +use Steamy\Core\Database; + final class ProductTest extends TestCase { + use Database; private ?Product $dummy_product; public function setUp(): void @@ -23,11 +26,19 @@ public function setUp(): void "Each bottle contains 90% Pure Coffee powder and 10% Velvet bean Powder", new DateTime() ); + + $success = $this->dummy_product->save(); + if (!$success) { + throw new Exception('Unable to save product'); + } } public function tearDown(): void { $this->dummy_product = null; + + // Clear all data from product tables + self::query('DELETE FROM store_product; DELETE FROM product;'); } public function testConstructor(): void diff --git a/tests/ReviewTest.php b/tests/ReviewTest.php index 682c28b..16ce6d4 100644 --- a/tests/ReviewTest.php +++ b/tests/ReviewTest.php @@ -7,6 +7,7 @@ use Steamy\Model\Client; use Steamy\Model\Location; use Steamy\Model\Review; +use Steamy\Model\Product; final class ReviewTest extends TestCase { @@ -14,6 +15,8 @@ final class ReviewTest extends TestCase private ?Review $dummy_review; private ?Client $reviewer; + private ?Product $dummy_product; + /** * Adds a client and a review to the database. @@ -23,6 +26,23 @@ final class ReviewTest extends TestCase */ public function setUp(): void { + // Create a dummy product for testing + $this->dummy_product = new Product( + "Velvet Bean", + 70, + "Velvet.jpeg", + "Velvet Bean Image", + "Velvet", + 6.50, + "Each bottle contains 90% Pure Coffee powder and 10% Velvet bean Powder", + new DateTime() + ); + + $success = $this->dummy_product->save(); + if (!$success) { + throw new Exception('Unable to save product'); + } + // create a client object and save to database $this->reviewer = new Client( "john_u@gmail.com", "john", "johhny", "User0", @@ -37,7 +57,7 @@ public function setUp(): void // create a review object and save to database $this->dummy_review = new Review( 1, - 1, + $this->dummy_product->getProductID(), $this->reviewer->getUserID(), "This is a test review.", 5 @@ -57,9 +77,10 @@ public function tearDown(): void { $this->dummy_review = null; $this->reviewer = null; + $this->dummy_product = null; // clear all data from review and client tables - self::query('DELETE FROM comment; DELETE FROM review; DELETE FROM client; DELETE FROM user;'); + self::query('DELETE FROM comment; DELETE FROM review; DELETE FROM client; DELETE FROM user; DELETE FROM store_product; DELETE FROM product;'); } public function testConstructor(): void @@ -102,7 +123,6 @@ public function testToArray(): void // Check if the actual values are correct self::assertEquals($this->reviewer->getUserID(), $result['client_id']); - self::assertEquals(1, $result['product_id']); self::assertEquals("This is a test review.", $result['text']); self::assertEquals( $this->dummy_review->getCreatedDate()->format('Y-m-d H:i:s'), diff --git a/webpack.config.js b/webpack.config.js index 34fd5df..0e3497f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -8,7 +8,6 @@ module.exports = { home_view: entryPath + "home-view.js", product_view: entryPath + "product-view.js", cart_view: entryPath + "cart-view.js", - cart_uploader: entryPath + "cart-uploader.js", theme_switcher: entryPath + "theme-switcher.js", }, output: {