WebOrbiton
v1.0.0.1

StocketBase

471 lines · 25.4 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/includes/config.php';
  6. require_once __DIR__ . '/includes/database.php';
  7. require_once __DIR__ . '/includes/user-auth.php';
  8. require_once __DIR__ . '/includes/csrf.php';
  9. require_once __DIR__ . '/includes/language.php';
  10. require_once __DIR__ . '/includes/site-front.php';
  11. require_once __DIR__ . '/includes/block-editor.php';
  12. require_once __DIR__ . '/includes/tags.php';
  13. require_once __DIR__ . '/includes/auth.php';
  14. require_once __DIR__ . '/includes/currency.php';
  15. require_once __DIR__ . '/includes/digital-files.php';
  16. require_once __DIR__ . '/includes/product-stats.php';
  17. ​
  18. $previewId = (int) ($_GET['preview'] ?? 0);
  19. $isPreview = $previewId > 0;
  20. ​
  21. $db = Database::site();
  22. $settings = SiteFront::settings();
  23. ​
  24. $slug = trim((string) ($_GET['slug'] ?? ''));
  25. ​
  26. if (Languages::enabled() && !isset($_GET['stocketbase_lang']) && preg_match('#^([a-z]{2,3}(?:-[a-z]{2,4})?)/(.*)$#', $slug, $languageMatch) === 1 && in_array($languageMatch[1], Languages::activeCodes(), true)) {
  27. $_GET['stocketbase_lang'] = $languageMatch[1];
  28. Languages::resetCache();
  29. SiteFront::resetSettings();
  30. $settings = SiteFront::settings();
  31. $languageRest = trim($languageMatch[2], '/');
  32. ​
  33. if ($languageRest === '') {
  34. require __DIR__ . '/index.php';
  35. exit;
  36. }
  37. ​
  38. if (preg_match('/^[a-z0-9-]+\.php$/', $languageRest) === 1 && $languageRest !== 'product.php' && is_file(__DIR__ . '/' . $languageRest)) {
  39. require __DIR__ . '/' . $languageRest;
  40. exit;
  41. }
  42. ​
  43. $slug = $languageRest;
  44. $_GET['slug'] = $languageRest;
  45. }
  46. ​
  47. if ($isPreview) {
  48. Auth::boot();
  49. } else {
  50. UserAuth::boot();
  51. }
  52. ​
  53. $query = $isPreview
  54. ? "SELECT p.*, c.name AS category_name, c.slug AS category_slug, COALESCE(t.display_name, 'System') AS author_name
  55. FROM products p
  56. LEFT JOIN categories c ON c.id = p.category_id
  57. LEFT JOIN team_accounts t ON t.id = p.created_by
  58. WHERE p.id = :id LIMIT 1"
  59. : "SELECT p.*, c.name AS category_name, c.slug AS category_slug, COALESCE(t.display_name, 'System') AS author_name
  60. FROM products p
  61. LEFT JOIN categories c ON c.id = p.category_id
  62. LEFT JOIN team_accounts t ON t.id = p.created_by
  63. WHERE p.slug = :slug AND p.status = 'published' LIMIT 1";
  64. $statement = $db->prepare($query);
  65. $statement->execute($isPreview ? ['id' => $previewId] : ['slug' => $slug]);
  66. $product = $statement->fetch();
  67. ​
  68. if (!$product) {
  69. http_response_code(404);
  70. $pageTitle = Language::get('page_title_not_found', 'Not found') . ' - ' . $settings['site_name'];
  71. require __DIR__ . '/includes/front-header.php';
  72. echo '<p>' . htmlspecialchars(Language::get('product_not_found', 'Product not found.')) . '</p>';
  73. require __DIR__ . '/includes/front-footer.php';
  74. exit;
  75. }
  76. ​
  77. if ($isPreview) {
  78. if (!Auth::check()) {
  79. http_response_code(403);
  80. exit('Preview access requires team login.');
  81. }
  82. ​
  83. $currentTeamUser = Auth::user();
  84. $canPreview = $currentTeamUser !== null
  85. && ((int) $product['created_by'] === (int) $currentTeamUser['id']
  86. || Auth::hasRoleAtLeast(Auth::ROLE_STORE_OWNER)
  87. || Auth::role() === Auth::ROLE_CATALOG_ASSISTANT);
  88. if (!$canPreview) {
  89. http_response_code(403);
  90. exit('You do not have permission to preview this product.');
  91. }
  92. } else {
  93. $updateViews = $db->prepare('UPDATE products SET views_count = views_count + 1 WHERE id = :id');
  94. $updateViews->execute(['id' => $product['id']]);
  95. }
  96. ​
  97. $imagesStatement = $db->prepare('SELECT image_path, alt_text FROM product_images WHERE product_id = :id ORDER BY sort_order ASC, id ASC');
  98. $imagesStatement->execute(['id' => $product['id']]);
  99. $galleryImages = $imagesStatement->fetchAll();
  100. if (empty($galleryImages) && !empty($product['cover_image_path'])) {
  101. $galleryImages = [['image_path' => $product['cover_image_path'], 'alt_text' => null]];
  102. }
  103. ​
  104. $variantsStatement = $db->prepare('SELECT * FROM product_variants WHERE product_id = :id ORDER BY is_default DESC, sort_order ASC, id ASC');
  105. $variantsStatement->execute(['id' => $product['id']]);
  106. $variants = $variantsStatement->fetchAll();
  107. ​
  108. $productTags = ProductTags::isEnabled() ? ProductTags::forProduct((int) $product['id']) : [];
  109. ​
  110. $productFiles = DigitalFiles::filesForProduct((int) $product['id']);
  111. $productIsFree = DigitalFiles::isFree($product, $variants);
  112. $downloadLoginRequired = ($settings['require_account_to_purchase'] ?? '0') === '1';
  113. $downloadCustomer = $isPreview ? null : UserAuth::user();
  114. $customerOwnsProduct = !$productIsFree && !empty($productFiles) && $downloadCustomer !== null
  115. && DigitalFiles::customerOwnsProduct((int) $downloadCustomer['id'], (int) $product['id']);
  116. ​
  117. $productBuyersCount = ($settings['product_show_buyers'] ?? '0') === '1' ? ProductStats::buyersCount((int) $product['id']) : 0;
  118. $productDownloadsCount = ($settings['product_show_downloads'] ?? '0') === '1' && !empty($productFiles) ? ProductStats::downloadsCount((int) $product['id']) : 0;
  119. ​
  120. $reviewsDb = Database::users();
  121. $reviewsEnabled = ($settings['reviews_enabled'] ?? '1') === '1';
  122. ​
  123. $loadReviews = function () use ($reviewsDb, $product): array {
  124. $statement = $reviewsDb->prepare(
  125. "SELECT r.*, u.display_name FROM product_reviews r
  126. LEFT JOIN user_accounts u ON u.id = r.user_account_id
  127. WHERE r.product_id = :id AND r.status = 'visible'
  128. ORDER BY r.created_at DESC LIMIT 50"
  129. );
  130. $statement->execute(['id' => $product['id']]);
  131. ​
  132. return $statement->fetchAll();
  133. };
  134. ​
  135. $reviews = $loadReviews();
  136. $reviewError = null;
  137. $reviewSuccess = null;
  138. ​
  139. if (!$isPreview && $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'submit_review') {
  140. if (!$reviewsEnabled) {
  141. $reviewError = Language::get('review_disabled', 'Reviews are currently disabled.');
  142. } elseif (!UserAuth::check()) {
  143. $reviewError = Language::get('review_login_required', 'Please log in to leave a review.');
  144. } elseif (!Csrf::verify($_POST['csrf_token'] ?? null)) {
  145. $reviewError = Language::get('form_security_failed', 'Security check failed, please try again.');
  146. } else {
  147. $rating = (int) ($_POST['rating'] ?? 0);
  148. $body = trim((string) ($_POST['body'] ?? ''));
  149. ​
  150. $existingReview = $reviewsDb->prepare('SELECT id FROM product_reviews WHERE product_id = :product_id AND user_account_id = :user_id LIMIT 1');
  151. $existingReview->execute(['product_id' => $product['id'], 'user_id' => UserAuth::user()['id']]);
  152. ​
  153. if ($existingReview->fetch()) {
  154. $reviewError = Language::get('review_already_posted', 'You have already reviewed this product.');
  155. } elseif ($rating < 1 || $rating > 5) {
  156. $reviewError = Language::get('review_rating_required', 'Please select a star rating from 1 to 5.');
  157. } elseif ($body === '') {
  158. $reviewError = Language::get('review_body_required', 'Please write a short review.');
  159. } else {
  160. $insert = $reviewsDb->prepare(
  161. "INSERT INTO product_reviews (product_id, user_account_id, rating, body, status) VALUES (:product_id, :user_id, :rating, :body, 'visible')"
  162. );
  163. $insert->execute([
  164. 'product_id' => $product['id'],
  165. 'user_id' => UserAuth::user()['id'],
  166. 'rating' => $rating,
  167. 'body' => mb_substr($body, 0, 2000),
  168. ]);
  169. $reviewSuccess = Language::get('review_posted', 'Thanks — your review has been posted.');
  170. $reviews = $loadReviews();
  171. }
  172. }
  173. }
  174. ​
  175. $averageRating = 0.0;
  176. if (!empty($reviews)) {
  177. $ratedReviews = array_filter($reviews, static fn (array $review): bool => $review['rating'] !== null);
  178. if (!empty($ratedReviews)) {
  179. $averageRating = array_sum(array_map(static fn (array $review): int => (int) $review['rating'], $ratedReviews)) / count($ratedReviews);
  180. }
  181. }
  182. ​
  183. $relatedProducts = [];
  184. $latestProducts = [];
  185. ​
  186. if (($settings['related_products_enabled'] ?? '1') === '1') {
  187. if (!empty($product['category_id'])) {
  188. $relatedStatement = $db->prepare(
  189. "SELECT id, title, slug, cover_image_path FROM products WHERE status = 'published' AND category_id = :category_id AND id != :id ORDER BY published_at DESC LIMIT 4"
  190. );
  191. $relatedStatement->execute(['category_id' => $product['category_id'], 'id' => $product['id']]);
  192. $relatedProducts = $relatedStatement->fetchAll();
  193. }
  194. ​
  195. $latestStatement = $db->prepare(
  196. "SELECT id, title, slug, cover_image_path FROM products WHERE status = 'published' AND id != :id ORDER BY published_at DESC LIMIT 4"
  197. );
  198. $latestStatement->execute(['id' => $product['id']]);
  199. $latestProducts = $latestStatement->fetchAll();
  200. }
  201. ​
  202. $pricingModel = (string) ($settings['pricing_model'] ?? 'per_product');
  203. $isSaasPolar = (string) ($settings['store_category'] ?? 'general') === 'saas' && (string) ($settings['payment_provider'] ?? 'stripe') === 'polar';
  204. $currentUrl = SiteFront::productUrl((string) $product['slug']);
  205. $productCurrency = (string) ($product['currency'] ?: $settings['store_currency']);
  206. ​
  207. $pageTitle = ($product['seo_title'] ?: $product['title']) . ' - ' . $settings['site_name'];
  208. $pageDescription = $product['seo_description'] ?: $product['excerpt'];
  209. $ogType = 'product';
  210. $ogImagePath = !empty($product['cover_image_path']) ? $product['cover_image_path'] : null;
  211. $pageType = 'product';
  212. $pageTitle = $isPreview ? 'Preview - ' . $pageTitle : $pageTitle;
  213. ​
  214. require __DIR__ . '/includes/front-header.php';
  215. ​
  216. ?>
  217. <article class="article-view product-view">
  218. ​
  219. <?php if (($settings['breadcrumbs_enabled'] ?? '0') === '1'): ?>
  220. <nav class="article-breadcrumbs" aria-label="<?= htmlspecialchars(Language::get('breadcrumb_label', 'Breadcrumb')) ?>">
  221. <ol>
  222. <li><a href="index.php"><?= htmlspecialchars(Language::get('nav_home', 'Home')) ?></a></li>
  223. <?php if (!empty($product['category_name'])): ?>
  224. <li><a href="category.php?slug=<?= urlencode($product['category_slug']) ?>"><?= htmlspecialchars($product['category_name']) ?></a></li>
  225. <?php endif; ?>
  226. <li aria-current="page"><span><?= htmlspecialchars($product['title']) ?></span></li>
  227. </ol>
  228. </nav>
  229. <?php endif; ?>
  230. ​
  231. <?php if (!empty($product['category_name'])): ?>
  232. <a href="category.php?slug=<?= urlencode($product['category_slug']) ?>" class="article-category-link"><?= htmlspecialchars($product['category_name']) ?></a>
  233. <?php endif; ?>
  234. ​
  235. <h1 class="article-title"><?= htmlspecialchars($product['title']) ?></h1>
  236. ​
  237. <?php if ($averageRating > 0): ?>
  238. <div class="product-rating-summary" aria-label="<?= htmlspecialchars(str_replace('{rating}', number_format($averageRating, 1), Language::get('product_rating_aria', 'Average rating {rating} out of 5')), ENT_QUOTES) ?>">
  239. <?php for ($star = 1; $star <= 5; $star++): ?><?= Icons::icon('star', 'icon star' . ($star <= round($averageRating) ? ' star-filled' : '')) ?><?php endfor; ?>
  240. <span class="product-rating-count"><?= htmlspecialchars(number_format($averageRating, 1)) ?> (<?= count($reviews) ?>)</span>
  241. </div>
  242. <?php endif; ?>
  243. ​
  244. <?= SiteFront::renderShareControl($product) ?>
  245. ​
  246. <?php if (!empty($galleryImages)): ?>
  247. <div class="product-gallery">
  248. <?php foreach ($galleryImages as $galleryIndex => $galleryImage): ?>
  249. <img src="<?= htmlspecialchars($galleryImage['image_path']) ?>" alt="<?= htmlspecialchars((string) ($galleryImage['alt_text'] ?? '')) ?>" class="product-gallery-image<?= $galleryIndex === 0 ? ' product-gallery-image-active' : '' ?>" data-gallery-index="<?= $galleryIndex ?>">
  250. <?php endforeach; ?>
  251. </div>
  252. <?php endif; ?>
  253. ​
  254. <?php foreach (SiteFront::activeAds('product-page') as $productPageAd): ?>
  255. <?= SiteFront::renderAd($productPageAd) ?>
  256. <?php endforeach; ?>
  257. ​
  258. <div class="product-buy-box">
  259. <div class="product-price-row">
  260. <?php if (!empty($product['price_cents'])): ?>
  261. <span class="product-price"><?= htmlspecialchars(Currency::format((int) $product['price_cents'], $productCurrency)) ?></span>
  262. <?php if (!empty($product['compare_at_price_cents']) && (int) $product['compare_at_price_cents'] > (int) $product['price_cents']): ?>
  263. <span class="product-price-compare"><?= htmlspecialchars(Currency::format((int) $product['compare_at_price_cents'], $productCurrency)) ?></span>
  264. <?php endif; ?>
  265. <?php if (!empty($product['billing_interval']) && $product['billing_interval'] !== 'one_time'): ?>
  266. <span class="product-price-interval">/ <?= htmlspecialchars($product['billing_interval'] === 'year' ? Language::get('subscription_per_year', 'year') : Language::get('subscription_per_month', 'month')) ?></span>
  267. <?php endif; ?>
  268. <?php elseif ($productIsFree): ?>
  269. <span class="product-price"><?= htmlspecialchars(Language::get('product_free_label', 'Free')) ?></span>
  270. <?php endif; ?>
  271. </div>
  272. ​
  273. <?php if ($productBuyersCount > 0 || $productDownloadsCount > 0): ?>
  274. <div class="product-counters">
  275. <?php if ($productBuyersCount > 0): ?>
  276. <span class="product-counter"><?= Icons::icon('users', 'icon icon-sm') ?><?= htmlspecialchars(sprintf(Language::get('product_buyers_label', 'Buyers: %s'), number_format($productBuyersCount))) ?></span>
  277. <?php endif; ?>
  278. <?php if ($productDownloadsCount > 0): ?>
  279. <span class="product-counter"><?= Icons::icon('download', 'icon icon-sm') ?><?= htmlspecialchars(sprintf(Language::get('product_downloads_label', 'Downloads: %s'), number_format($productDownloadsCount))) ?></span>
  280. <?php endif; ?>
  281. </div>
  282. <?php endif; ?>
  283. ​
  284. <?php if (!$isPreview && !empty($productFiles) && ($productIsFree || $customerOwnsProduct)): ?>
  285. <div class="product-downloads">
  286. <?php if ($customerOwnsProduct): ?>
  287. <p class="product-downloads-owned"><?= Icons::icon('check', 'icon icon-sm') ?><?= htmlspecialchars(Language::get('product_owned_label', 'You own this product.')) ?></p>
  288. <?php endif; ?>
  289. <?php if ($productIsFree && $downloadLoginRequired && $downloadCustomer === null): ?>
  290. <a href="user-login.php?redirect=<?= urlencode('product.php?slug=' . rawurlencode((string) $product['slug'])) ?>" class="unlock-btn"><?= htmlspecialchars(Language::get('product_download_login', 'Log in to download')) ?></a>
  291. <?php else: ?>
  292. <?php foreach ($productFiles as $productFile): ?>
  293. <a href="<?= htmlspecialchars(DigitalFiles::downloadUrl((int) $productFile['id'])) ?>" class="unlock-btn product-download-btn" rel="nofollow">
  294. <?= Icons::icon('download', 'icon icon-sm') ?>
  295. <?php if (trim((string) ($productFile['button_label'] ?? '')) !== ''): ?>
  296. <span><?= htmlspecialchars(DigitalFiles::displayName($productFile)) ?></span>
  297. <?php else: ?>
  298. <span><?= htmlspecialchars(Language::get('product_download_button', 'Download')) ?> <?= htmlspecialchars($productFile['original_name']) ?></span>
  299. <?php endif; ?>
  300. <small><?= htmlspecialchars(DigitalFiles::formatSize((int) $productFile['size_bytes'])) ?></small>
  301. </a>
  302. <?php endforeach; ?>
  303. <?php endif; ?>
  304. </div>
  305. <?php endif; ?>
  306. ​
  307. <?php if (!empty($product['track_inventory'])): ?>
  308. <?php $stockQty = $product['stock_quantity'] !== null ? (int) $product['stock_quantity'] : null; ?>
  309. <div class="product-stock-status">
  310. <?php if ($stockQty !== null && $stockQty <= 0): ?>
  311. <span class="badge-out-of-stock"><?= htmlspecialchars(Language::get('product_out_of_stock', 'Out of stock')) ?></span>
  312. <?php elseif ($stockQty !== null && $stockQty <= 5): ?>
  313. <span class="badge-low-stock"><?= htmlspecialchars(sprintf(Language::get('product_low_stock', 'Only %d left'), $stockQty)) ?></span>
  314. <?php else: ?>
  315. <span class="badge-in-stock"><?= htmlspecialchars(Language::get('product_in_stock', 'In stock')) ?></span>
  316. <?php endif; ?>
  317. </div>
  318. <?php endif; ?>
  319. ​
  320. <?php if (!$isPreview && !$productIsFree): ?>
  321. <?php if ($isSaasPolar): ?>
  322. <form action="checkout.php" method="post" class="product-buy-form">
  323. <?= Csrf::field() ?>
  324. <input type="hidden" name="provider" value="polar">
  325. <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>">
  326. <button type="submit" class="unlock-btn" style="border:none;cursor:pointer;"><?= htmlspecialchars(Language::get('product_subscribe_polar_button', 'Subscribe via Polar')) ?></button>
  327. </form>
  328. <?php elseif ($pricingModel === 'subscription'): ?>
  329. <form action="checkout.php" method="post" class="product-buy-form">
  330. <?= Csrf::field() ?>
  331. <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>">
  332. <button type="submit" class="unlock-btn" style="border:none;cursor:pointer;"><?= htmlspecialchars(Language::get('product_subscribe_button', 'Subscribe')) ?></button>
  333. </form>
  334. <?php else: ?>
  335. <?php $outOfStock = !empty($product['track_inventory']) && $product['stock_quantity'] !== null && (int) $product['stock_quantity'] <= 0 && empty($variants); ?>
  336. <form action="cart.php" method="post" class="product-buy-form">
  337. <?= Csrf::field() ?>
  338. <input type="hidden" name="action" value="add">
  339. <input type="hidden" name="product_id" value="<?= (int) $product['id'] ?>">
  340. <input type="hidden" name="return_to" value="<?= htmlspecialchars($currentUrl, ENT_QUOTES) ?>">
  341. <?php if (!empty($variants)): ?>
  342. <label class="product-variant-label" for="variant_id"><?= htmlspecialchars(Language::get('product_variant_label', 'Options')) ?></label>
  343. <select name="variant_id" id="variant_id" class="product-variant-select">
  344. <?php foreach ($variants as $variant): ?>
  345. <option value="<?= (int) $variant['id'] ?>">
  346. <?= htmlspecialchars($variant['name']) ?><?= $variant['price_cents'] !== null ? ' — ' . htmlspecialchars(Currency::format((int) $variant['price_cents'], $productCurrency)) : '' ?>
  347. </option>
  348. <?php endforeach; ?>
  349. </select>
  350. <?php endif; ?>
  351. <label class="product-quantity-label" for="quantity"><?= htmlspecialchars(Language::get('product_quantity_label', 'Quantity')) ?></label>
  352. <input type="number" name="quantity" id="quantity" value="1" min="1" max="999" class="product-quantity-input">
  353. <button type="submit" class="unlock-btn" style="border:none;cursor:pointer;" <?= $outOfStock ? 'disabled' : '' ?>><?= htmlspecialchars(Language::get('product_add_to_cart_button', 'Add to cart')) ?></button>
  354. </form>
  355. <?php endif; ?>
  356. <?php endif; ?>
  357. </div>
  358. ​
  359. <?php
  360. $productHtml = BlockEditor::render($product['content_blocks']);
  361. ?>
  362. <div class="article-content product-content">
  363. <?= $productHtml ?>
  364. </div>
  365. ​
  366. <?php if (!empty($productTags)): ?>
  367. <div class="article-tags">
  368. <span class="article-tags-label"><?= htmlspecialchars(Language::get('product_tags_label', 'Tags:')) ?></span>
  369. <?php foreach ($productTags as $productTag): ?>
  370. <a href="tag.php?slug=<?= urlencode($productTag['slug']) ?>" class="article-tag"><?= htmlspecialchars($productTag['name']) ?></a>
  371. <?php endforeach; ?>
  372. </div>
  373. <?php endif; ?>
  374. ​
  375. </article>
  376. ​
  377. <?php if (($settings['related_products_enabled'] ?? '1') === '1' && (!empty($relatedProducts) || !empty($latestProducts))): ?>
  378. <section class="related-section">
  379. <?php if (!empty($relatedProducts)): ?>
  380. <h2><?= htmlspecialchars(Language::get('product_related', 'Related products')) ?></h2>
  381. <div class="article-grid article-grid-small">
  382. <?php foreach ($relatedProducts as $related): ?>
  383. <a href="<?= htmlspecialchars(SiteFront::productUrl($related['slug'])) ?>" class="product-card">
  384. <?php if (!empty($related['cover_image_path'])): ?>
  385. <img src="<?= htmlspecialchars($related['cover_image_path']) ?>" alt="" class="product-card-image">
  386. <?php endif; ?>
  387. <div class="product-card-body">
  388. <h3><?= htmlspecialchars($related['title']) ?></h3>
  389. </div>
  390. </a>
  391. <?php endforeach; ?>
  392. </div>
  393. <?php endif; ?>
  394. ​
  395. <?php if (!empty($latestProducts)): ?>
  396. <h2><?= htmlspecialchars(Language::get('product_latest', 'More recent products')) ?></h2>
  397. <div class="article-grid article-grid-small">
  398. <?php foreach ($latestProducts as $latest): ?>
  399. <a href="<?= htmlspecialchars(SiteFront::productUrl($latest['slug'])) ?>" class="product-card">
  400. <?php if (!empty($latest['cover_image_path'])): ?>
  401. <img src="<?= htmlspecialchars($latest['cover_image_path']) ?>" alt="" class="product-card-image">
  402. <?php endif; ?>
  403. <div class="product-card-body">
  404. <h3><?= htmlspecialchars($latest['title']) ?></h3>
  405. </div>
  406. </a>
  407. <?php endforeach; ?>
  408. </div>
  409. <?php endif; ?>
  410. </section>
  411. <?php endif; ?>
  412. ​
  413. <?php if ($reviewsEnabled && !$isPreview): ?>
  414. <section class="reviews-section">
  415. <h2><?= htmlspecialchars(Language::get('product_reviews_title', 'Reviews')) ?></h2>
  416. ​
  417. <?php if (empty($reviews)): ?>
  418. <p><?= htmlspecialchars(Language::get('product_no_reviews', 'No reviews yet.')) ?></p>
  419. <?php else: ?>
  420. <ul class="reviews-list">
  421. <?php foreach ($reviews as $review): ?>
  422. <li class="review-item">
  423. <div class="review-header">
  424. <strong><?= htmlspecialchars((string) ($review['display_name'] ?? 'Customer')) ?></strong>
  425. <?php if ($review['rating'] !== null): ?>
  426. <span class="review-stars">
  427. <?php for ($star = 1; $star <= 5; $star++): ?><?= Icons::icon('star', 'icon star' . ($star <= (int) $review['rating'] ? ' star-filled' : '')) ?><?php endfor; ?>
  428. </span>
  429. <?php endif; ?>
  430. <span class="review-date"><?= htmlspecialchars(date('F j, Y', strtotime($review['created_at']))) ?></span>
  431. </div>
  432. <?php if (!empty($review['body'])): ?>
  433. <p class="review-body"><?= nl2br(htmlspecialchars($review['body'])) ?></p>
  434. <?php endif; ?>
  435. </li>
  436. <?php endforeach; ?>
  437. </ul>
  438. <?php endif; ?>
  439. ​
  440. <?php if ($reviewError !== null): ?>
  441. <p class="review-error" style="color: var(--danger);"><?= htmlspecialchars($reviewError) ?></p>
  442. <?php endif; ?>
  443. <?php if ($reviewSuccess !== null): ?>
  444. <p class="review-success" style="color: var(--success);"><?= htmlspecialchars($reviewSuccess) ?></p>
  445. <?php endif; ?>
  446. ​
  447. <?php if (UserAuth::check()): ?>
  448. <form method="post" class="review-form">
  449. <?= Csrf::field() ?>
  450. <input type="hidden" name="action" value="submit_review">
  451. <label><?= htmlspecialchars(Language::get('product_review_rating_label', 'Your rating')) ?></label>
  452. <div class="review-rating-input">
  453. <?php for ($star = 5; $star >= 1; $star--): ?>
  454. <label aria-label="<?= htmlspecialchars(sprintf(Language::get('review_star_label', '%d / 5'), $star)) ?>">
  455. <input type="radio" name="rating" value="<?= $star ?>" required>
  456. <?= Icons::icon('star', 'icon') ?>
  457. </label>
  458. <?php endfor; ?>
  459. </div>
  460. <label for="review-body"><?= htmlspecialchars(Language::get('product_review_body_label', 'Your review')) ?></label>
  461. <textarea name="body" id="review-body" rows="4" maxlength="2000"></textarea>
  462. <button type="submit" class="unlock-btn" style="border:none;cursor:pointer;"><?= htmlspecialchars(Language::get('product_review_submit_button', 'Submit review')) ?></button>
  463. </form>
  464. <?php else: ?>
  465. <p><a href="user-login.php?mode=login"><?= htmlspecialchars(Language::get('product_review_login_prompt', 'Log in to leave a review')) ?></a></p>
  466. <?php endif; ?>
  467. </section>
  468. <?php endif; ?>
  469. ​
  470. <?php require __DIR__ . '/includes/front-footer.php'; ?>
  471. ​