product/detail

Trang chi tiết một sản phẩm.

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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>