diff --git a/README.md b/README.md index 3e6ab013..d16da13c 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,16 @@ The code for the admin website is found in a separate repository. ## Main features - MVC pattern -- Dynamic routing +- Semantic URL routing - Email-based password recovery - Email notification on order - Testing with phpUnit - Mobile-responsive website -- Javascript code bundling with backwards compatibility +- Utilizes Webpack for efficient code bundling and compatibility with older browsers. - Product review system with nested comments +- Fuzzy searching on shop page +- Pagination +- SEO optimized - REST API ## Documentation diff --git a/public/js_original/cart-view.js b/public/js_original/cart-view.js index dc5cca23..1b1eb18f 100644 --- a/public/js_original/cart-view.js +++ b/public/js_original/cart-view.js @@ -4,7 +4,6 @@ import Cart from "./models/Cart"; import CartItem from "./models/CartItem"; -import ModalManager from "./modal"; function updateCart(e) { const sectionNode = e.target.parentNode.parentNode; @@ -50,6 +49,9 @@ function updateCart(e) { } async function checkout() { + // set loading animation on checkout button to prevent multiple form submissions + document.querySelector("#checkout-btn").setAttribute("aria-busy", "true"); + const myCart = Cart(); const items = myCart.getItems(); @@ -63,10 +65,14 @@ async function checkout() { body: JSON.stringify(data), }); + // stop loading animation + document.querySelector("#checkout-btn").removeAttribute("aria-busy"); + if (response.ok) { // Clear cart items from localStorage if checkout is successful myCart.clear(); - ModalManager("my-modal").openModal(); + + document.querySelector("#my-modal").setAttribute("open", ""); return; } const x = await response.json(); @@ -85,9 +91,12 @@ function initCartPage() { ...document.querySelectorAll("section input[type='number']"), ]; - ModalManager("my-modal").init(); + const checkoutBtn = document.querySelector("#checkout-btn"); - document.querySelector("#checkout-btn").addEventListener("click", checkout); + // if checkout button is present on page (button is absent when cart is empty) + if (checkoutBtn !== null) { + checkoutBtn.addEventListener("click", checkout); + } quantityInputs.forEach((input) => { input.addEventListener("change", updateCart); diff --git a/public/js_original/profile-view.js b/public/js_original/profile-view.js new file mode 100644 index 00000000..63ef7341 --- /dev/null +++ b/public/js_original/profile-view.js @@ -0,0 +1,30 @@ +function openTab(evt, tabName) { + console.log("New tab = " + tabName); + + // hide all tab contents + const tabcontents = [...document.getElementsByClassName("tabcontent")]; + for (let i = 0; i < tabcontents.length; i++) { + tabcontents[i].style.display = "none"; + } + + // remove active class from the currently active tab link + const tablinks = document.getElementsByClassName("tablink"); + for (let i = 0; i < tablinks.length; i++) { + tablinks[i].className = tablinks[i].className.replace(" active", ""); + } + + // display content for clicked tab + document.getElementById(tabName).style.display = "block"; + + // set active class only to the clicked tab link + evt.currentTarget.className += " active"; +} + +const tabs = ["Account", "Orders", "Settings"]; + +window.addEventListener("DOMContentLoaded", () => { + [...document.getElementsByClassName("tablink")].forEach((tablink, i) => { + console.log(i, tablink); + tablink.addEventListener("click", (e) => openTab(e, tabs[i])); + }); +}); \ No newline at end of file diff --git a/public/styles/views/Profile.css b/public/styles/views/Profile.css index dd8bc4cb..f89067b5 100644 --- a/public/styles/views/Profile.css +++ b/public/styles/views/Profile.css @@ -18,11 +18,32 @@ button[name="account_delete_submit"] { background-color: red; } -table button{ - padding:5px; +table button { + padding: 5px; } -table tr button:first-of-type{ +table tr button:first-of-type { border: 0; background-color: red; +} + +/* Style tab links */ +.tablink { + float: left; + border: none; + outline: none; + cursor: pointer; + font-size: 17px; + background-color: var(--secondary); +} + +.active { + background-color: var(--contrast); + color: var(--contrast-inverse); +} + +/* Style the tab content (and add height:100% for full page content) */ +.tabcontent { + display: none; + padding: 20px 0; } \ No newline at end of file diff --git a/src/controllers/Cart.php b/src/controllers/Cart.php index 5f93ec91..58588ad4 100644 --- a/src/controllers/Cart.php +++ b/src/controllers/Cart.php @@ -5,9 +5,9 @@ namespace Steamy\Controller; use Exception; +use PDOException; use Steamy\Core\Controller; use Steamy\Core\Utility; -use Steamy\Model\Mailer; use Steamy\Model\Order; use Steamy\Model\OrderProduct; use Steamy\Model\Product; @@ -71,8 +71,6 @@ private function handleInvalidURL(): void private function handleCheckout(): void { - // 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) { @@ -110,25 +108,43 @@ private function handleCheckout(): void $new_order->addLineItem($line_item); } - // save order - $success_order = false; + // attempt to save order. An exception will be generated in case of any errors. try { $success_order = $new_order->save(); - http_response_code($success_order ? 201 : 400); + } catch (PDOException $e) { + error_log($e->getMessage()); + http_response_code(503); + echo json_encode(['error' => 'Database error: ' . $e->getMessage()]); + return; } catch (Exception $e) { error_log($e->getMessage()); - http_response_code(500); + http_response_code(400); echo json_encode(['error' => $e->getMessage()]); + return; + } + // if order was unsuccessfully saved without any exceptions generated + if (!$success_order) { + http_response_code(500); + echo json_encode(['error' => "Order could not be saved for an unknown reason."]); + return; } - // send confirmation email if order was successfully saved - if ($success_order) { - try { - $signed_client->sendOrderConfirmationEmail($new_order); - } catch (Exception $e) { - error_log($e->getMessage()); - } + // send confirmation email + try { + $success_mail = $signed_client->sendOrderConfirmationEmail($new_order); + } catch (Exception $e) { + http_response_code(503); + echo json_encode(['error' => "Order was saved but email could not be sent: " . $e->getMessage()]); + return; + } + + if (!$success_mail) { + http_response_code(503); + echo json_encode(['error' => "Order was saved but email could not be sent for an unknown reason."]); } + + // if everything is good, tell client to reset the document view + http_response_code(205); } public function index(): void diff --git a/src/controllers/Product.php b/src/controllers/Product.php index d1bcb201..8a2d6096 100644 --- a/src/controllers/Product.php +++ b/src/controllers/Product.php @@ -21,7 +21,7 @@ class Product private ?ProductModel $product = null; // product to be displayed private array $view_data; - private ?User $signed_user = null; // currently logged-in user + private ?User $signed_user; // currently logged-in user public function __construct() { @@ -38,13 +38,10 @@ public function __construct() // get product id from URL $product_id = filter_var(Utility::splitURL()[2], FILTER_VALIDATE_INT); - // check if user is logged in - $reviewer_email = $_SESSION['user'] ?? ""; - // get user details - $user_account = Client::getByEmail($reviewer_email); - if (!empty($user_account)) { - $this->signed_user = $user_account; + $this->signed_user = $this->getSignedInClient(); + + if (!empty($this->signed_user)) { $this->view_data["signed_in_user"] = true; } diff --git a/src/controllers/Profile.php b/src/controllers/Profile.php index d64fd06f..6bd132b4 100644 --- a/src/controllers/Profile.php +++ b/src/controllers/Profile.php @@ -74,7 +74,7 @@ private function handleAccountDeletion(): void */ private function handleUnsignedUsers(): void { - if (!array_key_exists('user', $_SESSION) || !isset($_SESSION['user'])) { + if (empty($this->getSignedInClient())) { Utility::redirect('login'); } } diff --git a/src/core/Controller.php b/src/core/Controller.php index a7cb3939..f3e0a550 100644 --- a/src/core/Controller.php +++ b/src/core/Controller.php @@ -38,8 +38,13 @@ private function getLibrariesTags(array $required_libraries): string return $script_str; } + /** + * @return Client|null Client account of currently logged-in user. Null if no one is logged in. + */ private function getSignedInClient(): ?Client { + // $_SESSION['user'] was set to the client email on login + // if it is empty, no one is logged in if (empty($_SESSION['user'])) { return null; } diff --git a/src/models/Order.php b/src/models/Order.php index 00cef300..1448a120 100644 --- a/src/models/Order.php +++ b/src/models/Order.php @@ -122,12 +122,8 @@ public function save(): bool $update_stock_stm = $conn->prepare($query); foreach ($this->line_items as $line_item) { - if (!empty($line_item->validate())) { - // line item contains invalid attributes - $conn->rollBack(); - $conn = null; - throw new Exception("Invalid line item:" . json_encode($line_item)); - } + // set order ID of line item + $line_item->setOrderID($new_order_id); // fetch product corresponding to line item $product = Product::getByID($line_item->getProductID()); @@ -139,6 +135,9 @@ public function save(): bool throw new Exception("Product with ID " . $line_item->getProductID() . " does not exist"); } + // set true unit price of line item + $line_item->setUnitPrice($product->getPrice()); + // get stock level for current product $stock_level = $store->getProductStock($product->getProductID()); @@ -146,15 +145,39 @@ public function save(): bool // store does not have enough stock $conn->rollBack(); $conn = null; + + $error_message = <<< EOL + Store with ID $this->store_id has insufficient stock ($stock_level) for the following line item: + Product ID = {$line_item->getProductID()} and quantity = {$line_item->getQuantity()}. + EOL; + + throw new Exception($error_message); + } + + // validate line item + $line_item_errors = $line_item->validate(); + if (!empty($line_item_errors)) { + // line item contains invalid attributes + $conn->rollBack(); + $conn = null; + + $line_item_info = json_encode($line_item->toArray()); + $line_item_errors = json_encode($line_item_errors); + + $error_message = <<< EOL + Invalid line item: + $line_item_info + + Errors: + $line_item_errors + EOL; + throw new Exception( - "Store with ID " . $this->store_id - . " has insufficient stock for product " . $line_item->getProductID() + $error_message ); } - // insert into order_product table - $line_item->setOrderID($new_order_id); - $line_item->setUnitPrice($product->getPrice()); + // insert line item into order_product table $success = $insert_line_item_stm->execute($line_item->toArray()); if (!$success) { @@ -192,14 +215,9 @@ public function save(): bool * * @param OrderProduct $orderProduct * @return void - * @throws Exception */ public function addLineItem(OrderProduct $orderProduct): void { - $errors = $orderProduct->validate(); - if (!empty($errors)) { - throw new Exception("Invalid line item: " . json_encode($errors)); - } $this->line_items[] = $orderProduct; } diff --git a/src/models/OrderProduct.php b/src/models/OrderProduct.php index 0048327d..2c79e138 100644 --- a/src/models/OrderProduct.php +++ b/src/models/OrderProduct.php @@ -20,6 +20,15 @@ class OrderProduct private int $quantity; private float $unit_price; + /** + * Create a new OrderProduct object + * @param int $product_id + * @param string $cup_size + * @param string $milk_type + * @param int $quantity + * @param float|null $unit_price If not set, the default $unit_price is -1. + * @param int|null $order_id If not set, the default $order_id is -1. + */ public function __construct( int $product_id, string $cup_size, diff --git a/src/views/Cart.php b/src/views/Cart.php index e29a73dc..4948ddd6 100644 --- a/src/views/Cart.php +++ b/src/views/Cart.php @@ -16,24 +16,11 @@ ?>
- -

Checkout successful! ✨

+

Your order has been successfully placed and an email has been sent to you.

diff --git a/src/views/Profile.php b/src/views/Profile.php index 14f600b3..e4b95f30 100644 --- a/src/views/Profile.php +++ b/src/views/Profile.php @@ -15,27 +15,22 @@ ?> - + + +
+

Deleting your account!

+

Are you sure you want to delete your account? This action is irreversible.

+ +
+
+

My profile

@@ -173,53 +168,4 @@
- - - - -
-

Deleting your account!

-

Are you sure you want to delete your account? This action is irreversible.

- -
-
- \ No newline at end of file + \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 0e3497f2..42397bb1 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -7,6 +7,7 @@ module.exports = { global_view: entryPath + "global-view.js", home_view: entryPath + "home-view.js", product_view: entryPath + "product-view.js", + profile_view: entryPath + "profile-view.js", cart_view: entryPath + "cart-view.js", theme_switcher: entryPath + "theme-switcher.js", },