product/detail
Trang chi tiết một sản phẩm.
- Tệp giao diện:
product/detail.html - Đường dẫn:
/san-pham/{slug} — hoặc đường dẫn riêng của sản phẩm - Controller:
App\Modules\Product\ProductController::productDetailPageById
Ngoài những biến dưới đây, slot này còn nhận toàn bộ biến toàn cục.
Biến của trang
| Biến | Kiểu | Mô tả | Từ phiên bản |
|---|---|---|---|
product |
Product |
Sản phẩm đang xem. | 1.0 |
variants |
Variant[] |
Các phiên bản đang bán; dùng variant.id khi thêm vào giỏ. | 1.0 |
images |
Image[] |
Thư viện ảnh, ảnh đại diện xếp trước. | 1.0 |
product_categories |
Category[] |
Danh mục chứa sản phẩm; mỗi mục có url riêng (có thể null). | 1.0 |
attributes |
AttributeGroup[] |
Thông số kỹ thuật, gom theo nhóm. | 1.0 |
active_promotions |
Promotion[] |
Khuyến mãi đang áp dụng cho sản phẩm. | 1.0 |
related_products |
Product[] |
Sản phẩm liên quan (cùng danh mục). | 1.0 |
page_title |
string |
Tiêu đề SEO của trang. | 1.0 |
page_description |
string |
Mô tả SEO của trang. | 1.0 |
Mã đầy đủ
Toàn bộ tệp product/detail.html của bộ giao diện mặc định. Chép về templates/<bộ giao diện của bạn>/product/detail.html rồi sửa theo ý muốn.
<div class="container page-content">
<nav class="breadcrumb">
<a href="/">Trang chủ</a> /
{% if product_categories.size > 0 and product_categories[0].url %}
<a href="{{ product_categories[0].url }}">{{ product_categories[0].title }}</a> /
{% elsif product_categories.size > 0 %}
<span>{{ product_categories[0].title }}</span> /
{% else %}
<a href="/san-pham">Sản phẩm</a> /
{% endif %}
<span>{{ product.title }}</span>
</nav>
<div class="pdp-layout">
<div class="pdp-gallery">
{% if images.size > 0 %}
{% if images[0].media_type == "video" %}
<div class="pdp-gallery__placeholder" id="main-image"></div>
{% else %}
<img src="{{ images[0].url }}" alt="{{ images[0].alt_text | default: product.title }}"
class="pdp-gallery__main" id="main-image">
{% endif %}
{% if images.size > 1 %}
<div class="pdp-gallery__thumbs">
{% for img in images %}
{% if img.media_type == "video" %}
<div class="pdp-gallery__thumb {% if forloop.first %}pdp-gallery__thumb--active{% endif %}"></div>
{% else %}
<img src="{{ img.url }}" alt="{{ img.alt_text | default: product.title }}"
class="pdp-gallery__thumb {% if forloop.first %}pdp-gallery__thumb--active{% endif %}"
data-src="{{ img.url }}" onclick="document.getElementById('main-image').src=this.dataset.src">
{% endif %}
{% endfor %}
</div>
{% endif %}
{% else %}
<div class="pdp-gallery__placeholder"></div>
{% endif %}
</div>
<div class="pdp-info" x-init="setInterval(() => { nowTs = Date.now(); }, 1000)" x-data="{
variants: {{ variants | json | escape }},
selected: {{ variants | first | json | escape }},
qty: 1,
loading: false,
added: false,
nowTs: Date.now(),
get price() {
return xStore.utils.formatCurrency(this.selected?.price ?? 0);
},
get compareAtPrice() {
return this.selected?.compare_at_price
? xStore.utils.formatCurrency(this.selected.compare_at_price)
: null;
},
get hasFlash() {
return !!(this.selected && this.selected.flash);
},
get flashPrice() {
return this.selected?.flash
? xStore.utils.formatCurrency(this.selected.flash.deal_price)
: null;
},
get flashRemaining() {
return this.selected?.flash ? this.selected.flash.remaining : null;
},
get flashCountdown() {
const f = this.selected?.flash;
if (!f || !f.ends_at) return null;
const end = Date.parse(String(f.ends_at).replace(' ', 'T') + 'Z');
let diff = Math.floor((end - this.nowTs) / 1000);
if (diff <= 0) return '00:00:00';
const h = String(Math.floor(diff / 3600)).padStart(2, '0');
const m = String(Math.floor((diff % 3600) / 60)).padStart(2, '0');
const s = String(diff % 60).padStart(2, '0');
return h + ':' + m + ':' + s;
},
selectVariant(variant) {
this.selected = variant;
},
async addToCart() {
if (!this.selected || this.loading) return;
this.loading = true;
try {
await xStore.cart.addItem({ variant_id: this.selected.id, quantity: this.qty });
this.added = true;
setTimeout(() => { this.added = false; }, 2000);
} finally {
this.loading = false;
}
}
}">
{% if product.brand %}
<a href="/thuong-hieu/{{ product.brand.slug }}" class="pdp-brand">
{% if product.brand.logo_url %}
<img src="{{ product.brand.logo_url }}" alt="{{ product.brand.title }}" class="pdp-brand__logo">
{% else %}
<span class="pdp-brand__name">{{ product.brand.title }}</span>
{% endif %}
</a>
{% endif %}
<h1 class="pdp-title">{{ product.title }}</h1>
<div class="pdp-price" id="pdp-price">
<template x-if="hasFlash">
<span class="pdp-flash">
<span class="price price--flash" x-text="flashPrice"></span>
<span class="price price--original" x-text="price"></span>
<span class="badge badge--flash">⚡ Flash Sale</span>
<template x-if="flashCountdown">
<span class="pdp-flash__countdown">Kết thúc sau <strong x-text="flashCountdown"></strong></span>
</template>
<template x-if="flashRemaining !== null">
<span class="pdp-flash__remaining">Còn <strong x-text="flashRemaining"></strong> suất</span>
</template>
</span>
</template>
<template x-if="!hasFlash && compareAtPrice">
<span>
<span class="price" x-text="price"></span>
<span class="price price--original" x-text="compareAtPrice"></span>
<span class="badge badge--sale">Giảm giá</span>
</span>
</template>
<template x-if="!hasFlash && !compareAtPrice">
<span class="price" x-text="price"></span>
</template>
</div>
{% if product.short_description %}
<div class="pdp-short-desc">{{ product.short_description }}</div>
{% endif %}
{% if variants.size > 1 %}
<div class="pdp-variants">
<label class="form-label">Phân loại:</label>
<div class="variant-options">
{% for variant in variants %}
<button type="button"
class="variant-btn"
:class="{ 'variant-btn--selected': selected && selected.id === {{ variant.id | json | escape }} }"
@click="selectVariant({{ variant | json | escape }})">
{{ variant.title }}
</button>
{% endfor %}
</div>
</div>
{% endif %}
<div class="pdp-qty">
<label class="form-label">Số lượng:</label>
<div class="qty-control">
<button type="button" class="qty-btn" @click="qty = Math.max(1, qty - 1)">−</button>
<input type="number" x-model.number="qty" min="1" max="99" class="qty-input">
<button type="button" class="qty-btn" @click="qty = Math.min(99, qty + 1)">+</button>
</div>
</div>
<button type="button" class="btn btn--primary btn--lg btn--block"
:disabled="!selected || loading"
@click="addToCart()">
<span x-show="!loading && !added">Thêm vào giỏ hàng</span>
<span x-show="loading">Đang thêm...</span>
<span x-show="added">Đã thêm vào giỏ!</span>
</button>
{% if active_promotions.size > 0 %}
<div class="pdp-promotions">
<h4 class="pdp-promotions__title">Ưu đãi đang áp dụng</h4>
<ul class="promotion-list">
{% for promo in active_promotions %}
<li class="promotion-item">
<span class="promotion-item__icon">🏷</span>
<span class="promotion-item__title">{{ promo.title }}</span>
{% if promo.description %}
<span class="promotion-item__desc"> — {{ promo.description }}</span>
{% endif %}
{% if promo.detail_url %}
<a href="{{ promo.detail_url }}" class="promotion-item__link" target="_blank">Xem chi tiết</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if product_categories.size > 0 %}
<div class="pdp-meta">
<span class="pdp-meta__label">Danh mục:</span>
{% for cat in product_categories %}
{% if cat.url %}
<a href="{{ cat.url }}" class="pdp-meta__link">{{ cat.title }}</a>
{% else %}
<span class="pdp-meta__link">{{ cat.title }}</span>
{% endif %}{% unless forloop.last %}, {% endunless %}
{% endfor %}
</div>
{% endif %}
{% if product.tags.size > 0 %}
<div class="pdp-tags">
{% for tag in product.tags %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% if settings.show_specs != false and attributes.size > 0 %}
<section class="pdp-specs">
<h2 class="section__title">Thông số kỹ thuật</h2>
{% for group in attributes %}
<div class="spec-group">
<h3 class="spec-group__title">{{ group.group }}</h3>
<table class="spec-table">
{% for item in group.items %}
<tr>
<th class="spec-table__label">{{ item.title }}</th>
<td class="spec-table__value">{{ item.value }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endfor %}
</section>
{% endif %}
{% if product.description %}
<section class="pdp-description">
<h2 class="section__title">Mô tả sản phẩm</h2>
<div class="rich-text">{{ product.description }}</div>
</section>
{% endif %}
<section class="pdp-reviews" id="pdp-reviews" data-product-id="{{ product.id }}">
<h2 class="section__title">
Đánh giá sản phẩm
{% if product.review_count > 0 %}
<span class="review-summary">
{% assign full_stars = product.avg_rating | round %}
{% for i in (1..5) %}{% if i <= full_stars %}★{% else %}☆{% endif %}{% endfor %}
{{ product.avg_rating | default: 0 | round: 1 }} ({{ product.review_count }} đánh giá)
</span>
{% endif %}
</h2>
<div id="reviews-container"></div>
<div id="reviews-load-more" style="display:none;">
<button type="button" class="btn btn--outline" id="btn-more-reviews">Xem thêm đánh giá</button>
</div>
</section>
<script>
(function() {
var productId = document.getElementById('pdp-reviews').dataset.productId;
var cursor = null;
var container = document.getElementById('reviews-container');
var loadMoreBtn = document.getElementById('btn-more-reviews');
function starHtml(rating) {
var s = '';
for (var i = 1; i <= 5; i++) { s += i <= rating ? '★' : '☆'; }
return '<span class="stars">' + s + '</span>';
}
function escHtml(str) {
return (str || '').replace(/[&<>"']/g, function(c) {
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
});
}
function loadReviews() {
xStore.reviews.list({ entityType: 'product', entityId: parseInt(productId), cursor: cursor || null, limit: 10 })
.then(function(data) {
if (data.items.length === 0 && !cursor) {
container.innerHTML = '<p class="empty-state">Chưa có đánh giá nào.</p>';
return;
}
data.items.forEach(function(r) {
var div = document.createElement('div');
div.className = 'review-item';
div.innerHTML =
'<div class="review-item__header">' +
starHtml(r.rating) +
'<strong class="review-item__author">' + escHtml(r.reviewer_name || 'Ẩn danh') + '</strong>' +
'<span class="review-item__date">' + (r.created_at || '').slice(0,10) + '</span>' +
'</div>' +
(r.title ? '<p class="review-item__title">' + escHtml(r.title) + '</p>' : '') +
(r.body ? '<p class="review-item__body">' + escHtml(r.body) + '</p>' : '');
container.appendChild(div);
});
cursor = data.has_more ? data.next_cursor : null;
document.getElementById('reviews-load-more').style.display = cursor ? 'block' : 'none';
})
.catch(function() {
if (!cursor) container.innerHTML = '<p class="empty-state">Không thể tải đánh giá.</p>';
});
}
loadReviews();
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', loadReviews);
}
})();
</script>
{% if related_products.size > 0 %}
<section class="section section--related">
<h2 class="section__title">Sản phẩm liên quan</h2>
<div class="product-grid">
{% for product in related_products %}
{% include 'components/product_item' %}
{% endfor %}
</div>
</section>
{% endif %}
</div>