diff --git a/README.md b/README.md index d16da13..88d179c 100644 --- a/README.md +++ b/README.md @@ -20,15 +20,16 @@ includes: For more details, see the [software requirements specification](docs/SOFTWARE_SPECS.md). -The code for the admin website is found in a separate repository. +This repository contains the code for the client website and the API. The code for the admin website is found in a +separate repository. ## Main features - MVC pattern - Semantic URL routing - Email-based password recovery -- Email notification on order -- Testing with phpUnit +- Email notification on checkout +- Integration testing with phpUnit - Mobile-responsive website - Utilizes Webpack for efficient code bundling and compatibility with older browsers. - Product review system with nested comments @@ -36,6 +37,7 @@ The code for the admin website is found in a separate repository. - Pagination - SEO optimized - REST API +- API testing with Guzzler and phpUnit ## Documentation @@ -58,3 +60,17 @@ Attribution-ShareAlike - https://github.com/kevinisaac/php-mvc 4. The filesystem was inspired by https://github.com/php-pds/sklseleton 5. Additional references are included within the code itself. + +# Todo + +Add `X-TEST-ENV` to the header of your request and set its value to `testing` if you want to use the testing database. +This is required when running tests for API. Without this key-value pair, the production database will be used. + +USE DOCKER PHP + +- read guzzle documentation. read base_uri +- test database is being used +- add variable to .env + +line 18 in Reviews API is redundant +use correct namespace \ No newline at end of file diff --git a/public/styles/views/Shop.css b/public/styles/views/Shop.css index bd9d5dd..f82c1f1 100644 --- a/public/styles/views/Shop.css +++ b/public/styles/views/Shop.css @@ -61,52 +61,4 @@ article header { #item-grid { grid-template-columns: repeat(1, 1fr); } -} - -.pagination { - display: flex; - list-style: none; - border-radius: 0.25rem; - gap: 0.45rem; - margin-top: 2cm; -} - - -.page-item { - --bs-padding-x: 0.5rem; - --bs-padding-y: 0.25rem; -} - -.page-link { - position: relative; - display: block; - padding: var(--bs-padding-y) var(--bs-padding-x); - text-decoration: none; - transition: color .25s ease-in-out, background-color .25s ease-in-out; - outline: 1px solid #dee2e6; -} - -.page-link:hover { - z-index: 2; - background-color: var(--contrast-hover); - color: var(--contrast-inverse); -} - -.page-link:focus { - z-index: 3; - outline: 0; - box-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.25); -} - -.page-item.active .page-link { - z-index: 3; - background-color: var(--contrast); - color: var(--contrast-inverse); -} - -.page-item.disabled .page-link { - color: var(--form-element-disabled-opacity); - outline-color: var(--form-element-disabled-border-color); - pointer-events: none; - background-color: var(--form-element-disabled-background-color); } \ No newline at end of file diff --git a/src/controllers/Orders.php b/src/controllers/Orders.php new file mode 100644 index 0000000..1cc1205 --- /dev/null +++ b/src/controllers/Orders.php @@ -0,0 +1,78 @@ +validateURL()) { + $url = Utility::getURL(); + $parts = explode('/', $url); + // Check if the last part of the URL is a valid integer + $lastPart = end($parts); + if (is_numeric($lastPart)) { + return (int)$lastPart; + } else { + return null; + } + } + return null; + } + + + private function handleInvalidURL(): void + { + if (!$this->validateURL()) { + (new Error())->handlePageNotFoundError(); + die(); + } + } + + public function index(): void + { + $this->handleInvalidURL(); + + $order_id = $this->getOrderIDFromURL(); + if ($order_id === null) { + (new Error())->handlePageNotFoundError(); + return; + } + + $order = Order::getByID($order_id); + if (!$order) { + (new Error())->handlePageNotFoundError(); + return; + } + + $order_products = Order::getOrderProducts($order->getOrderID()); + + $this->view_data['order'] = $order; + $this->view_data['line_items'] = $order_products; + + $this->view( + 'orders', + $this->view_data, + 'Order #' . $order_id, + enableIndexing: false + ); + } +} diff --git a/src/controllers/Pagination.php b/src/controllers/Pagination.php new file mode 100644 index 0000000..4f5bec7 --- /dev/null +++ b/src/controllers/Pagination.php @@ -0,0 +1,76 @@ +items_per_page = $items_per_page; + $this->total_items = $total_items; + $this->current_page_number = $current_page; + } + + /** + * Returns a query string that maintains all current query string parameters, except page number. + * @return string Query string + */ + private function getCurrentQueryString(): string + { + // create a string with all past query parameters except page and url + unset($_GET['page']); + unset($_GET['url']); + + return '?' . http_build_query($_GET); + } + + /** + * @param array $array + * @return array New array containing only elements to be displayed on current page + */ + public function getCurrentItems(array $array): array + { + return array_slice( + $array, + ($this->current_page_number - 1) * $this->items_per_page, + $this->items_per_page + ); + } + + /** + * Returns HTML code need to display pagination items + * @return string + */ + public function getHTML(): string + { + $current_page_number = $this->current_page_number; + $total_pages = (int)ceil((float)$this->total_items / $this->items_per_page); + $query_string = $this->getCurrentQueryString(); + + $view_file_path = __DIR__ . '/../views/Pagination.php'; + $html = ''; + + // get content from view file + ob_start(); + include $view_file_path; + $html = ob_get_contents(); + ob_end_clean(); + + return $html; + } + + public function index(): void + { + // we don't want the page /pagination to be accessible + (new Error())->handlePageNotFoundError(); + } +} diff --git a/src/controllers/Product.php b/src/controllers/Product.php index 8a2d609..25270dd 100644 --- a/src/controllers/Product.php +++ b/src/controllers/Product.php @@ -6,7 +6,6 @@ use Steamy\Core\Controller; use Steamy\Core\Utility; -use Steamy\Model\Client; use Steamy\Model\Comment; use Steamy\Model\Review; use Steamy\Model\User; @@ -19,6 +18,8 @@ class Product { use Controller; + private static int $MAX_REVIEWS_PER_PAGE = 2; + private ?ProductModel $product = null; // product to be displayed private array $view_data; private ?User $signed_user; // currently logged-in user @@ -232,6 +233,14 @@ private function showCommentForm(): void } } + /** + * @return int Page number on shop page. Defaults to 1. + */ + public function getPageNumber(): int + { + return (int)($_GET['page'] ?? 1); + } + private function validateURL(): bool { return preg_match("/^shop\/products\/[0-9]+$/", Utility::getURL()) === 1; @@ -270,11 +279,23 @@ public function index(): void $this->handleCommentSubmission(); } - $this->view_data['product_reviews'] = array_filter( + // get all reviews that match criteria + $all_matching_reviews = array_filter( $this->view_data['product_reviews'], array($this, "filterReviews") ); + $pagination_controller = new Pagination( + Product::$MAX_REVIEWS_PER_PAGE, + count($all_matching_reviews), + $this->getPageNumber() + ); + + // get html code for displaying pagination + $this->view_data['review_pagination'] = $pagination_controller->getHTML(); + + $this->view_data['product_reviews'] = $pagination_controller->getCurrentItems($all_matching_reviews); + $this->view_data['rating_distribution'] = $this->formatRatingDistribution(); $this->view( diff --git a/src/controllers/Profile.php b/src/controllers/Profile.php index f6c7892..e518acb 100644 --- a/src/controllers/Profile.php +++ b/src/controllers/Profile.php @@ -145,7 +145,7 @@ public function cancelOrder(): void } // Cancel the order - $order->deleteOrder(); + $order->cancelOrder(); } private function handleProfileEditSubmission(): void diff --git a/src/controllers/Shop.php b/src/controllers/Shop.php index 69a4c38..d04ca5b 100644 --- a/src/controllers/Shop.php +++ b/src/controllers/Shop.php @@ -16,7 +16,7 @@ class Shop { use Controller; - private array $data; + private array $view_data; private static int $MAX_PRODUCTS_PER_PAGE = 4; /** @@ -154,20 +154,6 @@ public function getPageNumber(): int return (int)($_GET['page'] ?? 1); } - /** - * @param $products - * @return array Products which should be displayed on current page - */ - public function applyPagination($products): array - { - // Slice the products based on pagination - return array_slice( - $products, - ($this->getPageNumber() - 1) * Shop::$MAX_PRODUCTS_PER_PAGE, - Shop::$MAX_PRODUCTS_PER_PAGE - ); - } - public function index(): void { // check if URL follows format /shop/products/ @@ -187,22 +173,27 @@ public function index(): void // get all products that match user criteria $filtered_products = $this->getMatchingProducts(); - // Slice the products based on pagination - $paginated_products = $this->applyPagination($filtered_products); + // get html for pagination + $pagination_controller = new Pagination( + Shop::$MAX_PRODUCTS_PER_PAGE, + count($filtered_products), + $this->getPageNumber() + ); + + $this->view_data['pagination'] = $pagination_controller->getHTML(); + $this->view_data['products'] = $pagination_controller->getCurrentItems($filtered_products); - // Initialize view variables (existing functionality) - $this->data['products'] = $paginated_products; - $this->data['search_keyword'] = $_GET['keyword'] ?? ""; - $this->data['categories'] = Product::getCategories(); - $this->data['sort_option'] = $_GET['sort'] ?? ""; - $this->data['selected_categories'] = $_GET['categories'] ?? []; - $this->data['current_page_number'] = $this->getPageNumber(); - $this->data['total_pages'] = (int)ceil(count($filtered_products) / Shop::$MAX_PRODUCTS_PER_PAGE); + // Initialize view variables + $this->view_data['search_keyword'] = $_GET['keyword'] ?? ""; + $this->view_data['categories'] = Product::getCategories(); + $this->view_data['sort_option'] = $_GET['sort'] ?? ""; + $this->view_data['selected_categories'] = $_GET['categories'] ?? []; + $this->view_data['current_page_number'] = $this->getPageNumber(); // Render the view with pagination information $this->view( 'Shop', - $this->data, + $this->view_data, 'Shop', template_tags: $this->getLibrariesTags(['aos']), template_meta_description: "Explore a delightful selection of aromatic coffees, teas, and delectable diff --git a/src/models/Order.php b/src/models/Order.php index c20cd86..07377be 100644 --- a/src/models/Order.php +++ b/src/models/Order.php @@ -260,21 +260,15 @@ public static function getByID(int $order_id): ?Order } /** - * Deletes the order and associated line items from the database. + * Cancels the order and associated line items from the database. */ - public function deleteOrder(): void + public function cancelOrder(): void { $conn = self::connect(); $conn->beginTransaction(); try { - // Delete line items first - $query = "DELETE FROM order_product WHERE order_id = :order_id"; - $stm = $conn->prepare($query); - $stm->execute(['order_id' => $this->order_id]); - - // Delete the order itself - $query = "DELETE FROM `order` WHERE order_id = :order_id"; + $query = "UPDATE `order` SET status = 'cancelled' WHERE order_id = :order_id"; $stm = $conn->prepare($query); $stm->execute(['order_id' => $this->order_id]); diff --git a/src/models/OrderProduct.php b/src/models/OrderProduct.php index 2c79e13..efb4ce4 100644 --- a/src/models/OrderProduct.php +++ b/src/models/OrderProduct.php @@ -87,17 +87,22 @@ public function validate(): array return $errors; } + /** + * order_id and product_id are the primary of the record to be searched. + * @param int $order_id + * @param int $product_id + * @return OrderProduct|null + */ public static function getByID(int $order_id, int $product_id): ?OrderProduct { - $query = <<< EOL - select * from order_product - where order_id = :order_id and product_id = :product_id - EOL; + $query = 'SELECT * FROM order_product WHERE order_id = ? and product_id= ?'; + $params = [$order_id, $product_id]; - $result = self::query($query, ['order_id' => $order_id, 'product_id' => $product_id]); + $result = self::query($query, $params); if (empty($result)) { return null; } + $result = $result[0]; return new OrderProduct( diff --git a/src/views/Orders.php b/src/views/Orders.php new file mode 100644 index 0000000..a51e937 --- /dev/null +++ b/src/views/Orders.php @@ -0,0 +1,48 @@ + + +
+

Order #getOrderID(), FILTER_SANITIZE_NUMBER_INT); ?>

+
+

Order Details

+

Order ID: getOrderID(), FILTER_SANITIZE_NUMBER_INT); ?>

+

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

+

Status: getStatus()->value)) ?>

+

Total Price: $calculateTotalPrice(), 2)) ?>

+
+ +
+

Order Items

+ + + + + + + + + + + + + + + + + +
Product NameQuantityMilk TypeCup SizeUnit Price
getProductName()) ?>getQuantity(), FILTER_SANITIZE_NUMBER_INT) ?>getMilkType()) ?>getCupSize()) ?>$getUnitPrice(), 2)) ?>
+
+
diff --git a/src/views/Pagination.php b/src/views/Pagination.php new file mode 100644 index 0000000..54dd056 --- /dev/null +++ b/src/views/Pagination.php @@ -0,0 +1,140 @@ + + $page_number + + EOL; +} + +/** + * Prints navigation button in HTML format + * @param int $current_page_number + * @param int $total_pages Total number of pages + * @param string $query_string + * @param bool $is_left True indicates left navigation button. + * @return void + */ +function displayNavigationButton(int $current_page_number, int $total_pages, string $query_string, bool $is_left): void +{ + $page_number = $current_page_number + ($is_left ? -1 : 1); + $page_link = $query_string . "&page=$page_number"; + + $link_content = htmlspecialchars($is_left ? "<" : ">"); + $className = "page-item"; + + if (($current_page_number > $total_pages) || // invalid page number + ($current_page_number < 1) || // invalid page number + ($current_page_number === 1 && $is_left) || // first page + ($current_page_number === $total_pages && !$is_left) // last page + ) { + $className .= " disabled"; + } + + echo <<< EOL +
  • + $link_content +
  • + EOL; +} + +?> + + + + \ No newline at end of file diff --git a/src/views/Product.php b/src/views/Product.php index c1fd697..c177424 100644 --- a/src/views/Product.php +++ b/src/views/Product.php @@ -11,6 +11,7 @@ * @var $default_rating int default rating in form * @var $rating_distribution string An array containing the percentages of ratings * @var $comment_form_info ?array Array with information to be displayed on comment form + * @var $review_pagination string HTML code for review pagination */ use Steamy\Core\Utility; @@ -190,6 +191,7 @@ function printComment(StdClass $comment): void } ?> +
    + + \ No newline at end of file diff --git a/src/views/Profile.php b/src/views/Profile.php index 09fd683..3987139 100644 --- a/src/views/Profile.php +++ b/src/views/Profile.php @@ -100,7 +100,7 @@ $totalPrice = htmlspecialchars(number_format($order->calculateTotalPrice(), 2)); // Determine button states - $cancelDisabled = $order->getStatus()->value === 'completed' ? 'disabled' : ''; + $cancelDisabled = ($order->getStatus()->value === 'completed' || $order->getStatus()->value === 'cancelled') ? 'disabled' : ''; echo <<< EOL @@ -112,17 +112,22 @@
    + View - - + +
    EOL; } + ?> + + +
    @@ -169,9 +174,10 @@
    +
    - + \ No newline at end of file diff --git a/src/views/Shop.php b/src/views/Shop.php index c1200bf..d3f3e0f 100644 --- a/src/views/Shop.php +++ b/src/views/Shop.php @@ -10,8 +10,7 @@ * @var string[] $selected_categories Array of selected categories * @var string $search_keyword keyword used to filter products * @var string $sort_option Sort By option selected by user - * @var int $current_page_number Current page number. - * @var int $total_pages Total number of pages + * @var string $pagination HTML code pagination */ use Steamy\Model\Product; @@ -40,69 +39,6 @@ function displayProduct(Product $product): void EOL; } - -/** - * Returns a query string that maintains all current query string parameters and page number. - * @param int $page_number - * @return string Query string link for page item - */ -function getPageItemLink(int $page_number): string -{ - // create a string with all past query parameters except page and url - unset($_GET['page']); - unset($_GET['url']); - - $link = '?' . http_build_query($_GET); - - // add page number as query parameter - $link .= '&page=' . $page_number; - - return $link; -} - -/** - * Prints page item in HTML format. - * - * @param int $current_page_number - * @param int $page_number Page number of page item - * @return void - */ -function displayPageItem(int $current_page_number, int $page_number): void -{ - $page_link = getPageItemLink($page_number); - $className = "page-item" . ($page_number === $current_page_number ? " active" : ""); - - echo <<< EOL -
  • - $page_number -
  • - EOL; -} - -/** - * Prints navigation button in HTML format - * @param int $current_page_number - * @param int $total_pages Total number of pages - * @param bool $is_left True indicates left navigation button. - * @return void - */ -function displayNavigationButton(int $current_page_number, int $total_pages, bool $is_left): void -{ - $page_link = getPageItemLink($current_page_number + ($is_left ? -1 : 1)); - $link_content = htmlspecialchars($is_left ? "<" : ">"); - $className = "page-item"; - - if (($current_page_number === 1 && $is_left) || ($current_page_number === $total_pages && !$is_left)) { - $className .= " disabled"; - } - - echo <<< EOL -
  • - $link_content -
  • - EOL; -} - ?>
    @@ -161,30 +97,7 @@ function displayNavigationButton(int $current_page_number, int $total_pages, boo - +