---
title: "Changelog"
url: https://phatdev.net/en/docs/tandoanh-theme/changelog
lang: en
updated: 2026-10-20
alternate:
  vi: https://phatdev.net/docs/tandoanh-theme/changelog
markdown: https://phatdev.net/en/docs/tandoanh-theme/changelog.md
---

# Changelog

Part of the doc set: [Tân Doanh](https://phatdev.net/en/docs/tandoanh-theme.md)

> All notable changes to the Tân Doanh theme. Each working day is grouped into a single version.

## CHANGELOG - Tân Doanh Theme

All notable changes to the theme are recorded here. Convention: each working day is grouped into a single version `[MAJOR.MINOR.PATCH]`, and small edits made on the same day go into that same entry.

## [1.9.54] - 2026-10-07
### Changed · Lighter mobile hero images (homepage)
- Registered 2 dedicated sizes, `td-hero-m-640` and `td-hero-m-768` (5:4 ratio, no crop), in `inc/hero-banner-art.php`, generated ONLY for images named `td-hero-mobile-*` (other images do not spawn extra files). Previously a 412px device at DPR 1.75 (Moto G Power, Lighthouse mobile) needed about 721px and so fell back to the 960w version (44 KB webp); it now picks 768w (about 31 KB). The `srcset` of the `<source>` and the LCP preload both read the same metadata, so they always match and nothing is downloaded twice.
- The 4 mobile hero images (ID 3000915-3000918) got extra 640 and 768 versions via `wp media regenerate --only-missing`; the webp files are produced by Converter for Media (the plugin's default quality).

## [1.9.53] - 2026-10-06
### Added · Dedicated phone banner images (homepage hero)
- Block `tandoanh/hero-banner`: each slide now has an extra phone image (`mobileImageUrl`, `mobileImageId`, `mobileImageWidth`, `mobileImageHeight`). At <= 600px the browser loads this image through `<picture><source media="(max-width: 600px)">` instead of the 1920x700 wide image (which used to be cropped on both sides, losing text and the comparison table on banners 3-4). Slides without a phone image keep the old behavior. The editor has a "Ảnh riêng cho điện thoại" (Dedicated phone image) field for each slide (choose, replace, remove).
- The mobile frame follows the image ratio (5:4, `--td-m-ar`), with `width`/`height` on the `<source>` and CSS `aspect-ratio`, so the layout does not shift (CLS 0). Slide 1 is not lazy and uses `fetchpriority="high"`; slides 2+ load late as before (`data-td-srcset` is expanded in theme.js).
- The LCP image is preloaded by width (`inc/perf.php`): at <= 600px it preloads the phone image, above 600px the wide image, never both. The shared function lives in `inc/hero-banner-art.php`.
- The 4 phone images (1080x864) were re-laid out from the 4 original banners, uploaded to the Media Library (ID 3000915-3000918) and assigned in the homepage post_content.
### Changed · Fixes from code review round 3 on the homepage
- Product names on homepage cards now take up to 3 lines (before: 2 lines truncated with `...` on 22/24 mobile cards and 37/55 desktop cards; after: 16 and 4), with the height locked so prices and buttons still line up.
- Build PC on the homepage: component names take up to 2 lines instead of 1 line truncated with `...`.
- Search box in the header: placeholder text uses the theme's secondary text color, not italic, and meets AA contrast (the tandoanh-code plugin forces #999 italic with `!important`, so the theme overrides it on purpose).

## [1.9.50] - 2026-10-06
### Fixed · Homepage after cross-review 68 (HR-01..HR-14)
- Customer reviews (HR-01/02): the `tandoanh/reviews` block excludes ALL AI-generated reviews (meta `by_ai`/`by_auto` = 1, including AI reviews generated separately on staging) from both the quotes and the score/count. The score and review count are computed live from approved non-AI reviews that have a star rating (on staging: 155 reviews, 4.8/5). Quotes: 5 stars, 70-260 characters, newest first, and the `verified` condition is gone. The "Đã mua:" (Purchased:) label shows only when the reviewer's email or account matches a completed order containing that exact product; otherwise it says "Đánh giá cho:" (Review for:). The transient cache key is versioned by theme version.
- Review sync: brings the live site's AI flag over to the test site (9th field `by_ai`, sets the `by_ai`/`by_auto` meta on the mirrored copy, which was previously lost); a flag-only mode sets the flag without adding or deleting reviews.
- Newsletter (HR-04): added one consent line under the form, "By subscribing, you agree to receive promotional emails from Tân Doanh and the Privacy Policy" (linking to page ID 8780), with no mandatory checkbox.
- HR-05: removed the 60px/36px padding-bottom of `main.td-home` (the empty strip between the newsletter and the footer).
- Homepage Build PC (HR-06): the preset row moved to the top of the whole block (spanning 2 columns), and the 3 metrics moved down to the right column under the button => the 2 columns are nearly equal in height, and the ~330px gap is gone. Homepage only.
- Hero (HR-07): removed the 560px height cap (it cut off the last row of the Windows table on slide 4 and the top of the tower on slide 1 at 1920); the cap is now 700px = the original image size of 1920x700.
- Touch targets / contrast (HR-09/10/14): footer contact links on mobile are 40px tall; 4px gap between mobile slider dots; the "·" separator in the top bar is aria-hidden, with contrast >= 4.5:1.
- Product cards (HR-12): the "Góp ...đ /tháng" (Installment ...đ /month) line is always on 1 line (nowrap), so it no longer wraps on mobile.
- Product names (HR-11): `inc/display-title.php` converts long dashes to "-" at the display layer (the_title of products, WooCommerce product names), frontend only; admin, REST, WP-CLI and cron are skipped; the DB is not modified.

## [1.9.49] - 2026-10-06
### Changed · 4 policy pages aligned with tandoanh.vn (A15)
- `templates/page-chinh-sach-van-chuyen.html`: removed the description sentence in the contact box ("...đóng gói an toàn cho Quý khách"), keeping the title + hotline/email/address cards.
- `templates/page-chinh-sach-bao-hanh.html`: removed the sentence "Vui lòng gọi trước khi gửi hàng để được hướng dẫn chi tiết" in the CTA, keeping the address + hotline + email.
- Table of contents on policy pages: a standard block (group + paragraph + list, className `tdp-policy__toc*`, no inline style), with CSS added at the end of `policy.css`, `page-chinh-sach-van-chuyen.css` and `page-chinh-sach-bao-hanh.css` (chips, theme color tokens, dark mode works automatically).
- The content of the 4 pages (post_content) was rewritten to match prod: hero facts and sentences not found on prod were removed. Opened in the editor: 0 invalid blocks.

## [1.9.48] - 2026-10-06
### Changed · Homepage redesign (audit D-10..D-18) + BP-15
- Section headings now have one single style: a vertical yellow bar + sans H2 + a "Xem tất cả" (View all) text link; the mono [bracket] label and icon box are gone; the rule lines match the container (they used to extend 24px on each side). Only 1 eyebrow remains on the page (Build PC).
- Featured zones (D-10): the 8 neon cards in 8 colors became charcoal cards with one yellow accent (the SVG background art is kept, filtered to monochrome yellow), 22px name text, 13px description, and a 36px round arrow chip as the button (the text "Khám phá ngay" (Explore now) is kept for screen readers); no more 3-line wraps or 7.5px text. White Zone keeps its warm light card.
- Featured categories (D-14): a 4-column grid from 1024, 2 columns below that, horizontal cards (flat yellow icon + name + product count), with the slider/arrows removed. The product count is computed LIVE from the link (matching 8/8 against the number at the top of each category page; the old hand-typed numbers were off, VGA showed 771 vs 734). `registry/cats/render.php`.
- Customer reviews (D-11): the `tandoanh/reviews` block pulls REAL reviews from WooCommerce (5 stars, verified orders, one per customer, shortened name, with the purchased product shown); the average score heading and review count are computed live (4.8/5 from 186). Removed the hand-typed figures and testimonials ("4.9/5 from over 12,000 customers", 4 unsourced customers). The `manual` attribute keeps a sourced manual-entry path. Mobile uses 1 column, with quotes up to 4 lines.
- Newsletter: removed the sentence "Hơn 15.000 khách đã đăng ký" (Over 15,000 customers have subscribed), which had no source; the form previously saved nothing, and now stores the email in `wp_td_promo_subscribers` (source `home`) through the REST route `tandoanh/v1/subscribe` (honeypot, limit of 5 per 10 minutes per IP, identical response for new and existing emails).
- Hero: on mobile the height is 208px (previously ~142px), cropped around each slide's focal point; on desktop 1920 it is capped at 560px (previously 702px); slider dots have a 40px touch area on mobile and a border so they show on light images. The scrolling text strip (ticker) is NOT changed (decided in A12). The 57px white gap between the ticker and the benefits strip is gone.
- TOP best sellers: a price of 0 shows "Liên hệ" (Contact) (previously "0đ"), and the title link becomes a 40px chevron on mobile; `TD_PRICE_FMT_VER` was raised to 3 so the old cache is dropped automatically.
- Build PC on the homepage (D-18): eyebrow/labels lose the brackets and "//", prices use the display font instead of mono, secondary text is >= 12px, and the last button aligns with the column bottom. The `bbfull_*` cache was selectively UNLINKed.
- BP-15: Account/Cart labels are 12px, the cart badge is 12px, the footer payment chips are 12px, and the installment line and discount label on product cards are 12px; mobile touch targets are 40px for header buttons (pseudo-element), footer social links are 40px, and footer policy links are 40px tall.
- New file `assets/css/home-redesign.css` (homepage only, loaded after system.css, with no purged version); `system.css` gained a 1.9.48 block.

## [1.9.47] - 2026-10-06
### Added · Flatsome shortcode compatibility layer
- `inc/flatsome-compat.php`: old posts/products/pages from the Flatsome theme no longer show raw shortcodes. It registers (only when they do not already exist) `[row]` `[col]` `[row_inner]` `[col_inner]` `[section]` `[ux_banner]` `[text_box]` `[ux_text]` `[ux_image]` `[button]` `[gap]` `[divider]` `[title]` `[ux_html]` `[ux_slider]` `[ux_products]` `[tabgroup]/[tab]` `[ux_video]` `[featured_box]` `[message_box]` `[ux_price_table]` `[bullet_item]` `[blog_posts]` `[block id=]` (the `blocks` CPT is non-public, with loop protection). `[lightbox]` and `[page_header]` drop their content.
- Unsupported Flatsome tags (`ux_*`, `scroll_to`, `follow`...) have the tag stripped and the content kept. Shortcodes from other plugins are untouched. A responsive 12-column grid follows `span`/`span__md`/`span__sm`, show/hide follows `visibility`, and inline CSS is output once, only on pages that contain shortcodes (dark/light by token).
- Cleans up orphaned `<p>`/`<br>` left by wpautop around blocks; normalizes curly quotes inside Flatsome tags.

## [1.9.46] - 2026-10-06
### Changed · Sidebar: the "Danh mục" (Categories) group as a navigation list
- Direct subcategories now appear as a vertical list (no longer chips like the filter groups): each row is one full-width link at least 38px tall, with the full name on the left (wrapping if long), and on the right a pale gray rounded badge with the product count plus an SVG chevron; thin dividers between rows; on hover a very light yellow background + bolder text + the chevron shifts 3px; a clear focus-visible state. The 250px scrolling frame inside the group is kept, with an icon + uppercase title; dark mode follows the tokens; the mobile filter sheet uses the same form. No "Tất cả ..." (All ...) row is added (it duplicates the breadcrumb and is a self-link to the page being viewed). The filter groups keep chips.

## [1.9.45] - 2026-10-06
### Fixed · PDP after re-check 62
- PDP breadcrumb at 390: the `hidden` attribute was overridden by `.td-bc-i {display:inline-flex}`, so the middle level did not hide; added `[hidden]{display:none!important}`, and the JS now collapses the middle level even when the last item is squeezed below 140px, with no horizontal scroll in the nav.
- H1: the " - " / " | " separators stick to only the single word right after them (nbsp after the mark); U+2060 and `text-wrap: pretty` were removed; U+2011 is used only for short compound words (Type-C), so the first line fills again.
- 720-1279px: the commitments card + policy links move below the gallery (left column). At >=1280: extra height is split 1:2 between Commitments : Consulting; the footer leaves room so the FAB does not cover the DMCA badge.

## [1.9.44] - 2026-10-06
### Fixed · cross-review 58 (whole site)
- GR-02: added a price-format version `td_price_fmt_ver()` / `TD_PRICE_FMT_VER` (inc/woo/price-format.php) to the keys of HTML caches that contain prices: group `td_blocks` (`topcol_*`, `psec_*`, `bbfull_*`), transients `td_topcol_*`, `tandoanh_toprow_*`, and the homepage section cache. To change the price format, just raise this number and the old cache is dropped automatically. The homepage no longer shows "2,290,000₫".
- GR-07: removed the 302 redirect from out-of-range `/page/N/` to page 1; it now returns 404 like production and hp (`inc/woo/woocommerce.php`).
- GR-03/04: the mobile Sort button fits on 1 line (the Filter button no longer takes its space, the short label duplicating the pill is gone, and at <=340px only the value remains); the selected item in the Sort sheet uses primary text color + a light yellow background + a left bar + a check mark (meets AA, previously 1.68:1).
- GR-09: the button system moved to a 40/48 scale (`base.css`, section "Thang chiều cao nút" (Button height scale)): the Submit comment button (no more blue #0046be; brand yellow, 40px, 8px radius), empty cart 48px, CF7 submit 48px, the Subscribe field + button 48px, homepage Build PC (button 48, pill 40), "Xem thêm" (See more) 40px, and the mobile Filter/Sort buttons 48px. Not touched: PDP buttons (Gọi ngay, tabs, PDP Build PC, See more content), and the mobile header icons at 38/34 (deliberate, so 7 icons fit in 360px).
- GR-08: the contact FAB steps back when scrolling down (<1200px) and returns on scroll up; the content column (grid + pagination) leaves 72px at the bottom (inline transform untouched).
- GR-05: the search page has a breadcrumb "Trang chủ > Kết quả tìm kiếm cho ..." (Home > Search results for ...) using the shared component (shortcode `[td_search_crumb]` in `templates/search.html`, 16/16 rhythm; added a `--td-gutter` side margin for `.td-search-page` at <=1344px, where the content previously touched the screen edge).
- Data: on the Shipping policy page (ID 8772), retail customers inside the city went from "8h" to "24h", and outside the city from "24h" to "24h-48h", to match the live site.

## [1.9.43] - 2026-10-06
### Fixed
- PDP at >=768px: removed the duplicate `padding-inline` of `.pdp-container` (main.td-pdp already has it); the breadcrumb and content now line up with the container edge like on category pages (previously 23px off on each side at 768).

## [1.9.42] - 2026-10-06
### Fixed · PDP after cross-review 60
- PF-01: `.desc-layout` at <=900px gets `align-items:stretch` + `min-width:0` (the Canon PIXMA G1010 no longer overflows horizontally by 56px at 390). PF-02: dark mode forces inline white backgrounds in short/long descriptions to transparent (~40 products), with no DB edits.
- PF-03: extra height is shared between both right-column cards (Commitments + Consulting, each space-between). PF-04: the H1 uses U+2060 after " -" / " |" and U+2011 for hyphens inside words, display layer only (preferring no bad breaks over the 90% mark).
- PF-05: the breadcrumb does not scroll horizontally; the middle level collapses to "…" on overflow (keeping Home + the nearest parent + the last item), and the last item has a real ellipsis (`.td-bc-t`). Shared component (system.css + theme.js), also applied to category/shop pages.
- PF-06: the PDP chat FAB at 1280-1599px moves bottom/right (transform untouched). PF-07: the Directions button has min-height 44px. Build PC is lighter (very light yellow background, solid 1px yellow border, dark mode uses the surface background). The sticky CTA is on for tablets <=1279px. Gallery at 720-1279px sticky top 84. The long-description collapse has a 320px floor. The copyright note (Software) is moved outside the condition that requires a short description (PF-12). Zalo stays 0783666668 (matches prod tandoanh.vn).

## [1.9.41] - 2026-10-06
### Changed · PDP: Similar products favor in-stock, even spacing, Directions button, long description sized to the recently viewed block
- `inc/woo/woocommerce.php`: "Sản phẩm tương tự" (Similar products) is ordered in stable groups (1 = in stock + price > 0, 2 = pre-order with a price, 3 = out of stock/discontinued/price 0), with a pool of 25 candidates cut to 5; shuffle is off and `order=asc` (WC's default reverses it). One meta query covers the whole pool (~10-25 ms). Upsells/cross-sells are not on the theme's PDP, so they do not apply.
- `content-single-product.php`, `single-product.css`: the buy block card and the Consulting/showroom card at >=1280px use flex + `space-between`, with EVERY part as a direct child and equal gaps between parts (measured on the AMD CPU page: 14px for every gap; consulting card 8px); the short description + See more button group is one part (`.pdp-sd-group`); the address and hours are 2 separate parts; the "Chỉ đường tới showroom" (Directions to the showroom) button is full width (44px, yellow border); the 3 contact tiles have icon frames of 40x40 with a 10px radius, kept consistent; removed the divider under the last policy item; fixed a stray `}` that broke the CSS after it.
- Long description: collapses to the height of the "Sản phẩm vừa xem" (Recently viewed) block in the 2-column layout (the See more button bottom is within 8px of the 5 products; floor of 480px when there are few or none), updated on resize or when the recently viewed block changes; Collapse scrolls back to the top of the tab.

## [1.9.40] - 2026-10-06
### Changed · PDP: consulting/showroom in the right column, short description above the buy button, Build PC button, review fixes
- Right column: Commitments (filtered by real sources: "1 đổi 1" (1-for-1 exchange) is hidden for CPU/Printer/Meeting room, replaced with "Đổi trả theo chính sách" (Returns per policy) through the filter `tandoanh_pdp_no_exchange_cats`; "Cam kết chính hãng" (Genuine guarantee) only when the product has a brand like the "Chính hãng" badge), 4 policy links, and the "Tư vấn và showroom" (Consulting and showroom) block (3 tiles Zalo / Messenger / Gọi ngay (Call now) using real logos `assets/img/contact/*`, address + hours with nbsp + "Chỉ đường" (Directions) with an icon). Below 1280px this block sits right after the buy block.
- Middle column: the short description sits below the price, above the button row (price > description > buttons spaced evenly at 16px); the "Build PC với linh kiện này" (Build PC with this component) button (dark background, dashed yellow border, icon chip, sub-line) is full width of the buy block; "Cam kết bản quyền" (Copyright commitment) only shows in the Software category; H1 22px (mobile 19px) with `text-wrap: pretty`, and the " - " / " | " separators stick to words using nbsp (display layer only).
- `td_trim_rich_whitespace()` (inc/woo/pdp.php, with tests 9/9): trims empty paragraphs/nbsp/br at the start and end of the short description, merges consecutive empty paragraphs in the middle, and trims only the start/end of the long description; the DB is not modified.
- The server hook `wp_loaded` 15 now also covers the plain "Thêm vào giỏ" (Add to cart): a variation clicked early before WC's JS has loaded (variation_id=0) is matched automatically by `attribute_*`, and if there is no match a friendly notice is shown + back to the PDP (PR-01).
- The mobile sticky CTA shows while the buy button is not FULLY inside the screen (threshold 1). "Sản phẩm vừa xem": shows up to 5 (stores 6), with the standard price "1.390.000đ" even for old data in localStorage.
- The PDP top row at >=1280px stretches: gallery | buy block | right column have bottoms aligned (measured at 1280/1440/1920: 0px offset, including products with a long collapsed 360 description and variations); the consulting card stretches to the bottom, "Chỉ đường"/Build PC stick to the card bottom; "Sản phẩm vừa xem" sits outside this row. Below 1280/mobile it stacks naturally.
- CSS: tables in descriptions scroll horizontally (no more 12px overflow at 390), the right-column card border is ~1.5:1 by token, brand tag/SKU 12px, and the old breadcrumb rule was removed (now in system.css).

## [1.9.39] - 2026-10-06
### Changed · PDP: slimmer right column, information gathered into the middle column
- `woocommerce/content-single-product.php`, `single-product.css`: the right column now holds only "Cam kết của Tân Doanh" (Tân Doanh's commitments, 4 short lines) + 4 policy links (returns, warranty, shipping, inspection); it is 354px tall (previously 1237px) at 1280/1440/1920. The showroom (address + hours of the Business group from `td_biz()` + a Google Maps "Chỉ đường" link) becomes one small line under the "Cần tư vấn nhanh?" (Need quick advice?) row; "Build PC với linh kiện này" becomes a compact link under the showroom line (component products only); "Cam kết bản quyền" becomes a 13px note under the specifications list.
- `single-product.css`: removed the old flat breadcrumb rule (it was already overridden with !important by `system.css`); the 16/16px breadcrumb spacing is unchanged at 390/768/1440.

## [1.9.38] - 2026-10-06
### Changed · PDP: collapsed short description, aligned button row, third narrow column
- Short description (`woocommerce/content-single-product.php`, `single-product.css`): capped at 360px height by CSS from the start (no CLS); JS only adds `.is-fits` when the content is short (no button) or `.is-collapsible` (fade to `--td-surface` + a "Xem thêm" (See more)/"Thu gọn" (Collapse) button, a rotating SVG chevron, `aria-expanded`/`aria-controls`). Applies at every viewport (previously desktop only, with 300/380px thresholds), re-measured when content or fonts change or on resize, and Collapse scrolls back to the top of the block if it has drifted off screen.
- Buy button row: removed the leftover ~12px margin-right on the Add to cart button, the 2 buttons split evenly with a 10px gap, and the left/right edges match the installment bar, divider line and form at 0px (measured at 390/768/1024/1280/1440/1920; simple and variable products).
- Third narrow column (`.pdp-side` becomes a grid child): at >=1280px gallery | buy block | 280px column; at 720-1279px it sits below the buy block; on mobile after the buy block. Not sticky. Real content: commitments + links to the 4 existing policy pages (returns, warranty, shipping, inspection, `tandoanh_pdp_policy_links()`), consulting + showroom from `td_biz()` with a Google Maps directions link built from the real address, and a "Build PC với linh kiện này" block only for components that belong to a step builder (bridge `?td_parts=`, `tandoanh_pdp_build_pc_url()`).

## [1.9.37] - 2026-10-06
### Changed · WHOLE-site redesign + shop/category pages
- D-02 button system: `tokens.css` adds the `--td-btn-h` scale 40 / `--td-btn-h-lg` 48 / `--td-btn-h-sm` 32, with radius `--td-radius` 8px (controls) and `--td-radius-lg` 12px (cards); `base.css` rewrites `.td-btn` into 3 tiers (flat gold primary with no gradient or shine sweep, secondary = `--ghost` outline, text) + danger, with full hover/focus-visible/active/disabled states; default WooCommerce buttons follow the same scale. `assets/css/system.css` (new, loaded last) harmonizes the search box, lang/theme/burger (40px from 768, mobile keeps small icons), the Categories + Build PC meganav buttons (flat gold, no glow/wiggle), "Xem tất cả" (View all), "Xem thêm" (See more), the homepage mini-builder, and the cart.
- D-03: mono type remains only for SKUs; labels/buttons/chips/badges/tabs/prices use the main font, and wide-tracking uppercase is removed from shared components.
- D-04 `inc/woo/price-format.php`: prices display in a single style, "1.890.000đ" (filter `wc_price_args` + `woocommerce_currency_symbol`, frontend only, AJAX from the frontend, and emails; WooCommerce settings are unchanged, admin/REST untouched); the gift packaging fee in `form-checkout.php` uses the same style.
- D-01 `inc/float-contact.php`: the cluster of 5 separate buttons became 1 round gold button (all sizes) that opens a list: Call Sales 1, Sales 2, Messenger, Zalo, email; the confirmation popup uses the standard button; on pages with a sticky CTA (body.td-scta-on) the FAB sits above that bar; the footer leaves bottom room.
- D-06 "just bought" toast: the customer name keeps only the first letter ("N***"), shows for 5 seconds, at least 60 seconds apart, up to 3 times per session, with z-index below the header/sticky/modal, hidden when a popup or filter is open, and on mobile placed above the sticky CTA (CSS in system.css).
- D-05: product card `woocommerce/content-product.php` + `woo-card.css`: empty stars removed when there are no reviews, a price of 0 = a "Gọi tư vấn" (Call for advice) button (no more "Mua ngay"), names locked to 2 lines so prices/buttons line up, one badge system (discount in red with white text; other labels gold with a border), price in tabular sans using `--td-price`, the installment line may wrap, 40px button + 40x40 cart.
- D-07: the logo keeps its 280:89 ratio (not squeezed by flex), 34px tall on mobile. D-08: in dark mode the product image box uses a neutral `#e8e8eb` background + `filter: brightness(.91)` via the tokens `--td-img-bg/--td-img-filter`.
- D-19/D-22/D-23 `archive-product.css` + `inc/woo/woocommerce.php` (category layout following hp, in TD yellow): category hero (`[td_shop_hero]`: eyebrow, display H1, real description, 3 commitments, 34px subcategory pills, collapsed to ~2 rows + "Xem thêm (n)" (See more) / "Thu gọn" (Collapse)); a sort bar in its own frame (`[td_shop_sortbar]` via the hook `woocommerce_before_shop_loop`@5: Sort pill + count, 32px pills with a touch area >= 40px, AJAX without reload); the filter sidebar groups use `<details>` with an icon + underlined uppercase title + 32px chips at 12px text (font sizes taken from hp: title 14.5px, chip 12px, sort label 12.5px, count 12px). Each filter group is at most 250px tall and scrolls inside the group (thin scrollbar, fade shadow at the bottom, reachable by tab + focus-visible, also applied to the mobile bottom sheet). The sidebar is NOT sticky, does not scroll on its own, and moves with the page. The "Danh mục" (Categories) group (chip links, direct subcategories only, hidden when none; /shop shows level 1) sits above "Thương hiệu" (Brands).
- Shared breadcrumb (`system.css`): a thin-bordered frame, the current level as a pale yellow chip, SVG chevrons, on mobile it scrolls horizontally and auto-scrolls to the end, the last chip has an ellipsis + title; the tokens `--td-bc-gap-top/bottom` (16px) keep top/bottom spacing consistent across the shop, category and PDP pages. PDP: Home > parent category > main category > product name chip (a single Rank Math schema).
- D-20/D-21: the mobile Sort + Filter bars are both 44px with the same radius, the hollow gold dot is removed; the grid has 3 columns from 700px (2 columns below 700, with gaps that do not touch the edge).
- D-26 pagination: `td_pagination_html()` is shared by the template and AJAX: 40px buttons with an 8px radius, the current page in gold, Previous/Next with chevrons (disabled at the ends), and on mobile "‹ 1 / n ›"; a page beyond the last (/page/999) redirects with 302 to the first page of the list.
- D-27: 2-column mobile cards get even padding, and the installment price wraps without touching the edge.
- A11y: product names on cards in shop/category/AJAX are H2 (below H1, no level skipping; the homepage and single pages keep H3); the mobile sort sheet is `visibility:hidden` when closed; Lighthouse accessibility 100.
- Removed the `.purged.css` versions of `base`, `woo-card`, `archive-product` (the source changed, so purge must be re-run after the design is approved); `enqueue.php` loads `system.css` at priority 99.

## [1.9.36] - 2026-10-06
### Fixed · "Mua ngay" (Buy now) on simple products not reaching checkout
- `inc/woo/pdp.php`: the Buy now button only sent `tandoanh_buy_now=1`, while `add-to-cart` sat on the Add to cart button, so the POST lacked the product ID (a bug that existed before the PDP redesign). Added a hidden `tandoanh_buy_pid` next to the button; the server (wp_loaded 15) fills in `add-to-cart` from it before WooCommerce processes the request, and redirects to checkout with the right product and quantity. Variable products are unchanged.

## [1.9.35] - 2026-10-06
### Fixed · Variable PDP: "Mua ngay" with no variation selected no longer posts the form to the server
- `inc/woo/pdp.php`: the Buy now / Add to cart buttons are truly disabled when the variation is not fully selected (class `disabled` + `wc-variation-selection-needed`, `aria-disabled`); clicking shows the message "Vui lòng chọn phiên bản trước khi mua" (Please choose a version before buying) right in the variation block (no alert), scrolls/focuses to the selector, and does not submit; once fully selected they re-enable, and on reset they disable again. This also works while WC's variation JS has not loaded yet (blocks when a select is empty; lets the submit through when all are filled). Server (wp_loaded 15): a Buy now missing `variation_id` is matched automatically from attribute_*, and if no match is found it goes back to the PDP with a friendly notice instead of a WC error.
- `woocommerce/content-single-product.php`: the mobile sticky CTA bar mirrors the blocked state (proxy click, so the same message).
- `assets/css/templates/single-product.css`: disabled button styling + the message box.

## [1.9.34] - 2026-10-06
### Changed · Product page (PDP) redesign: layout, buy buttons, variations, out of stock, responsive
- D-24 `assets/css/templates/single-product.css`, `inc/woo/pdp.php`: the buy button row for variable products is a single flex row (wrapper `.woocommerce-variation-add-to-cart` + `.single_variation_wrap`), with a flat gold "Mua ngay" first and an outlined "Thêm vào giỏ"; both are 48px tall and do not overlap the variation chips; variation chips are rebuilt with tokens (name on the left, price below, the selected mark in the top right without covering text, selected = a thick gold border); the button label "Thêm vào giỏ" (filter `woocommerce_product_single_add_to_cart_text`), and "Mua ngay" in sentence case. Variation descriptions (Condition, Warranty...) appear under the chips.
- D-25 `woocommerce/content-single-product.php`: the buy block has just 1 primary and 1 secondary button; "Tính trả góp" (Calculate installments) becomes a link row with an arrow; Facebook/Zalo chat + "Gọi đặt hàng" (Call to order) (3 blue/beige buttons) merge into one neutral link row "Zalo, Messenger, Gọi ngay" (the blue is gone). Quick info (short desc) moves below the buy block, and the box-in-box is removed.
- D-28 `inc/woo/pdp.php`: the filter `woocommerce_breadcrumb_main_term` picks the Rank Math primary category (`rank_math_primary_product_cat`), and falls back to the deepest term if none (WC's default picks the term with the largest parent ID). Checked on 10 random PDPs: no other product name appears. Data note: the ASUS GT 1030 product (ID 105369) is assigned only to the category "GTX 1650 4GB" (the category assignment needs fixing on production; data is not edited here).
- D-29/D-30: from 720px a 2-column layout (gallery ~45-50% on the left, sticky while scrolling; information on the right); on mobile the main image is 4:3, the breadcrumb is a single horizontally scrolling row (flat, no frame) that auto-scrolls to the deepest category; the sticky CTA bar shows from the first screen when the buy button is still below the viewport, with the full price (no ellipsis, tabular sans), at 360-420px the cart button becomes a 44px icon, and the page leaves bottom room for the bar.
- D-31: "Sản phẩm vừa xem" (Recently viewed) is hidden by default (`hidden`), and JS shows it only when there are other products in the history; descriptions/specification tables use 14px type.
- D-32: out-of-stock products: the price is muted to gray, with a "Sản phẩm hết hàng" (Out of stock) / "Ngừng kinh doanh" (Discontinued) label + a message + a yellow "Gọi tư vấn: hotline" (Call for advice: hotline) button (there is no back-in-stock notification feature, and none was added); the "Hết hàng" status text in the rating row is no longer forced green.
- D-33: removed the 300px right column; the 3 cards (benefits, copyright, consulting) merged into 1 "Quyền lợi khách hàng" (Customer benefits) card under the buy block (sales/technical consulting, email and working hours from the business information, and the copyright shown as a note at the card's end); thumbnails in one horizontally scrolling row.
- D-34: tabs in sentence case, with no mono/tracking; "Đánh giá" (Reviews) shows a number only when > 0.
- PDP price: removed the frame + the "Giá:" label; the number is large, 28-38px, in tabular sans, colored `--td-price` (fallback `--td-danger`).
- Deleted the outdated artifact `assets/css/templates/single-product.purged.css` (the 11/08 version lacked the new rules; the theme uses the source file when the artifact is absent); regenerate it if a purged version is wanted.

## [1.9.33] - 2026-10-06
### Changed · Pre-built configurations sorted from cheapest to most expensive
- `inc/blocks/registry/buildband/render.php`: the preset tabs in the "Tự build dàn PC trong 3 phút" (Build your PC in 3 minutes) block (homepage) are sorted by ACTUAL TOTAL PRICE ascending (computed after out-of-stock parts are swapped automatically); the "Liên hệ báo giá" (Contact for a quote) preset goes last; the first tab (the default) is the cheapest preset. Only the render order changes, preset data is not edited, and the Build PC / Add to cart buttons still follow each preset.

## [1.9.32] - 2026-10-06
### Changed · Mobile speed of the 3 PC landing pages, Hot PC, Kèo Thơm (Lighthouse mobile 82-91 up to 98-100, LCP 3.2-3.6s down to 1.7-2.4s)
- Root cause: webfonts (Inter, JetBrains Mono, Be Vietnam Pro 190KB + the dedicated font of the 3 PC landing pages 195KB) are high-priority requests that finish BEFORE FCP, so they counted on the path to FCP/LCP on Slow 4G (FCP 3.0s). Text still appears immediately in a metrics-matched fallback font and then swaps, so the final look is unchanged.
- `inc/late-fonts.php` (new): the pages `pc-gaming`, `ai-workstation`, `creator-studio`, `hot-pc`, `keo-thom` switch `fonts.css` to `fonts-fallback.css` (local @font-face only) and load webfonts AFTER first-contentful-paint with an inline script: it reads `fonts-web.css` + `landing-{slug}-fonts.css`, downloads only the subsets whose characters appear on the page, and calls `document.fonts.add` once after all have loaded (1 text re-layout instead of one per font). A fallback fires after load + 1.5s or 4s, and `<noscript>` keeps the CSS link. A comparison/rollback mode: add `?td_fonts=blocking` to the URL (loads fonts the old way, and the landing pages reload the font file through a normal CSS link).
- `assets/css/fonts-fallback.css`, `fonts-web.css` (new, generated from `fonts.css`; `fonts.css` remains the source, as noted at the top of the file).
- `assets/css/landing-perf.css` (new, only <= 480px): `content-visibility:auto` + `contain-intrinsic-size` (height measured at 390px) for sections below the first screen, cutting the text re-layout when fonts arrive from ~40ms to ~15ms. Clicking an anchor link or arriving with a #hash turns `html.td-nocv` on and disables it completely (`landing-enhance.js`) so scrolling lands on the real position.
- `assets/js/landing-enhance.js`: removed synchronous layout reads at load (scroll-spy runs its first pass after a frame, it measures the section wrapper instead of sections inside the content-visibility area, and the GPU meter bar waits 2 frames instead of `offsetWidth`).
- `assets/css/landing-enhance.css`: the 'Inter Tight Fallback' size-adjust changes from 99.3% to 95% (measured in Chrome on real Vietnamese text: webfont/Arial 0.947-0.953), and a dedicated fallback is added for UPPERCASE headings per landing page (gaming 94.6%, ai 92.4%, creator 93%). Hero height matches between the fallback and the arrived webfont at 360-1350px. CLS on pc-gaming went from 0.077 on mobile and 0.117 on desktop down to 0.
- `inc/lcp-banner.php` (new): the top banner image of `keo-thom` (raw HTML in post_content) gets a `wp-image-ID` class so core adds width/height + srcset, drops `loading="lazy"`, and adds `fetchpriority="high"` (CLS 0.112 down to 0, and mobile loads a small image instead of 2560px). Post data is not edited.
- `inc/blocks/registry/hotpc-hero/render.php`: the Hot PC hero image has srcset/sizes (mobile ~460px instead of the 1920px image) and `fetchpriority="high"`.

## [1.9.31] - 2026-10-06
### Fixed · td overall QA (A11y light/dark, cart, checkout, homepage)
- Q-02 `inc/woo/pdp.php`: "Sản phẩm vừa xem" (Recently viewed) stores the price as plain text (removes `<del>`/screen-reader-text, decodes entities, converts long dashes to "-"); JS strips tags and decodes with DOMParser for old data already saved in localStorage (raw price HTML no longer shows).
- Q-03 `inc/blocks/registry/biz-text/render.php` (variant `h1`, token `{brand}`) + `templates/front-page.html`: added a visually hidden H1 (`screen-reader-text`) for the homepage.
- Q-04 `woocommerce/content-single-product.php`: the hidden `.td-sticky-cta` bar uses `inert` (fallback `tabindex=-1`) instead of `aria-hidden` wrapping focusable buttons.
- Q-05: light `--td-success` #1a8f4c changed to #157a3d (4.13 to 5.4:1; updates `tokens.css`, `theme.json`, 5 critical files); cart headings follow the right hierarchy (item h2, block h3, accessory name h4, the 4 commitment cards h3; CSS follows); checkout: the Select2 Province/Ward fields have `aria-labelledby` pointing to the label (`checkout.js`, MutationObserver), the Select2 placeholder uses `--td-text-2` and the opacity on disabled fields is removed, `.showlogin/.showcoupon` use `--td-link-hover`, and inline red text in payment gateway descriptions is overridden in color by CSS (the DB option is left as is); `inc/a11y.php`: meta description for Cart/Checkout via the filter `rank_math/frontend/description`.
- Q-06: landing dark `--ink-3` #82796b changed to #9a9181 (fixed at the source `landing-clone/*.html`, the generated `assets/css/landing-*.css` and `landing-enhance.css`; `scrub_labels()` stays idempotent); on the installment guide page, the labels/notes of the highlighted tile `.tdp-tragop__estat--hl` use `--td-text-2`.
- Q-07 `layout.css` + `layout.purged.css`: in dark mode the cart count badge uses dark text #14110a on a #ff6257 background (6.4:1).
- Q-09 `inc/admin/business-info.php`: the long dash was removed from the default company name (the option `tandoanh_business_info` and 3 places "18 - 70", "10 - 30" on the installment guide page ID 55836 were fixed in the DB, with a backup); the "play" icon of the Discipline section on the creator landing became an inline SVG (`landing-clone`, `landing-inline`, `creator-discipline.html`).
- `inc/landing.php::tandoanh_land_live_display()`: when all 4 monitors are hidden, the `.cr-display-monitors` frame is dropped too (the calibration block took a whole row and the lead-in sentence did not mention "below").

## [1.9.30] - 2026-10-06
### Changed · Creator landing: monitor prices pulled live, rows hidden when out of stock
- The "Màn hình & màu" (Monitors & color) section (`creator-display.html`, `landing-inline/creator-studio.html`, `landing-clone/creator-studio.html`): each row carries `data-td-pid` (product ID) and the token `{{td_price:ID}}` in place of a hand-typed price.
- `inc/landing.php`: the function `tandoanh_land_live_display()` reads the current price at render time; a product that is out of stock, priced 0, on pre-order, discontinued or no longer existing (rule A1) has its whole row hidden. It is called by the inline version and by `land-section/render.php`.

## [1.9.29] - 2026-10-06
### Changed · Landing generation scripts synced with current content (re-running does not overwrite)
- Checked: regenerating into a copy of the theme (including from the state before 1.9.28) gives results identical to the files in use.

## [1.9.28] - 2026-10-06
### Changed · PC landing pages: removed "live"/lab-suggesting labels, creator monitors are items on sale
- Gaming hero (`land-section/sections/gaming-hero.html`, `land-hero/scenes/gaming.html`, `landing-inline`, `landing-clone`): "LIVE FPS · 4K ULTRA" became "FPS tham khảo · 4K Ultra" (Reference FPS · 4K Ultra); "TAN DOANH PC LAB · APEX-X9" and "APEX-X9 · LIVE TELEMETRY" became "Cấu hình tham khảo" (Reference configuration); the 5 SVGs `assets/landing-svg/gaming-build-*.svg` drop the text "PC LAB".
- Creator: "OctaneBench · score" became "OctaneBench · điểm tham khảo" (reference score); the monitor section (`creator-display.html`) replaced BenQ/Eizo/Apple with 4 graphics monitors the shop actually sells (ASUS ProArt PA278QEV, PA279CV, PA329CRV, ViewSonic VP2788-5K), with full names linking to the products and current prices.
- Note: the EAGLE PSU is actually the MSI MAG A850GL 850W (ID 75592), not the FSP Vita 750W.

## [1.9.27] - 2026-10-06
### Fixed · Slow cold-cache homepage and caching of the Build PC preset block
- `inc/blocks/registry/buildband/render.php`: the auto-generated benchmark (`td_bench_auto_groups`, about 290 queries, 1.4s for 6 presets) now has an object cache keyed by the data stamp of the `wp_td_benchmarks` table (`tandoanh_bp_bench_cached`, 12-hour TTL). Cold-cache homepage TTFB dropped from 2.5s to 1.3s.
- `inc/home-sections.php`: the buildband block cache key now also includes `TanDoanhBuildPC_Security::cache_epoch()` (td_buildpc_version), so a price/stock change through a path that does not call `woocommerce_update_product` also makes the block recompute.

## [1.9.26] - 2026-10-06
### Fixed · Build PC preset widget (buildband) prints prices in the proper Vietnamese format
- `inc/blocks/registry/buildband/render.php`: each component price, the "Linh kiện" (Components) line, the discount and the total print as `4.290.000đ` (`number_format( $x, 0, ',', '.' )`) instead of `4,290,000đ`.

## [1.9.25] - 2026-10-06
### Changed · PC landing pages (pc-gaming, ai-workstation, creator-studio): copy based on policy and real data
- Removed unsourced statements: "12K+ PCs đã giao" (12K+ PCs delivered), "4.9 · 2.4K reviews", "24/7", "thuê thử / Try Before Buy" (trial rental), "Tân Doanh PC Lab đo trên hệ thống thực" (measured on real systems), 48h burn-in, "bảo hành toàn bộ linh kiện 36 tháng" (36-month warranty on all components), "ship HCM trong ngày" (same-day HCM delivery), 1-for-1 replacement within 6 months, ICC profile / calibration / training / LUT included with every machine, and "đối tác chính thức Eizo, BenQ, Apple" (official partner of Eizo, BenQ, Apple). They are replaced with content that matches the Shipping, Warranty and Goods policy pages (free delivery inside HCM City within 24h for PC assembly orders, 7-day exchange for manufacturer defects, out-of-province customers pay in advance).
- The "Quyền lợi" (Benefits) block (previously Ưu đãi / Offers): lists only benefits found in the policies, with no gifts. The FPS / AI bench / app bench sections are clearly marked as reference numbers, not measured at Tân Doanh; the hero telemetry is labeled "minh hoạ" (illustration).
- Hero: the gaming stats take the configuration count, policy values, and the score and review count from real WooCommerce product reviews (tokens `{{td_rating}}`, `{{td_review_count}}`, 6-hour transient); the AI and creator heroes take VRAM, RAM, SSD and CPU from the most powerful configuration (RTX PRO 6000 96GB, 256GB ECC; the terminal demo shows 1 GPU instead of 4 GPUs with 192GB).
- New tokens in landing markup: `{{td_hours}}`, `{{td_hours_short}}` (working hours from Business Info), alongside the existing `{{td_phone}}`, `{{td_address}}`. Functions `tandoanh_land_tokens()`, `tandoanh_shop_rating()` (`inc/landing.php`), `td_biz_token_map()`, `td_biz_expand_tokens()` (`inc/admin/business-info.php`).
- Generated by the landing build script (the same result on every re-run) and the script that updates the AI hero block in the DB.
- TD-EAGLE swapped the RX 9070 XT for the MSI GeForce RTX 5070 12G VENTUS 2X OC (PSU, socket and cooler checks: 0 warnings, 15/15 configurations).
### Changed · Working hours share a single source
- `td_biz_hours_rows()`, `td_biz_hours_text()`; shortcode `[td_biz field="hours"]` (or `key="hours"`, `hours_short`). The "Giờ làm việc" (Working hours) card on the Contact page (`contact-cards`, field `source: hours`) and the CTA block on the licensed software page (`prem-cta`, token `{{td_hours}}`) read from Business Info, matching the footer. Long dashes in working hours became "-" (default, option, /en/ translation).
### Fixed
- Buyback price list (`inc/buyback-pricelist.php`): at the display layer, "GTX 2060/2070/2080" becomes "RTX", "Gigabye" becomes "Gigabyte", and long dashes become "-" (the Google Sheet source is left unchanged).
- Rank Math: the descriptions and titles of the 3 landing pages dropped unsourced claims. Hot PC: the "Giao và hỗ trợ tận nơi" (On-site delivery and support) block now follows the real policy.

## [1.9.24] - 2026-10-06
### Added · Server-side installment calculation core (`inc/installment-calc.php`)
- `tandoanh_installment_calc_card()`, `tandoanh_installment_calc_finance()`, `tandoanh_installment_calc()` are equivalent to `calcCard`/`calcFinance` in `installment-core.js` (same formulas, same rounding to the dong and rounding up to 1,000đ, same result key names); `tandoanh_installment_check_card()`/`_check_finance()` check eligibility conditions; `tandoanh_installment_resolve( $token, $amount )` reads a selection like `card:VIB:6` or `fin:pk1:12:20`, checks it against the active configuration and then computes; `tandoanh_installment_compare()` is a term comparison table. Used by the A4 print page of the tandoanh-buildpc 2.8.0 plugin. JS and PHP equivalence was verified (6 cases, every key matches).

### Added · Build PC preset widget automatically replaces out-of-stock items (`inc/buildband-substitute.php`, `inc/blocks/registry/buildband/render.php`)
- Preset data (synced from production) is not edited. At render time, an item no longer sold (not published, out of stock, pre-order, discontinued, price 0) is replaced with an item on sale in the same step/category (from the leaf category up to the parent), at the closest price (about ±25%, ±50%, then unlimited; when the original price is 0, the median price of the same group is used), preferring the same RAM capacity and excluding laptop/ECC RAM types; a replacement must pass `tandoanh_landing_check_compat()` (socket, DDR, cooler, case) and the Build PC plugin's power supply estimate. "Tạm hết" (Temporarily out) or "Liên hệ báo giá" (Contact for a quote) shows only when no replacement can be found.
- A replaced item gets a small label "Đã thay món tương đương (thay cho ...)" (Replaced with an equivalent item (instead of ...)) (on hover or keyboard focus); the total, pill, compatibility box, and the "Thêm vào giỏ" (Add to cart) and "Tuỳ chỉnh cấu hình" (Customize configuration) buttons (td_parts + td_qty) use the effective set of components. The result is cached in a transient keyed by `td_buildpc_version` (Redis), so a price/stock change or a sync run triggers a recompute.
- The CSS for the label and tooltip is in `assets/css/templates/home.css` and `home.purged.css`.

## [1.9.23] - 2026-10-05
### Fixed · Review fixes for the "Tính trả góp" (Installment calculator) module (`inc/tragop-admin.php`, `inc/tragop-data.php`, `inc/installment.php`)
- **XLSX import blocks zip bombs**: at most 50 entries in the file, the declared uncompressed size of `sharedStrings.xml` + `sheet1.xml` totaling no more than 20MB (checked with `statName`, read with a limit), and no more than 500 table rows; only sheet1 + sharedStrings are read. Wrapped in `libxml_use_internal_errors` so a broken file does not print PHP warnings.
- **Name sanitizing on import**: `sanitize_text_field`, strips the formula characters `= + - @` at the start of a name (against CSV/formula injection), at most 60 characters, and at most 100 new providers per import.
- **A 0% fee is valid** in the term fee field (manual admin save and table import): 0 is different from empty/"x" (not supported). The admin instructions are updated.
- **Guide link** accepts only http/https or an internal path: saved through `esc_url_raw` with the scheme restricted, and the public configuration and JS re-check it.
- Admin: the provider name field is 260px wide (no more clipped text); the note on the "Tính trả góp" button shows only when the price is at or above the lowest minimum among the enabled providers; notices use a neutral system voice; mobile: the term select and guide link are 44px tall.

## [1.9.22] - 2026-10-05
### Changed · Homepage Build PC preset widget (`inc/blocks/registry/buildband/render.php`)
- Calls the `tandoanh-buildpc` 2.7.0 plugin (`TanDoanhBuildPC_Compat::preset_report`) when the plugin is present: out-of-stock, pre-order and discontinued items show "Tạm hết" (Temporarily out), and items priced 0 show "Liên hệ" (Contact); when any such item exists, the pill label, the "Linh kiện" line and "TỔNG CỘNG" (TOTAL) show "Liên hệ báo giá" (Contact for a quote) instead of an incomplete sum.
- The "Tương thích" (Compatibility) box does a real check (CPU and mainboard socket, RAM type, estimated PSU wattage); warnings use a yellow frame via the token `--td-brand-strong`, and the fixed string with a green tick is gone. Without the plugin the old string is kept (with long dashes changed to "-").
- Opening a preset share link extends the share's `updated_at` (the plugin's 90-day session cleanup cron).

## [1.9.21] - 2026-10-05
### Added · "Tính trả góp" (Installment calculator) module (credit card + finance company)
- **Lazy frontend** (`assets/js/installment-core.js`, `installment-ui.js`, `assets/css/installment.css`): a modal (a bottom sheet on mobile) with a "Thẻ tín dụng" (Credit card) tab (choose a bank and a supported term, clear notice when below/above the limit, a suggestion for the lowest-fee provider) and a "Công ty tài chính" (Finance company) tab (loan package, down payment %, term, a suggestion to raise the down payment when the loan limit is exceeded). Results: monthly payment, fee/interest, total payable, difference versus paying in full, first/following payments, required card limit, and a term comparison table. Dark/light by token, a11y (dialog, focus trap, Esc, arrow-key tabs, aria-live results area).
- **Pure calculation core + unit tests**: card fee = amount x term % (+ swipe fee if enabled), monthly payment = (amount + fee) / term rounded up to 1,000đ; finance companies use flat interest or declining balance (annuity).
- **API for other pages** (Build PC...): `window.tdInstallment.open({amount,title})`, `.mount(el,opts)`, `.setAmount(n)`, `.calc(...)`, `.getLast()` (latest selection, sessionStorage), `.planHtml(plan?)` (the A4 "Kế hoạch trả góp" (Installment plan) block for printing), and the event `tdinstallment:change`. PHP: `tandoanh_installment_button()`, `tandoanh_installment_public_config()`, `tandoanh_installment_product_eligible()`. REST `GET /wp-json/tandoanh/v1/installment-config` (5-minute cache).
- **Installment admin** (`inc/tragop-admin.php`): enable/disable each provider, reorder with arrows, type (bank / finance company), minimum + maximum value, fees entered as "2,05" or "2.05" (validated 0 to 100), a "add card swipe fee" switch + swipe %, finance company loan packages (interest, minimum down payment, loan limit, terms), interest method, a disclaimer line, a guide link, and the fee table's update date. **Fee table import** from an .xlsx file (ZipArchive + SimpleXML), a .csv, or a pasted table; providers are matched by name, with a backup and a 1-step undo button.
- The new configuration lives in the option `tandoanh_tragop_options` (autoload no), read through `tandoanh_tragop_settings()`; an old option missing keys uses defaults (no data loss).
### Changed
- PDP: the "Tính trả góp" button (price > 0, in stock, meeting the minimum; price follows the selected variation) replaces the old modal block + inline calculation table (the PDP is about 10KB lighter in transfer per Lighthouse; CSS/JS load only when the customer clicks or hovers over the button).
- The price in the modal follows the selected variation: it looks up `variation_id` in `data-product_variations`, matches by attributes automatically when WooCommerce's variation JS loads late, and listens to the `found_variation` / `reset_data` events.
- `[td_tragop]` embeds the same new interface (lazy loaded on scroll). `tandoanh_installment_monthly()` and the fee table `[td_tragop_table]` respect each provider's on/off state and maximum price.
- Saving in the admin no longer calls `wp_cache_flush()` (which clears all of Redis), and only clears the WPFC HTML cache.

## [1.9.20] - 2026-10-05
### Fixed · Build PC quantity bridge (`inc/landing.php`)
- The `td_parts`/`td_qty` bridge runs only on the `/tu-chon-cau-hinh-pc/` page and only when both parameters are strings; no other URL creates a transient.
- `td_parts[]=` or `td_qty[]=` (array form): ignored quietly, with no more PHP warning "Array to string conversion" (removed from `$_GET` so the old plugin's preload does not warn either).
- `td_qty` accepts only integers 1..16 (negatives, 0, letters and decimals are rejected; `absint` is no longer used).
- The share key is an md5 of the content (the same configuration reuses 1 transient), with at most 30 new keys per hour per IP.

## [1.9.19] - 2026-10-05
### Fixed · PC landing pages round 2 (fixes from the cross-review of release 1.9.18)
- **Cooler matches the CPU socket**: TD-TRAIN / TD-QUAD / TD-THREAD (Threadripper sTR5) use the Noctua NH-U14S TR5-SP6 (the manufacturer lists sTR5/SP6, compatible with Threadripper PRO 7995WX); before, they had the Kraken Elite 420 V2 which supports only LGA1851/1700/1200/115X + AM5/AM4. TD-INFER (Core Ultra 7 265K, LGA1851) switched from the ID-Cooling FX360 ARGB (LGA1851 not listed) to the FX360 INF (manufacturer lists LGA1851/1700 + AM5/AM4), at the same price.
- **Case fits the mainboard**: TD-TRAIN / TD-STUDIO (E-ATX TRX50 mainboard) switched from the Corsair 7000D (ATX attribute) to the Thermaltake AX700 (E-ATX/XL-ATX/EEB).
- **Dual / quad channel RAM**: builds from mid-range gaming up use 2 sticks of 16GB DDR5 (the total price is multiplied by the full quantity, and the spec reads "32GB (2x16GB) DDR5-6000"); Threadripper builds use 32GB ECC Registered RAM: 4 sticks (TRX50, quad channel, 128GB), 8 sticks (WRX90, 8 channels, 256GB). TD-FOX keeps 1 stick of DDR4.
- **The Build PC button passes the correct quantities**: link `?td_parts=ID,ID&td_qty=ID:SL` (3 JS clones + the comparison table + the summary card); `inc/landing.php` adds a `template_redirect` bridge that makes the 3 builds create a share key with quantities (the buildpc plugin's preload always loads quantity 1). REST `/build-price` accepts at most 24 IDs per configuration; transient `td_buildprice4_`.
- **Automatic compatibility check** `tandoanh_landing_check_compat()` (`inc/landing-builds.php`): CPU-mainboard socket, DDR, ECC Registered RAM only on sTR5, cooler supports the CPU socket (reads the product description), and the case fits the mainboard form factor. The landing build script prints errors/warnings and refuses to generate markup while errors remain.
- Renamed the TD-XEON build (it uses Threadripper PRO, not Xeon) to TD-THREAD, tier "Flagship Workstation".
- Build card JS: when a configuration is missing an item (`ok:false`), the static spec is kept, with no mismatched label overwrite.
- The "VGA MSI" block (MSI page) shows again: the `source=keyword` branch of product-list also matches child categories (`inc/home-sections.php`).
- Meta descriptions of the 3 PC landing pages (gaming, AI, creator) were rewritten to match the current configurations.
- Turned off `wptexturize` site-wide (`inc/content-enhance.php`): the H1 "Kèo Thơm - Tri Ân Khách Cũ" no longer turns into a long dash; the MSI/ASRock block titles changed long dashes to "-".

## [1.9.18] - 2026-10-05
### Fixed · PC landing pages (pc-gaming / ai-workstation / creator-studio): configurations built from components ON SALE
- **Rule A1** (`inc/landing-builds.php`, new): a component may be used/shown only when published + price > 0 + `stock_status = instock` (excluding out of stock, discontinued, pre-order). The 15 builds are configured in `inc/landing-builds-config.php` (the single source); 14/15 builds had price-0 / out-of-stock components replaced with equivalent products on sale (checked CPU-mainboard socket, DDR4/DDR5, PSU >= CPU+GPU TDP x 1.5).
- **REST `/tandoanh/v1/build-price`** (`inc/landing.php`): fixed the bug that silently skipped price-0 items (producing a falsely low price, e.g. TD-DRAGON 46.67M instead of ~237M). When one item on sale is missing, it returns `ok:false` + "Liên hệ báo giá" (Contact for a quote) instead of an incomplete sum. It also returns `specs` (CPU/GPU/RAM/SSD/PSU generated from the real product names) + a list of failing IDs; transient key `td_buildprice3_`.
- Specs printed on cards, the comparison table, the "Build hiện tại"/"Recommended"/"Selected" cards, the 4 Component cards, the gaming hero telemetry, and the text on the 5 gaming-build SVG images: generated from real components by the landing build script (the same result on every re-run). Removed incorrect hard-coded text (ARM Cortex 128GB, the old RTX 4090, a hard-coded struck-through "offer" price, and FPS/VR/Capture rows with no data).
- JS clone (8g): updates prices + specs live from REST, and assigns prices to every `[data-td-price]` cell (comparison table, summary card).
- FAQ: the hotline + showroom address come from td_biz through the tokens `{{td_phone}}` / `{{td_address}}` (land-section render.php + inline), dropping the hotline 1900.6868 and the old address; old build names (ORCA/KRAKEN/AMBER/COPPER/BRONZE/RUST/MAHOGANY...) were renamed to the real build names.
- gaming-promo: removed the fake countdown timer + "Mega Sale 5.5", replaced with a "Quyền lợi khi mua PC tại Tân Doanh" (Benefits of buying a PC at Tân Doanh) block; the conditions button points to the real policy page; in the AI/creator promos the `#` link became `/lien-he/`.
- AI terminal: the fake hostname `td-orca` became `demo`.
- Cleaned the empty CSS `background-position-x: ;` in the generated markup.
### Added · On-sale filter for list blocks
- `tandoanh/product-list` + `tandoanh/product-section` gain the attribute `sellable` (+ `orderby`): keeps only published products that are in stock with price > 0; a dynamic source builds IDs by total_sales descending and then price descending; if there are not enough, fewer slots are shown (not filled with other products). Applied to hot-pc, the 4 TOP pages, the 5 brand pages, kèo thơm, and software.
- Shortcode `[td_price id=""]`: the live price of 1 product (a product no longer sold shows "Liên hệ"), for hard-coded price tables inside page content.

## [1.9.17] - 2026-08-29
### Added · Mobile PDP sticky CTA bar (QA proposal - raises order completion)
- A buy bar fixed at the bottom of the screen once the customer scrolls past the main buy button (mobile <768px). It shows ONLY when the product can be bought (`is_purchasable && is_in_stock`). The sticky "Thêm giỏ"/"Mua ngay" buttons **proxy-click the real buttons** (`.single_add_to_cart_button` / `.tandoanh-buynow`) → keeping variation validation + buy_now intact. Price + thumbnail follow the variation through a MutationObserver. Show/hide uses an IntersectionObserver watching the buy BUTTON (exact when the main CTA is off screen). When shown, it moves the chat FAB (`#td-contact-mdock`) up to `bottom:88px` so it does not cover the Buy now button. Built dynamically in JS + inline CSS in the template, for purchasable products only. Verified in a mobile browser (390px): render + FAB clear + proxy OK.
- (QA report review: 2.3 dark tab contrast + 2.4 buildpc loading state + floating total + 2.5 Z Fold padding - all VERIFIED as ALREADY PRESENT/ALREADY FINE on TD, no fix needed. Only the Sticky CTA is new.)

## [1.9.16] - 2026-08-27
### Fixed · A11y filter UX (Lighthouse audit ui-ux-pro-max) - regression from 1.9.14
- **color-contrast**: the secondary toolbar text (`.td-shop__toolbar-count`, `.td-sort-open__val`, the × chip, `.td-active-clear`, the empty-state `p`, the sort sheet close button) used `$muted` at 55%, which did not meet 4.5:1. Raised to **72%** (`color-mix(--td-text 72%,transparent)`), which passes. Shop A11y 97→100.
- **label-content-name-mismatch**: the `.td-sort-open` button had `aria-label="Sắp xếp sản phẩm"` that did not contain the dynamic visible text ("Sắp xếp" + the value "Mới nhất"). Removed the aria-label, so the accessible name comes from the visible text (the correct WCAG label-in-name rule).
-

## [1.9.15] - 2026-08-24
### Added · PDP gallery switches by variation (paired with the tandoanh-code 6.8.2 plugin variation-gallery module)
- Refactored the inline gallery JS in `woocommerce/content-single-product.php` to SWITCH the gallery by the variation the customer picks: split out `bindThumbs()` (re-bindable after the strip is rebuilt), stored the original gallery, and exposed 2 global functions for the plugin to call:
  - `window.tdRenderGallery(images)` - rebuilds the main image + thumbnail strip from an array `[{id,thumb,medium,full,alt}]` (built with `createElement`/`textContent` - XSS safe). If the base product has only 1 image (no strip) but the variation has several → it creates `[data-td-thumbs]` itself (marked `data-td-dynamic`).
  - `window.tdResetVariationGallery()` - restores the original gallery when the customer deselects the variation (removing the dynamic strip if any).
  - The lightbox now RE-READS `srcs` from the DOM on every open (instead of capturing once at load) → correct once the gallery has switched by variation.
- No visible change when a product has no variation gallery images (most of them right now) - it activates only when a variation has `_ux_additional_variation_images` / its own featured image.
- Code review fixed 2 findings (M): (a) a parent product with NO image (placeholder, missing `#pdp-main-img`) whose variation has images → `ensureMain()` creates `#pdp-main-img` dynamically (hides the placeholder; reset removes it and restores the placeholder); the lightbox moved to delegation on `#pdp-main-wrap` + re-resolves the main image so it works with a dynamic main. (b) Switching to a variation with only 1 image now fully CLEARS the thumbnail strip (not just hides it) so the lightbox does not read the previous variation's images by mistake.

## [1.9.14] - 2026-08-23
### Perf · Block junk thumbnails on upload - whitelist the sizes the theme really uses (port of HP 1.9.17)
- Added the filter `intermediate_image_sizes_advanced` @999: generate ONLY the REGISTERED sizes (`get_intermediate_image_sizes()` + 3 WooCommerce sizes), dropping `1536x1536`/`2048x2048` (the WP big-image retina sizes the theme does not use) and ANY odd size a plugin injects. Verified: KEEPS thumbnail/medium/medium_large/large/small/td-responsive-320..1200/td-content-1600/WC; BLOCKS 1536/2048 + rogue plugin sizes. OLD images are left alone; only NEW generation on upload is blocked → each image spawns just the needed set of sizes, avoiding uploads bloat. (HP's Group 5.3 "WARRANTY line in the specs box" was NOT ported: the TD PDP uses a different tab-spec, with no CONDITION/WARRANTY specs box structure like HP.)

### Fixed · PDP structured data - removed the duplicate Product microdata (port of HP 1.9.27)
- The PDP template still had microdata (`itemscope itemtype="schema.org/Product"` + `itemprop` name/description) that created a **duplicate 2nd Product** next to the RankMath JSON-LD; for variable products (ProductGroup) the microdata version lacked `offers`, so Google reported invalid "must specify offers/review/aggregateRating". Removed the 3 microdata items from `content-single-product.php` (article/h1 name/desc - TD has these only in this file, unlike HP's 5 files), making **the RankMath JSON-LD the ONLY Product schema source** (verified live: microdata Product 0, RankMath Product has offers). Display is unchanged.

### Fixed · Admin comments - review metabox + Q&A list (port of HP 1.9.46)
- **WooCommerce `ReviewsCommentsOverrides`** excludes the post_type `product` from every `edit-comments` list-table screen → (1) the "Đánh giá" (Reviews) meta box on the product EDIT page was empty (the spinner kept spinning even when the product had reviews), (2) the Comments page filtered by `comment_type=td_qna` (Q&A questions attached to a product - `inc/qna.php`) was empty and could not be moderated (the new-question notification email links right here). Added the filter `comments_list_table_query_args` @20 (after WC @10): drops the `post_type` restriction when the query targets 1 post_id that IS a product (the metabox) or when `type=td_qna`. The general Comments page keeps WC's behavior of excluding products. (HP's Bug 1 - whitespace before `<?php` breaking the replyto-comment XML - does NOT apply: scanned 0 TD files with output before `<?php`.)

### Added · Filter UX (toolbar + CSS) - paired with the tandoanh-code 6.8.0 plugin
- **Toolbar shortcode `[td_shop_toolbar]`** (`inc/woo/woocommerce.php`) adds 3 elements for the new Filter UX: `.td-shop__toolbar-count` (total result count, rendered server-side from `$wp_query->found_posts` to avoid CLS; the filter JS updates it on AJAX filtering), `.td-sort-open` (the button that opens the Sort bottom sheet - mobile only, the JS builds the sheet from the native `<select class="orderby">`), and the badge `.td-filter-btn__count` (number of active filters) inside the Filter button.
- **Filter UX CSS INLINE** in `wp_head` (`#td-filter-ux-css`, only on shop/category/tag/tax/search pages) to avoid PurgeCSS - the same pattern as `td-backorder-css`. Styles for active chips/sort sheet/empty state/count/badge use theme tokens (`--td-brand/--td-surface/--td-line/--td-text/--td-radius-lg/--td-font-display`); muted text uses `color-mix` from `--td-text` (the theme has no `--td-muted`) so it adapts to light/dark. Mobile hides the native `<select>` and shows the sort sheet trigger; desktop is the reverse. The JS logic is in the plugin (`filter-r3.js`).

## [1.9.13] - 2026-08-23
### Fixed · Code review round (stock state onbackorder)
- **`onbackorder` product cards NO LONGER have a "Mua ngay"/add to cart button** (WooCommerce treats onbackorder as in stock, so the old card still allowed a direct buy → against spec). The card CTA branch (`content-product.php`) now prioritizes `$is_backorder` → a "Liên hệ đặt trước" (Contact to pre-order) button (linking to the PDP), matching the PDP buybar.
- **The backorder buybar replaces `form.cart` only for products that are NOT variable** (`! is_type('variable')`) - so a variable product (all variations on backorder) does not lose its variation selector; variable products fall back to the standard form to choose a variation as before. (Dynamically changing the header/buybar by variation remains in Tier B.)
- **`td_contact_url()` gains the filter `td_contact_url`** to override the Contact page (a different slug / configuration) instead of hardcoding. Bumped 1.9.12 → 1.9.13.

## [1.9.12] - 2026-08-23
### Added · Full 4-level stock status + "Hàng đặt trước" (Pre-order) (port of HP 1.9.36/1.9.49/1.9.50)
- **The PDP header stock line** (next to the rating) reflects all 4 states: `instock`→"Còn hàng" (In stock, green), `onbackorder`→"Hàng đặt trước" (Pre-order, blue), `outofstock`→"Hết hàng" (Out of stock, muted), `ngungkd`→"Ngừng kinh doanh" (Discontinued, orange). Helper `td_stock_header_meta()` + a map of the 4 states through `data-td-stock-labels` (preparing Tier B, changing dynamically by variation).
- **The `onbackorder` state "Hàng đặt trước"** - POSITIVE: the product still shows, KEEPS its list position (sort onbackorder=1), is NOT dimmed, but HIDES the buy button and shows a "Liên hệ đặt trước" (Contact to pre-order) button (linking to the Contact page); the shop card has a blue badge. Helpers `td_stock_is_backorder()` + `td_contact_url()`; `.td-buybar--backorder`/`.td-btn-backorder`/`.td-pcard__backorder-flag`; inline wp_head CSS against PurgeCSS. Bumped 1.9.11 → 1.9.12.
- **REMAINING (Tier B):** update the header + buybar dynamically when the variation changes (add data-stock to the variation cards of the tandoanh-code plugin + JS applyStockState).

## [1.9.11] - 2026-08-23
### Added · Self-hosted auto-update through phatdev.net
- Added a dedicated updater (`inc/theme-updater.php`, class `TD_Theme_Updater`) that checks `https://phatdev.net/api/updates/tandoanh` and shows the new version on the WordPress Updates screen, downloading the package from the licensed update server. It needs `define('TD_UPDATE_LICENSE', '...')` in wp-config.php; without the key it stays silent (no-op). The ZIP package is served only to a valid active license; the server logs and rate-limits by IP; the manifest is cached for 6h and cleared after updating.

## [1.9.10] - 2026-08-11 (Out of stock / Discontinued - hide the buy button + mark + push to the end of the list)

Aligned the behavior of **out of stock** (`outofstock`) + **discontinued** (`ngungkd`) products/variations with the HP version.

- **Re-registered the custom stock status `ngungkd`** (port of production's flatsome-child - the new theme lacked it → the admin lost the option and label): filters `woocommerce_product_stock_status_options` + `woocommerce_admin_stock_html`. Helpers `td_stock_is_oos()` / `td_stock_oos_label()`.
- **Force `is_in_stock()` = false for `ngungkd`** (filter `woocommerce_product_is_in_stock`). WC computes `is_in_stock = ('outofstock' !== stock_status)`, so ngungkd was treated as IN STOCK (purchasable, the card showed "Mua ngay", the rating showed "Còn hàng"). Now ngungkd is handled like out of stock everywhere.
- **PDP** (`content-single-product.php`): an out-of-stock/discontinued product → replaces `woocommerce_template_single_add_to_cart()` with **1 long button** "Sản phẩm hết hàng" / "Ngừng kinh doanh" (`.td-btn-oos`, readable in light and dark). A partially in-stock variable product: WC natively locks the button per variation (td has just 1 mixed product).
- **Card** (`content-product.php` + `woo-card.css`): the image is `grayscale(1)` + a **large single-line label in the middle of the image** (`.td-pcard__oos-flag`, slate = out of stock / orange `#b45309` = discontinued, meets AA), the `-%` badge is hidden when out of stock; actions show "Xem chi tiết" (View details).
- **Sort**: defined `move_outofstock_and_ngungkd_to_end` in the theme (the tandoanh-code plugin's fast-filter REMOVES the function with exactly this name) → instock/onbackorder → outofstock → **ngungkd last**, correct across pagination (tested on card-do-hoa-nvidia pages 28-29, all ngungkd).
- CSS fixed in the `.purged` version too. A11y=100/BP=100/CLS=0. td uses WC's native add-to-cart, so it does NOT have the `&quot;` bug that HP had.
- **Review hardening:** the sort `move_outofstock_and_ngungkd_to_end` dropped a dead `DOING_AJAX` branch + guards an empty orderby (avoiding a trailing comma that breaks SQL). Confirmed the hide-out-of-stock setting is off, with 0 variable products ngungkd.
- **Pre-existing fix:** on **variable** product cards the "Mua ngay" button used to point to `add-to-cart={parent_id}` (missing variation_id → checkout error/empty cart, 76 products affected) → now **variable → PDP** to choose a configuration; simple products keep the direct buy.

## [1.9.9] - 2026-07-20 (Landing: moved the build-card scene SVGs out to external <img> - 45% less DOM)

**Background:** the 3 mobile landing pages were stuck at 84-86 due to a CPU/DOM bottleneck (gaming had 5,513 elements, and the builds section alone had 2,690 = 49%, because of 5 inline SVG scenes drawing computers). Careful measurement proved it was not a network bottleneck.

**What was done:** an SVG-externalizing script moves the 5 scene SVGs inside `.build-img` of each builds section (baked HTML `inc/blocks/registry/land-section/sections/{gaming,ai,creator}-builds.html`) out into files `assets/landing-svg/{variant}-build-{n}.svg`, replacing the inline `<svg>` with `<img class="build-img-svg" loading="lazy" decoding="async" width="720" height="720">`. The vectors and SMIL `<animate>` are KEPT (SMIL still runs inside `<img>`), and there is NO switch to WebP/AVIF. About 7,672 tags were removed from the DOM (gaming 5,513→3,052).

**Important GOTCHA:** the ai/creator SVGs use `var(--accent/--bg-1/--ink-3…)` in presentation attributes like `fill=`. `var()` WORKS when the SVG is inline (Chrome resolves it) but does NOT work inside an external `<img>` (an isolated document) → every var-colored element disappeared (black on black - it nearly slipped through because it was checked at a small size). The real variable values are now **baked** into the SVG files (`bake_vars()` in the generator). This is safe because the landing pages have NO `[data-theme=dark]` override for these variables (the scene colors are fixed). A/B inline vs baked-external: 100% match.

**Result (median of 5 runs, Lighthouse):**
- gaming: 86→86 (inline-colored SVG, externalizing is neutral but DOM is -45%, TBT 101→26ms of headroom)
- ai: 94→**97** (+3 - removing the var() presentation attribute, which is costly in style recalc)
- creator: 81→82 (flat, DOM 1,201→~200)
- The (gaming) hero SVG STAYS inline: JS (landing-pc-gaming.js) inserts `<animate>` + updates telemetry through querySelector → an external `<img>` would break the effect.

**Note:** sections/*.html are baked files that render.php reads directly. Edit them directly here; if you re-bake from landing-clone the inline SVG returns - re-run the SVG-externalizing script. A11Y/BP stay at 100 (`alt=""` on decorative images).

## [1.9.8] - 2026-07-20 (PSI round: Accessibility + Best Practices = 100 site-wide)

**Re-measured all 14 page types × mobile/desktop.** Goal: Perf mobile ≥95, desktop ≥98, A11Y=100, BP=100 (SEO ignored - staging deliberately has Disallow).

### Best Practices 78-79 → 100 (cart, search, contact)
- **Real bug:** `delay_third_party` ran only inside `wpfc_buffer_callback_filter`, but WPFC does NOT run its buffer on pages it does not cache (cart, search, account). Exactly those 2 pages loaded `gtag/js` immediately → 1 long task of 551-583ms = almost all of the TBT 650-670ms → PERF mobile 82, BP 79. Cached pages all scored 95-99/BP 100.
- **Fix (`inc/speed.php`):** added `template_redirect` (priority 0) that does its own `ob_start()` to apply `tandoanh_speed_delay_third_party()` to the very pages WPFC skips. Tracking is KEPT (only the load time moves to the first interaction/12s). This buffer applies ONLY to cart/account/search - because it flushes BEFORE the WPFC buffer, applying it on a cached page would let WPFC minify swallow `type="td-delayed-src"` → GA would never run (measured, and it nearly broke homepage tracking, so it was narrowed).
- Made the delay regex **idempotent** (`([^>]*\s)src=` so it does not match its own `data-td-src=`) → running it several times is harmless.
- **Result:** cart mobile 82→99, search 82→99, all 3 (+contact) BP 78-79→100. TBT 650ms→0ms. GA still runs after interaction (verified in Chrome on both cached and uncached pages).

### reCAPTCHA v3 - contact desktop BP 78 → 100 (tandoanh-code plugin 6.7.5 + 6.7.6)
- reCAPTCHA's third-party cookie (`api2/reload`) dragged desktop BP to 78 (mobile is not affected since the form is farther from the viewport than the 200px rootMargin). The IntersectionObserver is now INSTALLED only after the first interaction → bots/Lighthouse do not load it, while real customers still load it early.
- **Code review found a race (Medium):** if the customer presses Submit before api.js has loaded → empty token → the server blocks a real customer by mistake. Added a **submit guard**: an empty token at submit blocks that attempt, gets the token and then resubmits; 20s fail-safe. Verified: the token field went 0→2105 characters before the real POST.

### Accessibility - creator-studio desktop 96 → 100 (`assets/css/landing-enhance.css`)
- 3 remaining contrast spots: (a) the `.cr-swatch span` clone has a rule (0,3,1)+!important that beat the old fix (0,2,1); (b) `.cr-filmtag-l` had been missed (only the parent `.cr-filmtag` + `-r` were fixed); (c) the `.active` tab of the Discipline section used `--accent-100` #8a6410 = 3.19.
- **Lesson:** do NOT set only `-webkit-text-fill-color` - the browser paints by fill, so the eye sees the right color, but Lighthouse's axe reads `color` → it still fails. You must set `color` itself with enough specificity to win.

### TRIED & DROPPED (recorded to avoid repeating) - landing mobile 84-86
- **Dedicated landing critical CSS** (generated from the real page, keeping all effects): removed render-blocking, FCP 3.2→1.7s but LCP did not move, CLS blew up to 0.998 → PERF 46.
- **content-visibility** (real height measured per section, median of 5 runs): main-thread 11.2→9.3s but TBT 101→257ms, net -5 points.
- **Root cause:** the landing pages are NOT network-bound (CSS done at 221ms, fonts 428ms) but CPU-bound - main-thread 11.2s, styleLayout 3.4s, DOM 5,513 elements (the builds section alone 2,690 = 49% from 5 inline SVGs). The only lever left: replace inline SVG → WebP/AVIF images (needs approval since it touches the design).

### Note on p404
- Lighthouse scores the 404 page 0 because of `ERRORED_DOCUMENT_REQUEST` (it refuses to score any page returning HTTP 404). This is the CORRECT behavior for a 404 page, NOT a site bug. p404 was removed from the measurement suite.

## [BRAND MIGRATION] - 2026-07-20 (Retire `pa_thuong-hieu` → `product_brand` site-wide + sync converts automatically)

Not a theme change (it lives in the plugin + the sync script), recorded here because it affects filters site-wide.

**Background:** the test site is a one-way copy of the live site; the live site stores the brand in the attribute `pa_thuong-hieu`, while the test site already used the `product_brand` taxonomy. Before, the two existed side by side (duplicate data), and every production sync pushed `pa` back.

**Order of work (important - the wrong order breaks filters):**
1. **A normal sync first** (116 products changed, reconcile 24, 0 errors).
2. **Migrate the Build PC filter to `product_brand` BEFORE stripping** - found that Build PC still generates the "Thương Hiệu" (Brand) filter from the WooCommerce attribute registry, so stripping first would empty the filter. Added the helper `bp_attr_taxonomy()` (`tandoanh-buildpc/includes/class-ajax.php`) mapping `thuong-hieu`→`product_brand`, applied at **7 places** that build the taxonomy (list filter, faceting, query, dependency, combined endpoint). The UI/JS key `thuong-hieu` is KEPT, so the frontend needs no changes.
3. **Strip once**: 3,374 products had `pa_thuong-hieu` removed + `_product_attributes` cleaned. A dry run first showed 100% of products already had `product_brand` → 0 risk of losing a brand. 0 products still carry pa.
4. **Sync converts automatically from now on**: fixed **both converters** of the sync step - convert pa→brand and then REMOVE pa.

**Other places that read the brand were checked/fixed:** category filter (index `product_brand`, safe) · PDP (product_brand first, pa only as a fallback) · Google Shopping (already product_brand) · `buildpc/ai/class-ai-index-manager.php` `extract_brand()` (FIXED: reads product_brand first, otherwise the AI index loses the brand).

**Code review caught 6 more points - all fixed:**
- [High] `wp_ajax_hp_sync_one` (MANUAL sync from the admin UI) did not call the converter → pa came back. It now calls `hp_sync_convert_brand()` after import.
- [High] The 2 converters did not check the result of `wp_set_object_terms()` yet removed pa → a scenario where a real brand could be LOST. They now RE-READ FROM THE DB to confirm the brand attached successfully before removing.
- [Med] The "already has a brand, so skip mapping" logic kept a WRONG brand forever when production changed the manufacturer. It now compares the source slug with the current one and re-syncs if they differ.
- [Med] `compute_filters_data()` still built `'pa_' . $aname` (a different variable, so the bulk replace MISSED it) → the combined endpoint filtered wrongly after migration. Now routed through the helper.
- [Low] Meta cleanup is separated from the condition that the pa taxonomy still exists.
- [Low] The fallback filter label is `Thương Hiệu` when the taxonomy is `product_brand`.

**Assigning brands to products that lacked one:** 36 published products had no brand → the brand was inferred from the name (preferring a match to the 166 existing terms, with "TD ..." pre-built PCs having absolute priority = Tân Doanh). 19 products were assigned, and 17 are truly generic goods (cables, thermal paste, pipe cutter, water glass) deliberately left blank. Gotchas avoided: `HP` wrongly matching inside "HPCT", and "PC TD (…Gigabyte X870E…)" nearly being assigned Gigabyte instead of Tân Doanh.

**Final verification:** category filter 24/24 correct for AMD · Build PC filters amd 69 / intel 50, 0 mixed brands, combined endpoint 16/16 correct · PDP kicker shows the brand · `pa_thuong-hieu` = 0 terms/0 products · PHP log empty.

## [1.9.7] - 2026-07-16 (Code review ROUND 2 - 6 more points tightened, including 3 new bugs)

Ran code review round 2 to (a) verify the 7 fixes of 1.9.6, (b) review the new 1.9.5 code, (c) scan areas round 1 did not touch. Code review confirmed fixes **1,2,3,4 are CORRECT** (SSRF bypass attempts with `evilgoogle.com`, `docs.google.com.evil.com`, userinfo `@`, IDN - none got through), but 5,6,7 were not airtight + it found 3 new bugs. Fixed:

- **[REAL BUG - new 1.9.5 code] `inc/landing.php` `/build-price`**: `array_unique($ids)` **dropped components with duplicate IDs** → a configuration with 2 sticks of the same RAM was priced too LOW. Removed `array_unique`. Verified: 1 stick = 60,990,000 (n=1), **2 sticks = 121,980,000 (n=2)**.
- **[Med] 2 public REST routes not throttled**: `/prices` + `/build-price` (`__return_true`, `/wp-json` does not use the page cache → changing the querystring forces repeated `wc_get_product`). Added a throttle of 120 req/minute/IP counted atomically, returning 429.
- **[Med] `assets/js/blocks.js`**: 2 places used `dangerouslySetInnerHTML` to put `title.rendered`/`nameMap` into the DOM → **editor-side XSS** when opening the ProductPicker. Changed to text nodes (React escapes automatically) + a helper `tdDecodeEntities()` using `<textarea>` (RCDATA, does not parse tags) so names still show `&` correctly.
- **[Med] AJAX rate limit (1.9.6) not solid**: `get_transient`+`set_transient` is **not atomic** (a race miscounts) + off-by-one (`>40` lets request 41 through) + it can wrongly block customers sharing an IP. Changed to `wp_cache_add`+`wp_cache_incr` (**atomic** on Redis), with a threshold of **120/minute** (generous for NAT/4G), and it skips itself when there is no persistent object cache.
- **[Low] `inc/woo/pdp.php` `safeUrl()`**: still let through **protocol-relative `//evil.com`** because the regex accepted any string starting with `/`. Tightened to `^(?:\/(?!\/)|https?:\/\/)`. Verified: `//evil.com` → `#`, while `/path` and `https://td...` still pass.
- **[Low] `inc/woo/woocommerce.php` `td_add_build`**: the referer check failed **silently** (a customer with a stripped Referer clicks and gets an empty cart with no idea why). Changed to a redirect with a `wc_add_notice` explaining it.

**Verified and PARTLY REJECTED a code review finding:** Code review said buildband should also drop `array_unique` to keep quantities - but `tandoanh-buildpc/includes/preload.php:68` sets `$selected[$step_id][$pid] = 1` (**qty hard-coded to 1**), so duplicate IDs are merged regardless of `array_unique`. Dropping it in buildband **changes nothing** → left as is. (The builder's qty=1 limit is an existing plugin limitation, noted in 1.9.5.)

**Not done (accepted):** the hand-made `tools/*.py` parser being "brittle" (Low) - they are just build tools run by hand during development, not runtime code that customers execute; fixing means rewriting the parser, which is not worth it yet.

## [1.9.6] - 2026-07-16 (Fixed 7 findings from the code review of the theme - security + performance)

Ran a code review over the whole theme. No Critical/RCE/SQLi/path-traversal; the foundation is sound (admin forms have nonce+capability). Fixed 7 worthwhile items (each self-verified as real, not a false positive):

- **[High] `inc/blocks/registry/newsband/render.php`**: `wp_remote_get('sslverify'=>false)` → turned back to `true` (against MITM of news content embedded into the site).
- **[Med] `inc/woo/checkout.php`**: tax code lookup cache bug - it wrote the transient `'none'` (a string) but reading accepted only `is_array` → a wrong tax code still fired an HTTP call to `api.xinvoice.vn` on every keystroke. Added a branch reading the `'none'` sentinel → short-circuit, ending the needless outside calls.
- **[Med] `inc/buyback-pricelist.php`**: SSRF - `normalize_csv_url()` returned an arbitrary URL (from the shortcode/option) and then `wp_remote_get` fetched it. Added a host whitelist (`docs.google.com`/`googleusercontent.com`/`google.com`); an unknown host falls back to the default source.
- **[Med] `inc/qna.php`**: N+1 (20 questions = 21 queries). All answers are gathered in 1 `parent__in` query and then grouped by `comment_parent` in PHP (keeping the behavior of taking the newest answer).
- **[Med] `inc/woo/woocommerce.php`**: `?td_add_build=` adds ≤15 products to the cart through a GET with no nonce → CSRF cart stuffing. Added a **same-origin referer** check (fits static caching, no nonce needed; the builder's main add-to-cart flow already has an AJAX nonce).
- **[Low] `inc/product-section-more.php` + `inc/top-row.php`**: the public AJAX `td_psec_more`/`td_tb_more` has no nonce (deliberate because of caching) → added a **rate limit of 40 req/minute/IP** (transient) against spam burning PHP/DB.
- **[Low] `inc/blocks/registry/buildband/render.php`**: `maybe_unserialize($raw, [...])` - WP's `maybe_unserialize` does NOT accept `$options`, so `allowed_classes:false` was ignored. Changed to `unserialize($raw, ['allowed_classes'=>false])` after `is_serialized()` → truly blocking object injection.
- **[Low] `inc/woo/pdp.php`**: "Sản phẩm vừa xem" built `innerHTML` from localStorage (a DOM-XSS sink). Changed to `createElement`+`textContent` + a URL scheme guard (blocking `javascript:`).

Note: the review ran in read-only mode. The fixes cover only the theme's OLD code (landing/config 1.9.5 untouched).

## [1.9.5] - 2026-07-16 (Build cards on the 3 landing pages = REAL CONFIGURATIONS, open editable in Build PC, live total price)

Before, the 5 build cards on each landing page were just placeholders: a link to one pre-built PC product, with a hard-coded price; **creator had the entire AI config COPIED by mistake** (names PHOTO/EDIT but pointing to ai-top-atom/100/500). Requirement: compose REAL configurations from separate components, open them editable in Build PC, with real prices.

### Mechanism
- **REST `tandoanh/v1/build-price?sets=id,id|id,id`** (inc/landing.php): returns the live TOTAL price for each set of component IDs (5-minute transient, /wp-json is not cached by WPFC). All 5 builds are batched in 1 request.
- **JS clone** (landing-clone/{slug}.html → regenerate `landing-{slug}.js`): each build reads `cfg.parts` (component IDs) → the card/button opens `/tu-chon-cau-hinh-pc/?td_parts=<ids>` (the builder maps components into steps automatically + **editable** + live price); `.now` = the /build-price total (the hard-coded price is only a fallback shown immediately). The old slug branch is kept as a fallback.
- **Parts-injection script**: inserts `"parts":[ids]` into each build inside `var TDCFG` (a bracket-balancing parser, idempotent). To edit TDCFG or recompose a config, edit the clone and then re-run the landing build script.

### 15 real configurations (composed from the catalog, every ID in stock + the right socket/PSU + the right builder category, each verified)
- **Gaming**: FOX 26.8M · WOLF 31.7M · TIGER 50.3M · EAGLE 65.9M · DRAGON 200M (i5-13400F→Core Ultra 9 285K, RTX 3060→5090).
- **AI**: INFER 56.8M · ATOM 241M · TRAIN 307M · QUAD 384M · XEON 807M (Core Ultra/Ryzen→Threadripper Pro 7995WX + RTX 5090/RTX PRO 6000 96GB + ECC-R RAM).
- **Creator** (composed NEW in a creative spirit, no more AI copy): PHOTO 48M · EDIT 70M · COLOR 109M (ProArt) · STUDIO 280M · MASTER 395M (Ryzen/Threadripper + RTX 4070S→5090).
- Prices are REAL per the TD catalog (higher than the old marketing prices at the high AI/creator tiers because a 5090 is ~124M, an RTX PRO 6000 ~400M, and a TR Pro ~108-260M).
- **Builder limit:** it loads 1 product per step (qty 1) → at the high RAM tiers (128/256/512GB ECC) a single 64GB stick loads and the customer raises the quantity in the builder. The spec text on the card keeps the full positioning.
- Verified live: all 3 landing cards have CTA→builder + the correct total price; opening TD-INFER (?td_parts=8 IDs) → the builder loads all 8 components, editable, total 56,820,000₫ matching the card; 0 console errors.

## [1.9.4] - 2026-07-13 (Account page: removed the FAKE membership tier, replaced with REAL WooCommerce data)

The My Account page showed "GOLD MEMBER · 7,250/10,000 points · upgrade to Platinum · extra 5% off every order + free express delivery for 6 months" - **all HARD-CODED FABRICATION** (every customer saw the same), with NO points/loyalty plugin active and NO code applying a 5% discount anywhere → an empty promise to customers. (A mockup carried over from Hưng Phát on 2026-07-01, never replaced with real data.)

- Ported HP's fix (`woocommerce/myaccount/my-account.php`): removed the tier + points + discount promise block, replaced with **3 REAL stat tiles**:
  - Số đơn hàng (Orders) - `wc_get_customer_order_count()`
  - Đã chi tiêu (Spent) - `wc_get_customer_total_spent()` (shortened "X tr"/"Xk")
  - Thành viên từ (Member since) - registration year
  - The tier badge → "Đã tham gia N năm" (Joined N years) / "Thành viên mới" (New member) (computed from `user_registered`), with a calendar icon in place of the medal icon.
- CSS: removed `.acc-tier-progress` (the fake points bar), added `.acc-stats`/`.acc-stat` (ported from HP, keeping td's gold tokens) + mobile responsive.
- Verified (real user 3276: 0 orders/0₫): shows "Joined 7 years · 0 Orders · 0₫ · 2019", **the fake Platinum/7,250/5% off is gone**, desktop + mobile do not overflow, 0 errors.

## [1.9.3] - 2026-07-13 (Fix bench logic + tier tabs + the FAQ × button - all 3 landing pages)

3 interaction bugs were reported (all from the fake JS clone - written for gaming and copied over, with random fake numbers). Fixed in `landing-enhance.js`/`.css` (running on the live block version, configured per variant).

- **Bench - category tabs do not filter + random numbers:** every `.fps-pill` ran the SAME clone handler → it repainted RANDOM numbers for all rows, and the categories (LLM/Diffusion/Audio/Video) did not filter (clicking Diffusion still showed Llama = nonsense). Fix: cloneNode removes the old handler and rebinds - **category pills FILTER the real rows** (LLM→3 LLM rows, Diffusion→SD/SDXL/Flux, Audio→Whisper, Video→Mochi), **build pills change numbers DETERMINISTICALLY** by the build's coefficient (weak→strong), with no randomness. A row↔category map per variant (ai/gaming/creator).
- **Builds tier tabs "clicking another tab loses everything":** the clone filter matched only the keywords `esport/streamer/4k` (from gaming) → the AI Inference/Training/Enterprise tabs + creator did not match → all cards were hidden. Fix: a tab→card-index map per variant (ai: Inference[0,1]/Training[2]/Enterprise[3,4]) → every tab always shows the right cards.
- **The ugly FAQ × button:** the toggle was a `+` (SVG) rotated 45° into `×` + a DARK gold background. Changed (CSS) to a **chevron** (SVG hidden, drawn with `::after`; closed = down / open = up, a LIGHT gold ring). Cleaner, standard accordion.

Verified on all 3 landing pages: categories filter the right rows (creator Photo=Lightroom+Photoshop…), build numbers are deterministic (314==314), no tier tab has 0 cards (gaming eSports:2/Streamer:1/4K:2; creator all 4 tabs >0), the FAQ chevron rotates correctly, 0 JS errors.

## [1.9.2] - 2026-07-12 (Live landing pages switch to the block version BY DEFAULT)

The 3 bespoke landing pages (`pc-gaming` / `ai-workstation` / `creator-studio`) used to show customers the **inline clone** (a `the_content` filter reading `inc/landing-inline/{slug}.html`); the block version was only a preview through `?td_blocks=1`. The block version now matches the clone 100% (desktop 1440 + mobile 390, 0px offset) and is editable like the homepage → it is now flipped to the default.

- **`inc/landing.php` - flip the default:** the `the_content` filter now returns `$content` early (rendering `post_content` = the `land-hero`/`land-section` blocks) for EVERY visitor. The inline clone branch is KEPT as a rollback/comparison, opt-in through `?td_inline=1` (the old flag `?td_blocks=1` is gone - no longer needed).
- Markup/CSS/JS are untouched: the block path already handles full-bleed itself (the template `page-{slug}.html` attaches `.td-main.tdp-land` layout `default`), the sticky subnav (gaming/creator bake in `.td-subnav`, AI builds it from the `navLinks` of `land-hero`), terminal-hide (land-hero render.php echoes it on the frontend), and the enhance CSS/JS (shared enqueue for both paths).
- Verified: the 3 default URLs (no query) return 200, `tdp-land-inline`=0, with all 7/8/9 land blocks + the subnav present; desktop 1440 screenshots are correctly full-bleed on all 3.

### Build names (fps-pill + comparison table) synced from TDCFG - data mismatch gone
- **Problem:** the build cards take their REAL names from `TDCFG` in `landing-{slug}.js` (AI: `TD-INFER/ATOM/TRAIN/QUAD/XEON`; creator: `TD-PHOTO/EDIT/COLOR/STUDIO/MASTER`), but the build-select pills in the benchmark section AND the comparison table header still kept the clone's old placeholder names (AI: `TD-OTTER/FALCON/ORCA/…`; creator: `TD-AMBER/COPPER/…`) → customers saw 3 different names for the same machine.
- **Fix (single source = TDCFG):** added a sync step in the clone script (`landing-clone/{slug}.html`, after the loop that applies TDCFG to the build cards) - pills whose text starts with `TD-` (the category pills `All/LLM/Diffusion/…` are NOT touched) + the `.compare-table .tier` cells receive the right build name by position. Regenerate `assets/js/landing-{slug}.js` with the landing build script. Edit TDCFG in 1 place → card + pill + compare all follow.
- Gaming was already consistent (`TD-FOX/WOLF/…` baked in match TDCFG) → the sync step is idempotent and changes nothing.
- Verified live (block version, no query): AI + creator + gaming all match in 3 places (pill = compare = card); category pills + tier labels are unchanged; clicking a pill still toggles active + repaints the FPS bar; JS `node --check` is clean, 0 console errors.

### Editable text for the (gaming/creator) hero + every section-head - a safe override, zero regression
- **Problem:** the gaming/creator hero + all section-heads are baked entirely inside `land-section` (the editor can only choose the variant/section, not edit text). The AI hero is already editable through `land-hero`. The gaming and creator heroes have DIFFERENT bespoke structures (`.hero-inner`/`.hero-divider` vs `.cr-hero-*`) → rebuilding each hero in PHP = a high regression risk on a page that has just gone live.
- **Approach (uniform + safe):** added 3 override attributes `ovEyebrow`/`ovTitle`/`ovLead` to `land-section`. Rendering keeps 100% of the baked markup + SVG scene, and only overrides the content of the first element with class `.eyebrow` / `.h-display`|`.h-section` / `.lead` using the helper `tandoanh_land_replace_inner()` - scanning with tag-name balance (no wrong cut when there are child tags of the same name), NOT `preg_replace` (avoiding backreferences in the content). Empty = keep the original → by default nothing changes.
- The sweet spot: every section-head is uniform `.eyebrow`+`.h-section`+`.lead`; all 3 heroes (gaming/creator/AI) are `.h-display` → 1 mechanism covers the hero headline and every section title/subtitle.
- `land-section` editor: removed the "later version" Notice, added a "Sửa chữ (đè clone)" (Edit text (override clone)) panel with Eyebrow (TextControl) + Title/Description (TextareaControl, allowing HTML `<span class="gold">`/`<br>`); the ServerSideRender preview overrides correctly. `npm run build` (wp-scripts) - compiled clean.
- Verified: 12/12 `do_blocks` tests PASS - the eyebrow/title/lead overrides work, the version WITHOUT override is byte-identical to the original bake (0 regression), the SVG scene + subnav + hero-divider + fps-pill are intact; the 3 live pages still return 200, with all markers present.
- REMAINING small increments (not done, low value/rarely edited): overriding the hero BUTTON labels (keeping the arrow SVG) + the hero STAT figures (gold + suffix + star) - repeated/special structures, to be split out later if needed.

### Split seasonal.css per preset - loading ~15KB instead of 60KB when a season is on
- `assets/css/seasonal.css` (60KB, 8 seasons merged) is the SOURCE; added a split script that generates `seasonal-shared.css` (8KB, shared `[data-preset] …` rules + keyframes) + `seasonal-{preset}.css` (5-8KB, the var line/accent/motif-SVG + `.td-sfx i` + the `body::before/after` of that season only). Selectors combining several presets (e.g. dark noel+dong) are duplicated into each related file - integrity 62 vs 59 rule braces as expected.
- `inc/admin/seasonal.php` enqueues `seasonal-shared.css` + ONLY `seasonal-{active}.css` (falling back to the monolith if the split is not generated). The scene inline CSS (side-strip SVG) is also scoped to the active preset (it used to repeat all 8 seasons).
- Verified: enabling `tet` → loads only `seasonal-shared.css` + `seasonal-tet.css` (NO noel, NO monolith); turning the preset off → 0 seasonal CSS (zero cost on ordinary days, PSI unchanged); braces in the split files are balanced; PHP lint is clean.
- To edit the seasonal decoration: edit `seasonal.css` and then re-run the split script.

### FIX the landing comparison table: a white #ececec border leaking from block-library (broken rounded corners + a crooked vertical line)
- **Symptom:** the rounded comparison table on the 3 landing pages "lost the white line" at the corners.
- **Root cause:** the post_content of the 3 landing pages contains blocks (land-section) → WP's `wp-block-library` loads the bare-element CSS `table{border:1px solid #ececec}` + `td{border-right:1px solid #ececec}` (applied globally, present on BOTH the block path and `?td_inline=1` - not specific to the block). The clone DELIBERATELY gives `.compare-table` NO outer border (relying on `.compare-card` `overflow:hidden`+`border-radius:20px`) and cells ONLY `border-bottom`. → (a) the square #ececec border is clipped by the rounded corners = a broken line at the corners; (b) `td` has a white right border while `th` does not = a crooked, wrong-colored VERTICAL white line that is not in the original clone.
- **Fix (landing-enhance.css, scope `[class*="tdp-land-"]`, specificity 0,2,0-1 > bare element):**
  - `.compare-table{border:0}` - removes the outer border (no more broken corners).
  - `.compare-table td,th{border-top:0;border-right:0;border-left:0}` - removes the leaking vertical/top borders while KEEPING the clone's `border-bottom` → only HORIZONTAL lines remain, matching the original landing.tandoanh.vn clone.
- QA also scanned the whole landing CSSOM for rules leaking from outside sources (bare elements/`.wp-*`/`.entry-*`) that apply to `.tdp-land-*`: only 4 hits - 2 harmless (`*{box-sizing}`, `a{color}` - the `.build-cta` button with a bold arrow on a gold background has the CORRECT contrast), 2 fixed above. 0 console errors.
- Verified live: computed `.compare-table` border=0, cell border-right=0, border-bottom keeps the horizontal line; AI + gaming screenshots are clean; build names are still correct INFER/ATOM/…

## [1.9.1] - 2026-07-10 (Security + Performance audit of theme 1.9.0 - 3 agents in parallel, self-verified)

Re-audited the whole theme after the landing rebuild with blocks (a lot of new code since 1.7.9). 3 independent agents (runtime security / baked-content + JS security / performance), and every finding was SELF-VERIFIED again before concluding.

### Security - NO critical/high/medium vulnerabilities
- **Path traversal is SAFE:** `land-section/render.php` joins `sections/{variant}-{section}.html` with `variant`+`section` both passing through `sanitize_key` + a hard 2-layer whitelist → `../` cannot be injected. `terminal-ai.html`/`scenes/{variant}.html` are the same.
- **XSS is SAFE:** every echoed attribute is escaped correctly (`esc_html`/`esc_url`/`esc_attr`); `titleHtml`/`brand` use `wp_kses_post` (block attributes, requiring `edit_posts`). The inline hide-script is static, with no input interpolation.
- **Baked HTML is clean:** 0 inline onclick (avoiding the old wptexturize bug), 0 unknown `<script>`, 0 `javascript:`/`on*` handlers.
- **JS is clean:** the terminal + CTA + subnav use `textContent`; the LIVE price from REST is assigned with `textContent` (line 119) - even if the endpoint returned malicious HTML it would show only as text. `innerHTML` is used only with the developer's hard-coded `TDCFG`. No `eval`/`Function`.
- INFO (no fix needed): the REST route `tandoanh/v1/prices` is a public GET but exposes only already-public prices + a transient cache; `postMessage(..,'*')` runs only in the `inFrame` branch (in the production inline version `inFrame=false` → dead).

### Performance - a good foundation, 1 fix
- **[FIX] Double scroll-spy on the inline page:** the inline script (landing.php) AND landing-enhance.js both bound `scroll` + `getBoundingClientRect` on `.td-subnav` → 2× layout reads per scroll. Trimmed: the inline script ONLY builds the subnav links from `.header .nav`, while click + scroll-spy + sticky are left to landing-enhance.js (1 handler for both inline and block). Verified: gaming inline subnav has 6 links, click scrolls and activates correctly, 0 errors.
- Noted (a content decision, not a bug): the inline `pc-gaming.html` is 383KB - heavy for LCP/TBT on a cache-miss request (Brotli/gzip already reduce it; WPFC caches the full HTML). The editor loads ~360KB CSS (the editorStyle of the 3 landing pages) - ONLY in admin when opening a landing page in the editor, not touching customers.
- Clean: the business-info shortcode (`td_founded`/`td_years_exp`) is static-cached + 1 `get_option`; the cheap early return of the the_content filter; no N+1/new autoload junk; the live page renders 1 readfile (not 7-9 blocks as feared - the block path is editor/`?td_blocks` only).

### Other
- Fixed the file ownership of `land-section/render.php` for consistency; confirmed the server blocks access to the build tools folder (the build tools are not exposed).

## [1.9.0] - 2026-07-10 (Landing block: editor like the homepage + fix dead buttons + taste QA for buttons/borders)

- **Editor like the homepage:** wrote the editor JS for `land-section` (choosing the variant/section + a ServerSideRender preview) and added new fields to the AI `land-hero` (eyebrow/titleHtml/stats num-unit-lbl-pct/link-style buttons). Built through `wp-scripts`. Declared `editorStyle` = the clone CSS (landing-{variant}.css) in block.json → the preview INSIDE the editor iframe shows styled exactly like the frontend. Verified: opening the AI page in the editor = 1 land-hero + 7 land-section, SSR renders styled, 0 block errors.
- **Fix dead buttons (dead on both inline and block):** the primary `.btn` buttons ("Workload Match"/"Khám phá 5 cấu hình"/"Đặt thuê thử"…) were `<button>` with NO href/handler (the clone captured them via the React runtime, which did not make it into the static clone). Added a handler in `landing-enhance.js`: mapped by label → scroll to the section (#workload/#bench/#builds/#compare…); buttons in the promo → `/lien-he/`. Runs on all 3 landing pages (AI/gaming/creator), 0 errors.
- **Taste QA for buttons/borders:** added the missing hover/active/focus (`.btn-link` brightens text + pushes the arrow; `.fps-pill`/`.btn-ghost` gold border + text; `.part-card` lifts + gold border + tinted shadow; `.workload-item` hover border) + a `translateY(1px)` press + a gold focus ring (a11y). Placed in `landing-enhance.css` scoped to `[class*=tdp-land-]` (the generated clone CSS is not edited, no leaks). 0 horizontal overflow on all 3 pages.
- **Still open:** the fps-pill (bench) shows the placeholder name TD-OTTER while the build card fetches live TD-INFER - a small data mismatch (the fps-pill is not yet wired to live). Switching the 3 live pages from inline→block: awaiting approval.

## [1.8.9] - 2026-07-10 (Phase 6 - gaming + creator built with blocks, all 3 landing pages match the clone pixel-perfect)

Replicated the block template for the other 2 landing pages. All 3 (ai/gaming/creator) now render with separate blocks, 100% identical to the clone on desktop + mobile.

- **`land-section` extended:** added the sections `hero/fps/discipline/display`. The script `extract_variant.py` extracts the hero (with the subnav built from the clone's `.header .nav`) + every section → `sections/{variant}-{id}.html`, baked as is.
- **gaming** (104449): hero + fps/parts/builds/compare/promo/faq. The PC telemetry scene (CPU/FPS/RAM/GPU HUD) + subnav match.
- **creator** (104451): hero + discipline/bench/display/builds/parts/compare/promo/faq. The DaVinci scene (LIFT/GAMMA/GAIN/OFFSET + timeline + render queue) + subnav match.
- **`landing-enhance.js/.css` generalized:** dropped the hard lock on `.tdp-land-ai` → runs on every variant (subnav scroll-spy for all 3; terminal typing + GPU bar limit themselves to pages that have `.ai-terminal`).
- **Automated verification (self-compare):** gaming desktop 1440 + mobile 390 = every section DIFFERS by 0px; creator promo/compare/builds measured separately = 0px (the full-page numbers differ only as an artifact of measuring lower sections before they settle, already proven on AI). 0 horizontal overflow, 0 JS errors, dark mode clean. The AI terminal types the full 13/13 after the refactor.

REMAINING: editor JS (drag-and-drop/text editing in Gutenberg) + switching the 3 live pages from inline to block. The customer pages STILL show the inline clone (untouched); the block version is at `?td_blocks=1`.

## [1.8.8] - 2026-07-10 (Phase 2-5 AI - the ai-workstation page built with BLOCKS, matching the clone pixel-perfect)

Finished rebuilding the ai-workstation page with separate blocks, 100% identical to the clone.

- **Block `land-section`** (new): 1 section = 1 block, `readfile` of the clone markup baked as is (workload/bench/parts/builds/compare/promo/faq → `sections/ai-*.html`). Interactions (FAQ accordion, tier filter tabs, animated benchmark bars, reveal-on-scroll) reuse the clone JS `landing-ai-workstation.js` - same selectors, working correctly.
- **Subnav** built from the `navLinks` of `land-hero` (CSS + scroll-spy JS in `landing-enhance.*`); navLinks map to the clone's anchors (#workload…#faq).
- Page 104450 post_content = `land-hero` + 7 `land-section` (separate blocks, drag-and-droppable once the editor JS exists).
- **Automated verification (self-compare of the block ?td_blocks=1 vs the clone):** every section DIFFERS by 0px on desktop 1440 AND mobile 390; 0 horizontal overflow; 0 JS errors; dark mode clean. The build names/prices (TD-INFER/ATOM/TRAIN/QUAD/XEON) are fetched live from Woo - block == clone.
- **Finding:** the build names TD-OTTER… in the static HTML are just placeholders; the live JS replaces them with real Woo products → both the clone and the block show the same TD-INFER….
- The customer-facing live page STILL shows the inline clone (not switched yet) - the block version is viewable at `?td_blocks=1`. Switching live + editor JS: the next step.

REMAINING: editor JS (drag-and-drop/text editing), switching the AI page live to blocks, and replicating to gaming + creator (Phase 6).

## [1.8.7] - 2026-07-10 (Phase 1 - rebuilding the landing pages with blocks: faithful + editable AI workstation hero)

Started rebuilding the 3 landing pages with SEPARATE BLOCKS, with the constraint that the hero + SVG + scene must LOOK like the clone. Architecture: each section = 1 block; the visual parts (hero/terminal/SVG) bake the clone markup AS IS into render, pulling only the text out as editable attributes. CSS uses `landing-{slug}.css` (scoped `.tdp-land-{variant}`), which is already loaded.

- **`land-hero` variant `ai`:** rewrote render to output markup identical to the clone (`.hero.ai-hero` + eyebrow + gold h1.h-display + lead + primary/link buttons + 3 stats + terminal). The terminal is baked into `terminal-ai.html` (13 lines + 4 GPU meters). New attributes: `eyebrow`, `titleHtml` (RichText, keeping `<span class=gold>`+`<br>`), `stats[num/unit/lbl/pct]`, `buttons[label/href/variant primary|link]`. The gaming/creator branches are unchanged (Phase 6).
- Connected `landing-enhance.js` (character typing + GPU bar) - the block outputs `.tdp-land-ai .ai-terminal` and it runs by itself. Added an inline-hide script in render (against flicker), on the frontend only (skipping the editor/REST).
- Preview flag `?td_blocks=1` (`inc/landing.php`): renders post_content (blocks) in place of the inline clone, to build + compare WITHOUT touching the live page (customers still see the inline clone, 100% identical).
- Set the block attributes of page 104450 to match the clone. **Verified: the block hero matches the clone pixel-perfect** (eyebrow/headline/lead/buttons/3 stats/terminal typing text + color + bars), 0 JS errors. The remaining difference: the subnav bar (Phase 5 - assembling the page).

REMAINING: workload/benchmark/components/5 configurations/comparison/promo/faq (Phase 2-4), assembling the page + subnav + turning off inline (Phase 5), replicating to gaming+creator (Phase 6).

## [1.8.6] - 2026-07-10 (AI workstation terminal: type CHARACTER BY CHARACTER + color fix + GPU bars counting %)

Version 1.8.5 (a fade per line) was wrong: the original landing types CHARACTER BY CHARACTER with a loading beat, the text COLOR was wrong (text lost), and the 4 GPU bars did not jump in %. Compared with the original landing.tandoanh.vn (React): the terminal types ~1 character per 15ms after ~450ms of loading; the command line is bright `rgb(234,240,252)`.

- **COLOR FIX (a real bug):** the terminal is a DARK panel, but in light mode the page's `--ink-*` tokens become DARK colors (meant for light backgrounds) → the command line `t-cmd` (using `--ink-0`) came out as dark `#1a1510` on a dark background = text lost. Re-scoped `--ink-0..3` back to the dark values (light text) ONLY inside `.ai-terminal` in light mode (`landing-enhance.css`). After the fix: t-cmd in light is `rgb(251,248,243)`.
- **Character-by-character typing (replacing the per-line fade):** `landing-enhance.js` - hides the lines, reads the text, and types each character in sequence (command 11ms, output 4ms, 55ms pause per line) after the 450ms loading beat; a blinking `.td-term-cursor` follows along. It finishes in ~5s (the original ~4s). Removed all of 1.8.5's nth-child stagger CSS.
- **4 GPU meters counting %:** the `.ai-meter-bar>div` bar fills 0→target (1.15s transition) + the % number `.ai-meter span:last-child` counts up with easeOutCubic.
- **Anti-flicker:** an inline script (variant ai only) hides the terminal lines IMMEDIATELY at parse time (before paint) → no flash of full text before typing. It triggers when the terminal enters the viewport (the hero is above the fold, so almost immediately on load).
- `prefers-reduced-motion: reduce`: shows the full text + bars at their targets immediately, no typing. The enhance file is NOT auto-generated (safe when the clone is rebuilt). Verified: char-by-char typing 0→23→66→169→261, bars 62%→98%, bright colors in both light/dark, 0 JS errors.

## [1.8.5] - 2026-07-10 (AI workstation: hero terminal types line by line like the original landing)

- The `ai-workstation` landing hero: the 13 terminal lines appeared AT THE SAME TIME (0.18s fade) instead of "typing" in sequence like the original landing. Cause: the typewriter effect is **React runtime JS** on the original landing, which did NOT make it into the static clone - so the old iframe did not have it either; it is not a regression from inlining.
- Fixed with **pure CSS** (`assets/css/landing-enhance.css`): a staggered `animation-delay` on the existing `ai-typed` keyframe (0.15s + 0.13s×n) + `animation-fill-mode: backwards` for `.ai-terminal-line:nth-child()` → a line stays hidden until its turn = typing line by line, finishing in ~1.9s. No JS added, no flash (the fill-mode keeps lines hidden before their turn).
- The enhancement file is **NOT auto-generated** and is enqueued AFTER the clone CSS (`inc/landing.php`), so rebuilding with `inline_clone.py` does not wipe it. Scoped to `.tdp-land-ai` → the other 2 landing pages (which have no `.ai-terminal-line`) are not affected.
- `prefers-reduced-motion: reduce` → shows all 13 lines immediately. Verified: sequential typing 0→2→6→10→13 lines over time, reduced-motion 13/13 immediately, pc-gaming/creator-studio 0 errors.

## [1.8.4] - 2026-07-10 (Product FAQ: old Flatsome shortcodes → native accordion)

- 193 products (monitors, coolers…) have an FAQ section stored with the old Flatsome `[accordion]/[accordion-item]` shortcodes. The block theme does not register this shortcode, so it showed **literally** (`[accordion title=…]`) on the page.
- Ported the converter from the `hungphat-laptop` theme (`inc/content-enhance.php`): renders with native `<details>/<summary>` - accessible, NO JS needed, content always in the DOM (good for SEO). Prefix `td-accordion`, functions `tandoanh_*`.
- Normalized curly quotes: Flatsome content stored `title=”…”` (curly), so `shortcode_parse_atts` could not read the attribute → a `the_content` filter at priority 9 changes curly → straight quotes ONLY inside the `[ ]` pair, leaving curly marks in the body intact.
- CSS in `single-product.css` (+ `.purged.css`), scoped to the PDP since all 193 affected posts are products. The active color uses `--td-brand-strong` (already theme-aware: dark gold #8a6a00 in light / bright gold #f0c75e in dark), so the dark override rule HP needed is **not required**.
- Verified on 5 products: 0 literal shortcodes, the first item `auto_open` opens by itself, open/close interaction OK, active head contrast 5.07:1 (light) / 12.28:1 (dark) meets AA, 0 JS errors.

## [1.8.3] - 2026-07-10 (Ministry of Industry and Trade logo + backlink in the footer)

- The MOIT (Bộ Công Thương) badge was **hotlinking** `https://static.tandoanh.vn/wp-content/uploads/2020/02/logoSaleNoti.png` and pointing to an old record `…/2d6b68ce-…`.
- The new image `DaThongBao-BCT.png` (360×111) was **uploaded to the Library** (attachment ID 104715) → `/wp-content/uploads/2026/07/DaThongBao-BCT.png`; no more dependence on an outside domain. (The first version, 360×135, was replaced by a tight crop at 360×111: the file was overwritten, `wp media regenerate 104715` made the metadata + thumbnails match, keeping the URL and attachment ID unchanged.)
- The backlink changed to `http://online.gov.vn/nen-tang/67bfee06-f6fa-4856-943e-48f7b8b6ca8c`. Opened in a real browser to verify (the page is an SPA, `curl` cannot read the content): it is correctly `tandoanh.vn` - Công ty TNHH TH Hoàng Phương, tax code 0313792226.
- Updated through the option `tandoanh_business_info` (Admin → Thông tin DN (Business info) → Link nội bộ (Internal links)) **and** fixed the default `links.bct` in code so that even if the option is deleted it does not fall back to the old link.
- `site-footer/render.php`: the `<img>` width/height `170×64` → `360×111` to match the real image ratio (3.243) - a correct aspect-ratio placeholder, mobile homepage CLS 0.0024. The display size is still set by CSS (182×56).

## [1.8.2] - 2026-07-10 (Homepage brand card hover turns link blue)

- The "Thương hiệu uỷ quyền trực tiếp" (Directly authorized brands) block (`.td-brandwall`): hovering a card (INTEL, AMD, ASUS…) turned the text **link blue `#14529e`** on the gold background instead of the accent text color - the text sank into the background, wrong tone.
- Cause: the global rule `a:hover:not(.td-btn):not(.button):not(.wpcf7-submit):not([class*="__buy"]):not([class*="__cart"]):not([class*="td-pdp-chat"])` at `base.css:55` has specificity **0,7,0**, overriding `.td-brand:hover { color: var(--td-on-brand) }` (0,2,0) in `templates/home.css:320`. The same class of bug as the "Mua ngay" button (1.6.x) - any card that is an `<a>` and not in the `:not()` list gets hit.
- Fix: added `:not(.td-brand)` to the global rule, **also fixing `base.purged.css`**.
- Verified: hover + `:focus-visible`, both card types (regular and `--star`), light + dark ⇒ text `#1a1505` on the gold background, contrast **10.82:1** (light) / **11.70:1** (dark), meeting WCAG AA.

## [1.8.1] - 2026-07-10 (Years of experience on the About page: hard-coded → computed)

- The figures strip on the `/gioi-thieu/` page hard-coded `2007` (founding year) and `17+` (years of experience) in `post_content`. Wrong - 2026 − 2007 = **19** - and it would be wrong again every year.
- Added `misc.founded_year` (default 2007) to Business Info + a **"Năm thành lập"** (Founding year) input in the admin.
- Helpers `tandoanh_founded_year()` / `tandoanh_years_exp()` (using `wp_date('Y')`, so they follow the site timezone) + 2 shortcodes that **return a bare number, with no wrapper tag**: `[td_founded]`, `[td_years_exp]` - dropped straight into the existing markup `<span class="tdp-about__num">[td_years_exp]<i>+</i></span>` without generating nested `<span>` the way `[td_biz]` does.
- The `post_content` of page 104669 changed the 2 hard-coded numbers into shortcodes. Change the founding year in the admin and both fields update automatically; next year the years of experience rise on their own.

## [1.8.0] - 2026-07-10 (3 landing pages: removed the iframe, embedded the clone DIRECTLY in the DOM)

The 3 bespoke landing pages (`pc-gaming` / `ai-workstation` / `creator-studio`) were embedded with an `<iframe>` pointing to the static clone file `landing-clone/{slug}.html` (pc-gaming 502KB). Consequences: mobile LCP ~3.2s, and the content sat in a child document so **search engines did not index it under the page URL**. This session removes the iframe but **keeps the clone absolutely identical** (per the earlier direction) by embedding the clone's markup + CSS + JS straight into the page.

### Approach
- The build script splits each clone file into 3 parts and **scopes all CSS under `.tdp-land-{variant}`** (`:root`/`html`/`body` → wrapper; `html[data-theme=…] body …` → drop the `body` token; `@font-face`/`@keyframes` kept; `@media`/`@supports` recursive). A scope-check script confirms: **734 / 774 / 823 selectors, 0 slipping through** ⇒ the clone CSS does not leak into the theme or the other way round.
  - `assets/css/landing-{slug}.css` (112-122KB), `assets/js/landing-{slug}.js` (14KB, defer), `inc/landing-inline/{slug}.html` (markup).

### GOTCHA of the CSS scoper (it crashed once and lost light mode)
- **The tokenizer must skip comments and strings when scanning for `{` `;` `}`.** The clone CSS has a comment containing a semicolon: `/* … giữ gold; panel thiết bị vẫn tối */`. A raw scan cut at the `;` inside the comment ⇒ the rest of the comment was treated as a selector ⇒ it produced `.tdp-land-gaming panel thiết bị vẫn tối */html[data-theme="light"]{…}` = a broken selector, and the browser **dropped the entire rule** ⇒ the landing's whole light mode died (a black background in the middle of a light site).
- **A comment right before a selector sits in the same prelude** → it must be split off before scoping, or the scope gets inserted in the middle of the comment.
- **The verify script must use THAT SAME tokenizer.** The first verify version scanned raw and so reported "0 slipping" while the CSS was broken - a worthless check. Verify now adds a condition: the scope must not appear inside a comment.
- `inc/landing.php`: the `the_content` filter pours the markup into `<div class="tdp-land-inline tdp-land-{variant}">` + enqueues CSS/JS by slug. The sticky sub-nav `.td-subnav` is built from the clone's nav (the original nav is `display:none`), and now reads directly in the same document instead of through `contentWindow`. Added `scroll-margin-top:120px` for `[id]` so the site's sticky header does not cover the top of a section.
- The clone JS already had a branch `inFrame = window.parent !== window` ⇒ running standalone it scrolls by itself, with no edit needed.

### Measurements (mobile, CPU 4×, Slow 4G, after the WPFC cache)
| Page | LCP before (iframe) | LCP after (inline) | CLS |
|---|---|---|---|
| pc-gaming | 3168ms | **1232ms** | 0.033 |
| ai-workstation | - | **1952ms** | 0.004 |
| creator-studio | - | **1248ms** | 0.037 |

- Pixel comparison with the standalone clone: `#builds` is **1188px tall in both**, and the total height of pc-gaming is **7429px in both** ⇒ geometry matches exactly. The filter tabs, SVG images in cards, TD-EAGLE, and live prices (REST `tandoanh/v1/prices`) are all intact.
- QA of 3 pages × {mobile 390px, desktop 1440px} × {light, dark}: 0 horizontal overflow, 0 JS errors, 0 requests ≥400.

### Notes
- The `tandoanh/land-*` block set is still in `post_content` and in the theme (masked by `the_content`). During the investigation 3 bugs in the block-native version were fixed (missing enqueue of `landing-motion.js`; `landing-scene-gaming.css` was never loaded so the 4 hero HUD cards fell back to text; the kill-switch `.td-gaming-card{display:none}`) - kept in case an editable version is needed.
- `landing-clone/` is the **source**: edit the clone and re-run `inline_clone.py`; do not hand-edit the generated files.

### Other
- Footer: added a **Giới thiệu** (About) link (`/gioi-thieu/`) at the top of the "Hỗ trợ mua hàng" (Shopping support) column, with a URL input in the admin → Thông tin DN → Link nội bộ.
- Admin Mega Menu: the 3 action buttons had uneven heights (the Save button has `button-hero`) and the Save text was overridden by `.hplt-admin .button { color: var(--hplt-ink) }` into dark ink on the blue gradient background (2.3:1). Fix: unified at 44px, `color:#fff !important` for `.button-primary` ⇒ 5.27:1 meets AA.
- Admin Installments: the icon buttons Chọn logo / Gỡ (Choose logo / Remove) grew from 30px to 40px, equal to the height of the input in the same row (the 40px rule across the admin). Audit script over 13 admin pages: **0 issues**.

## [1.7.9] - 2026-07-10 (Security + Performance audit of the whole theme - 6 areas, 6 agents)

A comprehensive audit of the theme (183 PHP files / 23k lines + 134 JS) through 6 parallel agents (4 security: authz/SQLi/XSS-file-SSRF/secrets-CSRF-money; 2 performance: DB-query/assets), with every finding self-verified through `wp eval`/query measurement. The baseline code discipline is good: **no critical/high vulnerabilities** - nonce+capability on every AJAX/admin-post, SQL is all `prepare`/int-cast, no hard-coded secrets, no trust in client prices. Fixes applied:

### Performance
- **[BIGGEST] The PDP Performance tab (VGA/CPU) ~397 queries / ~1s per render, NOT cached** (`inc/perf/perf-data.php`): `td_bench_chip_value` (2 queries per call, uncached) is called in a nested loop `foreach metric × foreach 16 cards in the same line`. Each product save → WPFC purge → every VGA/CPU page rebuilt at this cost. **Fix**: cached the 5 pure-per-chip functions (`gaming_matrix`/`ai_matrix`/`cpu_matrix`/`gaming_self`/`self_groups`) in the object cache group `td_perf` with a 12h TTL. Saving a benchmark in the admin calls `wp_cache_flush()` (`tandoanh-benchmarks/admin-page.php:72`) so the cache clears itself - no risk of showing old numbers. Measured for real: **397 q/1035ms → 0 q/2ms when warm** (100% reduction), cold==warm output matches exactly, invalidation verified.
- **`SHOW TABLES` ran on every request** (`inc/woo/checkout-extras.php:157`, hook `init` prio 1): the promo table exists permanently but `SHOW TABLES LIKE` still ran on each request. Fix: an autoloaded option flag `td_promo_table_ready`, returning early ⇒ −1 query/request.
- **Tax code lookup (`td_lookup_tax_code`)** called the HTTP `api.xinvoice.vn` every time the customer typed, with no cache (`inc/woo/checkout.php`): added a transient per tax code for 12h (a nonexistent tax code is cached for 10 minutes) - cutting repeated outbound calls + protecting the nopriv endpoint from abuse.

### Security
- **[CONFIRMED, medium] CSV Injection** (`inc/woo/checkout-extras.php`, promo subscribers export): a guest (nopriv) enters `billing_first_name` = `=HYPERLINK(...)` / `=cmd|'/c calc'!A1` → saved in the DB → an admin who exports the CSV and opens it in Excel/LibreOffice **executes the formula**. `sanitize_text_field` does NOT filter `= + - @`. Fix: prefix `'` on every cell that starts with `= + - @ \t \r` before `fputcsv` (tested with 6/6 payloads).
- **PDP "recently viewed" JSON printed straight into `<script>`** (`inc/woo/pdp.php`): a product name containing `</script>` (entered by a shop manager) could break the block. Fix: `wp_json_encode(..., JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT)` (defense in depth).
- **Defensive object injection** (`inc/blocks/registry/buildband/render.php`): `maybe_unserialize($raw, ['allowed_classes'=>false])` for builder session data.
- **AI title translations stored raw** (`inc/i18n/ai-translate.php`): `wp_strip_all_tags` on `_td_en_title` before saving (the title is printed through `the_title`, guarding against an AI provider returning HTML).
- **Missing `esc_like`** (`inc/woo/checkout-extras.php:251`, subscriber search): a user typing `%`/`_` became a wildcard (functional, not SQLi since it is already `prepare`d). Fixed with `$wpdb->esc_like`.

### Fix: the PDP Facebook/Zalo chat button is hard to see on hover
- **2 stacked bugs**: (1) the hover background was the original FB blue `#1877f2` + white text at only 4.23 (below AA); (2) WORSE - the hover text was NOT white but became link blue `--td-link-hover` (blue text on a blue background, contrast 2.24, almost the same color) because the global `a:hover:not(.td-btn)...` (specificity 0,6,1) overrode the `color:#fff` of `.td-pdp-chat__fb:hover` (0,2,0) - the chat button is an `<a>` not on the exclusion list (exactly the old bug recurring).
- **Fix**: (a) FB hover background `#1877f2→#1465d8` (white 5.41), Zalo `#0068ff→#0059dd` (6.05); (b) added `:not([class*="td-pdp-chat"])` to the global a:hover (base.css + base.purged.css); (c) added `[data-theme="dark"] .td-pdp-chat__fb:hover/__zalo:hover{color:#fff}` because the dark default (pale blue text) comes later and overrides the hover color. Verified with Playwright in 4 states (light/dark × default/hover), all with AA text: default 5.41/7.2, hover 5.41 in both modes.

### Notes (NOT bugs - verified)
- `mix-blend-mode: multiply` at `page-tu-chon-cau-hinh-pc.css:608` is **dead CSS** (a false positive of the asset audit): the last 2 blocks with the same selector override `mix-blend-mode:normal` + `img{display:none}` (replaced by a `::before` SVG mask) → multiply is NOT active and causes no repaint.
- **`seasonal.css` (60KB) loads all 8 presets site-wide when a season is on** (~13KB of extra gzip per page): the biggest remaining asset finding, BUT the preset is currently OFF so the current impact = 0 (the file is not enqueued). Splitting per preset is a big refactor touching the data-URI scene mechanism - **awaiting approval** before doing it.
- LOW items not fixed (by design/bounded): the public REST `/qna` (honeypot+rate-limit+hold), the SSRF in the i18n endpoint/buyback URL (admin/editor-only), the `td_cart_count` cookie lacking HttpOnly (only a cart counter), and 682KB of autoload caused by core + other plugins (the theme's own options are all autoload=off for the large ones).

## [1.7.8] - 2026-07-09 (Seasonal v4 - redesign following the taste skill)
### Interface
- **Seasonal v4 - a full redesign of the seasonal decor**: changed from a multicolor clip-art language to **single-tone line art + restrained hairlines**:
  - **Side backgrounds**: each season has a vertical engraving-style line-art tile (stroke 1.6-1.7, monochrome `--td-season-line` + a very limited accent) + 2 layers of soft **radial-gradient orbs** for depth + a **mask-image fade** that dissolves toward the middle of the page; the right side mirrors it. Opacity .5 light / .32 dark.
  - **Card frames**: removed candy stripes/thick brackets/double borders - the default zone = a **2px hairline at the top that fades at both ends** (leaving 14% margins), the TOP zone + odd homepage sections = **2-stroke thin corner ticks** (top-left strong, bottom-right 55%), the shop zone/even sections = a BOTTOM hairline; the card background is only a 3.5% wash at the top, and the image-corner motif is redrawn as **21px mono line art at op .55** (a watermark, no more sticker look).
  - **Ornament next to the LOGO**: a 26px line-art vector right beside the logo for each season, in a LIGHT tone for the ink-colored header (yellow apricot blossom + red stamens / pine + star / yellow star / sun / snowflake / pumpkin / lantern + moon / graduation cap) - hidden below 640px.
  - **Falling effects**: reduced from 26 to 16 particles (mobile 9), falling slowly at 12-22s, small particles 5-7px; **depth**: 1/3 of the particles blurred 1.2px + faded + small, 1/4 sharper; a slightly curved trajectory (3-stop keyframe); removed all the flashy clip-paths (big bats/planes/10-point stars) - replaced with small petals/soft snow/warm golden sparkles/sun dust/muted autumn leaves/two-tone pale paper confetti.
- **Seasonal admin page redesign**: the menu tab is renamed in English **"Seasonal"** (matching the EN policy menu), H1 "Seasonal - Giao diện theo mùa" (Seasonal - Seasonal theme); the **season picker grid is FIXED at 3 columns**; double-bezel style cards (an outer shell + a nested sample image, concentric radii), the hidden radio replaced with a blue ring + a check-mark chip, hover lifts slightly + a diffused shadow, the "Xem thử ngoài web" (Preview on the site) button becomes a pill with a round icon (the icon slides diagonally on hover), transition cubic-bezier(.32,.72,0,1). The 8 sample images were re-captured with the v4 art.
- Verified after the redesign: 8/8 presets have strip + motif + hairline OK (Playwright, injected client-side), and the admin renders correctly in EN + 3 columns + bezel.
### Seasonal v14 - contrast standardized to PSI/WCAG AA
- **COLOR PRINCIPLE FOR THE WHOLE SYSTEM (base theme + 8 seasons)**: LIGHT background → DARK text, DARK background → LIGHT text; contrast ratios meet WCAG AA (text ≥4.5, large text/UI ≥3.0 - exactly the yardstick of Lighthouse/PSI Accessibility) BUT only the lightness/darkness is adjusted within the same tone, without changing the premium hue.
- **Fix of the "pitch-dark text" (Summer season)**: Summer is the ONLY season whose light mode used dark text (#241200 on orange #d96407) - reproducible on the real customer path (it is not a cache issue). Fix: the orange is one step deeper, `#c2410c` (hover/strong `#a83c05`), so it uses **cream text #fff3e6** like the other 7 seasons - measured light 4.74 OK; dark Summer locks `--td-on-brand:#241200` (a bright orange background → dark text, per the principle; before, it inherited cream from light, which would have broken).
- QA LESSON: tests of decor/colors MUST go through the real customer path (the server turns the preset on + the WPFC bundle + the purged CSS swap of speed.php), NOT hand-injected CSS - injection hid this bug for many rounds.
- **axe-core audit (the exact engine of PSI a11y) 90 runs (5 pages × 9 states × light/dark)**: 160 instances → 8 root-cause clusters, ALL fixed:
  - **New token `--td-brand-bright`** (tokens.css :root + 8 presets): accent TEXT on FIXED dark backgrounds (footer #1e1e23, bestsellers column head/topbar #15151a) - the light preset's deep brand (flag red #ad1a14 only 2.31) sinks into a dark background; its value = the brand of the DARK preset (5.18-11.3 OK). Applied to: `.footer-hours__head`, `.footer-col__title` (border), `.footer-contact svg`, `.topbar a:hover` (layout.css + purged), and the "Xem tất cả" (View all) link `.td-tb__head a` (top-row.php).
  - **BuildPC pill/btab active overridden by specificity** (root cause of "GAMING · 28M" at 2.04-2.68): `html[data-theme="light"] .td-bp__pill` (0,2,1) BEAT `.td-bp__pill.is-active` (0,2,0) → the active text got stuck at ink .72 blended over the season's brand background. Fix in home.css + purged: added a light override `.is-active{color:var(--td-on-brand)}` (4.74-10.82 OK), `.is-active .m{opacity:1}` (no more 4.45 on quoc-khanh dark), btab active light = `color-mix(strong 72%, #1a1510)` (6.03-9.52; the original pure gold strong was only 4.12), `.swap:hover` #16130a → var(--td-on-brand).
  - **Struck-through price in the bestsellers carousel dark 3.74** (the only bug of the BASE THEME): legacy wp_extra `del{opacity:.8}` - forced `opacity:1!important` following the woo-card 0.9.6 pattern (dark 5.07 / light 4.97 OK).
  - **Noel `--td-brand-strong` #1e7a46 → #1b6f3e** (the star/hotline brand pill on the pink mix background 4.46 → 5.17).
  - **Summer `--td-info` #0bb5c4 → #077e89 in light** (the "Chính hãng" (Genuine) badge with white text 2.49 → 4.82); dark Summer gets the bright teal #0bb5c4 back since the dark badge text is navy (5.9 OK).
  - **The "just bought" popup link (woo-notifications)** was a fixed inline #8a6a00 → overridden with `var(--td-brand-strong)!important` in plugin-overrides.css: it changes by season + ≥5.07 on white in every preset (the Winter season no longer sinks into gold at 2.27); the `<small>` timestamp, inline #94a3b8 (2.56) → #54545f light / #a6a6b0 dark.
- **GOTCHA: wptexturize breaks inline onclick (Lighthouse PDP 97→100)**: `onclick="...window.location='...'..."` in content-single-product.php had its single quote changed by wptexturize into `&#8217;` in the cached HTML → the attribute never closed, and the browser swallowed `<svg aria-hidden="true">` into the `<a>` = an aria-hidden-focus error. Fix: removed the inline onclick → `data-td-msapp` + a delegated click handler in theme.js (the Messenger mobile/iPad deep link behaves as before). RULE: no inline onclick containing quotes in templates - texturize/cache will break it.
- **Lighthouse Accessibility after the fix (real customer path, server preset tet)**: home 100, /shop/ 100, PDP 100 (before 97); the color-contrast audit = pass, 0 items on all 3 pages.
### Seasonal v14 - QA + Audit round 2 - 3 more real bugs beyond contrast
- **The "Build PC Custom" button (.meganav-build) sank on the Build PC page / dark** (contrast 1.37 originally, 1.54-2.72 in every season): page-tu-chon-cau-hinh-pc.css has the rule `[data-theme="dark"] .meganav-inner > a { color: var(--td-text)!important }` that swept up THE gold/season gradient button too → light text on a light background. Fix: `:not(.meganav-build)` (both the .css + .purged versions) → the button gets dark --td-on-brand text back correctly in every season (re-measured 5 dark seasons: dark text on a light background). This is a header button identical on all pages but ONLY the builder page has this overriding rule → home/shop/pdp are not affected.
- **The buyback price table lacked keyboard focus** (axe full ruleset, serious, hitting 9 presets × 2 themes): `.tdp-buyback__pricescroll`, the horizontally scrolling area, did not receive keyboard focus. Fix: `tabindex="0" role="region" aria-label` (buyback-pricelist.php) + a gold `:focus-visible` outline, `:focus:not(:focus-visible)` drops the mouse outline (page-thu-mua-hang-cu.css).
- **Heading skipping H1→H3 on the buyback page** (Lighthouse heading-order): the `prem-steps` block hard-coded `<h3>`; the hero "process" block comes right after the H1 → a skipped level. Fix: added the attribute `headingLevel` (2-4, default 3) to the block + dynamic render; the hero block is set to h2 in the content of page 62102 (the "Quy trình" (Process) block in the middle of the page keeps h3 since it has a parent h2). RULE: a block with a heading must let the heading level be adjusted, not hard-coded.
- **Comprehensive audit round 2 (2 agents + a dedicated script)**: (a) axe FULL ruleset WCAG 2.1 A/AA 108 runs (6 pages × 9 presets × 2 themes, excluding color-contrast, which already passes) → only 1 bug (pricescroll, fixed); Home/Shop/PDP/BuildPC/Cart are absolutely clean. (b) A script measuring contrast of EVERY button (23 selectors × 6 pages × 9 presets × 2 themes = 1260 measurements, including gradient backgrounds that axe skips) → 0 errors after the fix. (c) Lighthouse Accessibility on the 5 main pages = **100/100** (builder and buyback are both 100 after the fix).
- **Audit verification round 2 (90 runs again after the fix)**: 160 → 0 theme CSS violations. The only leftover = a frame of the "just bought" popup mid fade-in (every text is faint at mid-opacity - transient, which real Lighthouse does not catch); a live computed-style spot check confirms footer/tb-head = brand-bright, pill/btab active correctly on-brand/mix, del opacity 1, `.m` opacity 1 (the light override `.is-active .m` had to be added because `html[light] .td-bp__pill .m{opacity:.92}` is (0,3,1) and beats (0,3,0)).
### Seasonal v13 - CTA buttons change color by season
- **Root cause**: 4 groups of gold CTA buttons (`.td-btn--primary` base.css, the Build PC Custom button + the megamenu drawer call button in megamenu.css, `.td-bp__btn--gold` home.css - along with the .purged versions) HARD-CODED `linear-gradient(#ffe58f,#e3c56e,#b2933a)` over `var(--td-brand)`, while `color: var(--td-on-brand)` DID change by preset → when a season is on: the background stayed gold and the text became the season's cream/navy = both "does not change color" and "light/dark bug".
- **Fix**: made it all variable - tokens.css adds `--td-cta-grad` / `--td-cta-grad-hover` / `--td-cta-shadow-c` (default = exactly the old gradient + gold shadow, so ordinary days stay byte-for-byte identical); a shared rule `[data-preset]{ --td-cta-grad: linear-gradient(135deg, color-mix(brand 62% white), brand 50%, brand-strong); hover similarly lighter; shadow-c: brand }` → turning on a season makes the button change BOTH background + hover + shadow by itself, with the preset's --td-on-brand text matching the background (AA on-brand vs brand was already checked 16/16 earlier). Fixed 6 CSS files (3 originals + 3 purged) with an exact-string replace script; the shadow `rgba(227,197,110,.x)` → `color-mix(var(--td-cta-shadow-c) x%, transparent)`.
- Verified by computed measurement: the default keeps gold; tet/noel/dong × light/dark buttons change to the right season color, text contrast against the middle gradient stop 4.9-9.5.
- **QA agent 63 combos (3 pages × [default + 8 seasons] × light/dark @1600 + home mobile 390)**: the new variable CTA button PASSES 100% (0 gold stops when a season is on, contrast ≥4.5 in every preset, hover + shadow change correctly, the default keeps gold unchanged); a page-wide scan for goldLeftovers = EMPTY (all 12 default gold groups change by themselves when a season is on); 0 JS errors, 0 horizontal overflow even at 390px. **1 real finding fixed**: the 2 nav buttons "Danh mục sản phẩm" (Product categories) (.td-mega__toggle) + "Build PC Custom" (.meganav-build, layout.css) use a brand→brand-hover gradient but the 8 dark preset blocks LACKED an override of `--td-brand-hover` → the last stop of the dark gradient mixed in the light color, and contrast fell to 2.05-3.01 → added --td-brand-hover to all 8 dark presets (AA on-brand vs hover passes 8/8), re-verified on the 4 worst seasons: 6.71-9.01.
### Seasonal v12 - the OFFICIAL VECTOR SET (replacing the GPT images)
- **Source**: 2 packs "Seasonal vector illustrations" - 8 seasons × 2 separate LEFT/RIGHT images (300×900, transparent background, with both SVG + PNG; SVG is used for crispness + lightness). Deployed 16 files `assets/img/seasonal/scene-{preset}-{left|right}.svg` (name mapping: quockhanh→quoc-khanh, trungthu→trung-thu, tuutruong→tuu-truong); deleted the 8 GPT-generated PNGs.
- **Upgraded inline override mechanism for left/right**: ::before uses -left.svg, ::after uses -right.svg + `transform:none` (the scaleX(-1) is DROPPED - the right image is drawn separately, no longer mirrored); anchored at `center 164px` = the "hanging string" element in the image starts right below the solid header block, so it looks hung from the header; size `min(258px,92%) auto`; opacity 1 (dark .9); the orb + particle dots stay light (8%/7%/32%).
- **2 bugs during rollout (self-caught + fixed)**: (1) using `sprintf` on a CSS string containing `%` → a PHP 8 ValueError "Unknown format specifier" caused a **frontend 500** - switched to a str_replace placeholder `{SCENE}`; (2) after fixing the file the fatal remained - **php-fpm opcache kept the old version**, requiring `systemctl reload php8.2-fpm` (the old prod gotcha recurring on staging). Verified after the fix: home 200, the inline prints the right left/right + transform:none ×8 seasons, and the screenshot shows 2 different sides as designed. The admin samples were re-captured from the vector set. Process GOTCHA: whenever seasonal.php is edited and opcache is suspected → reload php8.2-fpm before debugging further.
### Seasonal v11 - 8 COMPLETE AI IMAGE SCENES
- **Season scenes generated with an AI image service** (1024x1536, high quality). GOTCHA: the service does NOT support `background:transparent` → generate on a **pure magenta #FF00FF background** and then chroma-key with ImageMagick (`-fuzz 18% -transparent + alpha Erode Diamond:1 + -trim + -resize 760x`).
- **Round 1 came out with faded, not vivid colors** (the prompt said "muted palette") → round 2 changed the prompt to **"premium glossy 3D render, VIVID rich saturated colors, sparkling highlights"** + the inline override for AI images shows `opacity:1` (dark .88) - the result is vivid photorealistic: Christmas has a glossy pine tree + 6 satin-bow gift boxes; Tet has a yellow apricot blossom bonsai hung with coins and red tassels + a lacquered pot + pink peach blossom + lucky money envelopes/tangerines/firecracker strings; all 8 seasons were deployed into `assets/img/seasonal/scene-*.png` (v10's auto-detection mechanism). The admin samples were re-captured from the AI images.
### Seasonal v10 - the AI IMAGE pipeline
- Hand-coded SVG reached its quality ceiling → switched to **real AI-rendered images**. The server cannot generate them itself (the buildpc Gemini key expired/invalid, the PSI key is blocked from the generativelanguage API), so the pipeline was set up in advance: the theme **auto-detects the file `assets/img/seasonal/scene-{preset}.png`** (transparent background, portrait image) and an inline CSS overrides that season's SVG scene (keeping the orb + particle dots, size min(240px,88%), ver by filemtime) - drop an image in and it works, delete it and it returns to the SVG, without touching code. Smoke test: created a fake file → the inline override was generated correctly → deleted.
- The standard 8-season image generation prompt set (with the shared requirements: portrait 2:3, soft 3D clay render, muted palette, transparent/magenta background for chroma-keying).
### Seasonal v9.1
- **The scene is anchored to the VERTICAL CENTER of the screen** (`background-position: center center` on the scene layer): anchoring by px from the top always got the top hidden by the solid header block (~160px: topbar+logo+nav) (hit twice: first the pine tree, then the flag) - with center, the scene is always fully visible at any screen height. Verified with captures of noel + quoc-khanh at 1000px tall: the star/tree top/flag tip are intact.
- **Redrew the Christmas hero a second time**: the "organic serrated canopy" hero rendered the path WRONG (a scattered canopy, a gap in the trunk - the zigzag path cut itself) → rebuilt with the reliable 4-tier triangle structure (rendered correctly in v8), enlarged + snow on the tier edges + 13 halo bulbs + 4 gift boxes at the base. LESSON: a complex hand-drawn SVG path must be render-tested before delivery - stacked simple geometry is safer than one winding path.
### Seasonal v9
- **The 2-side strip architecture changed: the scene does NOT REPEAT** - the scene layer switched from repeat-y to no-repeat (the strip is fixed, so one main cluster is always in view; vertical repeating created 2-3 identical pine trees). Under the scene a new layer 4 was added: **faint dots in the season's color** (radial dots 34×42 repeat, color-mix 40% var --td-season-line) covering the whole strip so it is not empty. The scene is anchored at `center 120px` (avoiding the solid header).
- **The Christmas tree was redrawn as 1 BIG "super realistic" TREE**: a solid organic serrated canopy (no more 4 separate triangles) in 2 light/dark layers, **snow settled on branch edges in zigzag streaks**, a 2-layer glowing top star + light rays, **5 light strings with ~20 bulbs, each bulb with its own radial halo** alternating red/yellow/green with specular highlights, a gradient wood trunk, 4 stacked gift boxes on a snow hill + cast shadows.
- **Fix of the bug: broken season frame border corners**: ribbons/ticks drawn flush at the edge (0,0) were **clipped into a broken curve** by the card's 14px border-radius → the A/C frame ribbons are inset 16px at both ends + round bar ends (3px), and the B frame ticks are inset (10px,10px) crop-mark style - no stroke touches the rounded corners any more. Verified with a close-up of a /shop/ card: clean corners.
- Confirmed: **every list zone has decor** - a season motif on each card + an ornament next to the zone title + ribbons/ticks by zone (the image the owner sent was exactly the "Sản phẩm bán chạy" (Best sellers) zone showing all 3 layers).
### Seasonal v8
- **The 2-side background changed from scattered objects to a BIG composed SCENE** (keeping the v7 3D quality): Noel = a big 4-tier snow-covered pine tree + a glowing top star + 3 golden light strings winding around + 8 hanging baubles (red/yellow/green specular) + **4 gift boxes stacked at the base** on a snow hill; Tet = a large blooming apricot blossom pot (yellow apricot + interwoven pink peach branches) + lucky money/tangerines/firecrackers at the base + stars falling from the canopy; Winter = a BIG snowman (wool hat + red scarf + twig arms) standing next to a snowy pine on a hill; Mid-Autumn = a big full moon + a PAIR of rabbits sitting under the moon + 2 lantern strings hung on both sides + a star lantern + patterned mooncakes; Halloween = a cluster of 3 big-and-small pumpkins (the big carved one WEARS a witch hat) + a moon + 2 flocks of bats + a dangling spider; National Day = a large flagpole + a flying gold-star flag + a bunch of red-yellow balloons with strings + fireworks + a dove; Summer = a beach (wavy sea + a sand strip) with a leaning coconut tree + a float ring + a volleyball + a sand bucket; Back to school = a BIG CHALKBOARD ON A 3-LEGGED EASEL + a backpack leaning against the easel leg + a stack of books + a red apple + pencils. Each tile: the main scene ~60% + sparse rest areas (candy canes/snowflakes/stars/waves) so the vertical repeat looks natural. The admin samples were re-captured.
- **Fix of the pine tree "cut across the trunk"**: the solid header (~100px) overlapped the z-index:-1 strip → the top of the first scene in the tile was covered. Fix: shifted the tile layer's `background-position` center top → **center 120px** - the area covered by the header now hits only the sparse padding at the end of the tile, and the main scene shows intact under the header. Re-verified on 8 seasons (noel/tet/dong/halloween/quoc-khanh checked by eye, the rest use the same formula): no main object is cut any more.
### Seasonal v7
- **Upgraded all 8 tiles + 8 motifs from flat to DIMENSIONAL/3D** with radial/linear gradients in the SVG defs: apricot/peach petals with a gradient from a bright center to a deep rim + pollen-dot stamens; a 2-layer pine canopy (lit side) + gradient snow; gift boxes with a vertical highlight + metallic ribbon + an **ellipse cast shadow under every object**; Christmas baubles/spheres with specular highlights; the snowman as 3 radial spheres + a wool hat; a moon with a 2-layer glow + craters; lanterns glowing from inside + bead tassels; pumpkins with dark ribs + light patches; a solid witch hat; a flag with cloth ripples (3 vertical shade streaks); a leather backpack with a 2-layer flap + a radial metal buckle + stitching; a wooden-framed chalkboard with grain; a coconut tree with a ribbed canopy + trunk grain; a 3D float ring; pencils with an eraser + a metal band. The palette keeps the muted v6 tone. The admin samples were re-captured from v7.
### Seasonal v5
- **Rebalanced the presence, keeping the line-art language** (not going back to clip-art): card borders in a clearer season tone (mix 42%), **a 3px SMOOTH ribbon gradient of the 2 season tones** (line→accent, no stripe pattern) at the top of zone A / the bottom of zone C; zone B has bold 2-tone corner ticks + a faint connecting hairline; the image-corner motif enlarged to 27px op .72; **a season ornament right beside the TITLE of each list zone** (`.td-shead__row::after` = mask var `--td-season-motif` + color `--td-season-line`, dark blends brighter automatically); the 2-side strips are bolder (opacity .62/.4) + an added **pattern dot layer in the season color** (radial dots 30×38) to fill them; fx 22 particles on desktop / 12 on mobile. Refactor: the 8 seasons' motifs became the CSS var `--td-season-motif`, shared across 3 places (card + title + ready for others).
- **Fixed the admin season picker bug that showed 2 selections**: the selected state used to follow both the server-rendered class `.is-active` (the card of the saved option) AND `:has(input:checked)` (the card just clicked) → it looked like 2 seasons were chosen. The class was dropped entirely, leaving only `:has(input:checked)` - clicking another season immediately releases the old card's ring. The admin samples were re-captured from the v5 art.
### Seasonal v6
- **Changed from single-tone line art to FLAT ILLUSTRATIONS IN REAL COLOR, muted palette** (not neon): every season redrawn with its full set of iconic objects - Tet: a YELLOW apricot branch + a PINK peach branch + red lucky money envelopes with a gold star + firecrackers + coins; Noel: a GREEN snow-covered pine with a gold star + red gift boxes with gold bows + a green gift box + candy canes + red baubles + a cloud snowman; National Day: a flying red flag with a gold star + fireworks + a dove; Summer: a sun + a coconut tree + a red-white striped float ring + waves + a starfish; Winter: a SNOWMAN with a hat, red scarf and carrot nose + a snowy pine + a big snowflake + a snow mound; Halloween: a smiling pumpkin with a green stem + a purple witch hat with stars + a golden moon + bats + swirl candy; Mid-Autumn: a golden moon with craters + a white RABBIT + a red lantern with gold tassels + a star lantern + clouds; Back to school: a blue wood-framed CHALKBOARD (an A letter + chalk strokes - the letters are symmetric because the strip must be mirrored with scaleX(-1), and "ABC" was once flipped backward) + a red BACKPACK with a gold buckle + a stack of 3-color books + a pencil + a graduation cap.
- The card motif + title ornament + logo charm all changed to color illustrations (var `--td-season-motif` shared across 3 places; the logo charm drops the mask and uses the mid-tone color motif directly + a light drop-shadow - readable on both the white and ink headers). The card motif is 28px op .9. Strip opacity .8 light / .5 dark (the dot layer dropped - the color illustrations fill it on their own); Tet fx falls as 2 kinds of petals, yellow apricot + pink peach, alternating. The admin samples were re-captured from v6.
- **PASS**: 32 frontend combos (8 presets × home/shop × light/dark @1920) - the 3-layer strip + mask fade + width 290 correct, hairline/tick by zone, motif op .55, fx 16 particles + depth blur, 0 overflow, 0 JS errors; at 1366/390 the strip hides correctly; admin Seasonal: EN menu, the grid 3→2→1 columns at the right breakpoints (1600/1280/800), 9 cards + 8 samples load for real, the "Xem thử ngoài web" flow (?td_preview_preset=noel) applies the data-preset + loads CSS correctly for admins only; the Build PC page has no more PHP Warning.
- **2 real findings about the logo ornament → FIXED the same day**: (1) the ornament placed to the right of the logo overlapped the search box by 14-22px (the logo→search gap is only ~12px) → changed to a **charm perched at the logo's top-right corner** (right:-6/top:-9, 21px, rotated 8°); (2) the light tone sank into the light header (a WHITE background, not ink - contrast 1.4-2.2:1) → changed the architecture: the shape = a **CSS mask**, the color = a background-color following `--td-season-line` (light uses a deep tone ≥3:1, dark blends brighter automatically `color-mix(... 45%, #fff)`) - 1 URI per season serving both modes. Re-verified: the mask applies correctly, does not cover the search, and is clearly visible in both light and dark.

## [1.7.7] - 2026-07-08 (Video editing benchmark + admin color alignment)
### New
- **The mobile drawer follows the Mega Menu config**: mobile-nav.php adds a dispatcher like desktop - it prefers the option `tandoanh_megamenu_config` (mapping tops/groups/links → drill-down: a group with no title spills its links straight into the level-1 panel, a group with a title + links becomes a level-2 panel, a group with no links becomes a link row), and when no config is saved it falls back to the source menu as before. Verified: the config branch with the editor seed outputs HTML **identical byte for byte** to the nav branch (root + 32 panels). The drawer transient key changed to `tandoanh_mnav_v3`; saving/resetting the config on the Mega Menu page both bust the drawer. From now on editing the config changes BOTH desktop and mobile - the config is the single source.
- **Seasonal theme v3 (the "do it more carefully" spec within the day)** - (1) The card ribbon became a **pattern specific to each season** through the var `--td-season-ribbon` (Tet diagonal red-yellow stripes, Noel red-white-green candy cane, National Day red-yellow-red, Summer orange-teal, Winter ice blue, Halloween orange-black, Mid-Autumn red-yellow, Back to school blue-yellow), shared by frame A (top) + frame C (bottom); cards also gained a **glow radiating from the top** (radial color-mix 9%); the frame B corner brackets were raised by 2 levels + vermilion dots in 2 corners. (2) **The 8 background tiles on both sides were REDRAWN with many detailed layers** (fill-opacity depth): Tet = a 2-tier apricot blossom branch + buds + a gold-star lucky money envelope + faded coins + peach petals; Noel = a snow-covered pine + a top star + 3-color baubles + a light string + a bowed gift box + full snowflakes; National Day = a flag waving in waves + 2 fireworks clusters + a faded bronze drum; Summer = a 2-layer sun + a 3-canopy coconut tree + a striped lifebuoy + 3-layer waves + clouds; Winter = a snowman (scarf + eyes + carrot) + a faded snowy pine + big 2-level flakes + a snow mound; Halloween = a corner cobweb + a grinning ribbed pumpkin + a purple witch hat + bats + candy + stars; Mid-Autumn = a cratered moon + a faded jade rabbit + a tasseled lantern + a star lantern with a red border + clouds; Back to school = a sharpened pencil + a stack of 3-color books + a blue chalkboard with lines of text + a graduation cap + a paper plane + a magnifying glass. (3) **The Seasonal admin page has a SAMPLE IMAGE for each season** (assets/img/seasonal/*.jpg, 8 images ~35KB captured for real with Playwright from home at 1920) + a **"Xem thử ngoài web ↗" (Preview on the site)** button per season: `?td_preview_preset=<key>` - the filter tandoanh_active_preset returns the preview preset ONLY for users with manage_options (customers do not see it), the enqueue loads the assets accordingly, a wp_head prio 2 script forces data-preset to beat localStorage, and the key `off` shows the interface with seasons off. Verified: 8/8 presets have strip+ribbon+motif OK after v3, and the filter is correct in all 3 cases (real key/off/invalid).
- **Fix of corrupted characters from double-encoding (lost backslash)** - the strings `&amp;`/`\n` in the block JSON had their `\` stripped from an earlier save without wp_slash, so it displayed "Build u0026amp; test 24h", and the buildband preset benchmarks were squashed into 1 line. Fixed the DB: home 6293 (ticker + features "Build & test 24h" + zones "Ryzen & Radeon" + the buildband Office preset restored with 4 benchmark lines + 2 costRows lines) and the Vertical Menu nav HTML 104248 (8 labels such as "Tanks & Accessories"…). Written back with the standard `wp_update_post(wp_slash(...))`; verified live that "u0026" is gone, the JSON parses OK, and the mega menu labels correctly show "&".
- **Fix of the icon dropping to a new line in the "Tương thích socket…" (Socket compatibility…) block (buildband)** - the global reset `svg{display:block}` (base.css) pushed the tick icon onto its own line → added `.td-bp__ok svg{display:inline;vertical-align:-2px}` (both home.css and home.purged.css); verified computed style: svg inline + the icon on the same line as the text.
- **QA by 2 agents in 2 tracks (admin 18 pages + frontend 108 combos)**: admin - 0 console errors, 0 overflow, 0 misplaced WP .notice; frontend - 0 horizontal overflow across 108 combos of 8 pages × 6 viewports × light/dark, mobile drawer 32/32 pass (open + drill + back), the /gioi-thieu/ page + the 3 policy bands render the right number/email/address from Business Info, all 8 presets have full decor; the 2 warnings "strip overlapping text" were investigated and were false positives (a full-bleed ticker with its own background; the strip's drawing area is ≥74px from the text). The real findings were fixed: see the tandoanh-buildpc 2.4.0 CHANGELOG (fatal get_share + created_at + 36px button). Noted and NOT changed (deliberate): the input inside the .ait-addon pill (Language translation) has border:none since the outer pill already has a 1.5px border; the 30px icon buttons on the Installments page are intentional by design.
- **Seasonal theme v2** - (1) **8 seasons**: added Halloween (pumpkin orange), Mid-Autumn (lantern red + moon yellow), Back to school (chalkboard blue + pencil yellow) - the AA tokens pass 6/6 combos from the start (button 5.15-7.87). (2) **Decorative background on BOTH SIDES of the screen** (≥1560px): a hand-drawn SVG tile for each season (apricot branch + lucky money / pine + snow / star flag / sun and sea waves / snowflakes / pumpkin + bat + spider / lantern + moon / pencil + books + graduation cap) placed at `body::before/::after` fixed `z-index:-1` (html has no bg, so the body bg is promoted to the canvas → the strip sits ABOVE the background and BELOW all content), the right side mirrored with scaleX(-1), with its own dark opacity. (3) **Card frames DIFFER BY ZONE**: the TOP best sellers zone (.td-tb) + odd product sections on home = a 2-tone CORNER BRACKET frame + a motif at the top-right; the shop/category/search pages + even product sections = a BOTTOM ribbon + a double border (outline -6px) + a motif at the bottom-right; the rest = a top ribbon + a motif at the bottom-left (default). (4) New FX: Halloween autumn leaves + bats (4th particle), Mid-Autumn stars + glowing lanterns (5th particle), Back to school yellow/mint paper planes. Playwright QA at 1920px tet home+shop light/dark: the 290px strip shows correctly on both sides, 26 particles, 0 JS errors, 0 overflow. GOTCHA: after editing CSS that WPFC has combined, you MUST `deleteCache(true)` (the bundle drops ?ver - the old gotcha recurring); the zone C selector must be written `[data-preset] .archive ...` since data-preset is on html while the body class is on body. Technical note in the file: .td-pcard::after belongs to woo-card.css - card decoration only touches ::before + __media::after.
- **About page /gioi-thieu/ (page 104669)** - the dead template page-tan-doanh.html (the slug duplicated the homepage and never rendered) was renamed to page-gioi-thieu.html (main.tdp-about + wp:post-content); the tdp-about content moved into the page content (turned into blocks, the full hero/stats/story/values/services/visit set); the CSS bundle was renamed page-tan-doanh.css → page-gioi-thieu.css (loaded by slug); the 3 hard-coded contact places in the Showroom section were replaced with the shortcode `[td_biz]` (address + sales1/sales2 + email). Verified live 200, with full markup + CSS + shortcodes. Not yet linked from the menu/footer - waiting for a decision on the position.
- **Brand: every reader flipped to product_brand** - the PDP kicker (content-single-product.php) reads the product_brand term first, falling back to the old attribute; the Google Shopping settings flipped brand_source_taxonomy=product_brand / fallback=pa_thuong-hieu. Backfill check: 0 published products lack product_brand. DECISION: do NOT delete the attribute pa_thuong-hieu (109 terms/3312 relationships) - it is the "inbox" of the real-time sync from the live site (`hp_sync_convert_brand()` converts each product on receipt and needs both taxonomies to exist); deleting it would make the sync spawn it again and lose the rollback path. Verified live: the PDP kicker shows the brand from the new taxonomy.
- **Seasonal theme (Phase 11)** - a new admin page "Theo mùa" (Seasonal) (`inc/admin/seasonal.php`, TD Theme → Theo mùa): choose 1 of 5 presets (Tet / Noel / National Day / Summer / Winter) or Default (off), with a toggle for the falling effect. The options `tandoanh_seasonal_preset` + `tandoanh_seasonal_fx`; the no-flash script in enqueue.php has read this option since 1.6.x. When on: product cards get a 2-tone ribbon at the top edge + a frame/background tinted in the season color (color-mix on --td-brand so it is correct in dark too) + an SVG motif at the image corner (apricot flower/snowflake/star/sun - a data-URI of ~300 bytes, the media background fixed #fff); the falling effect (peach petals/snow/stars/sun bubbles) is created by `seasonal-fx.js` AFTER window.load at idle, up to 26 particles (mobile 14), compositor-only transforms, respecting prefers-reduced-motion. `seasonal.css` + JS **load only when a preset is on** → zero PSI cost on ordinary days. **Full AA re-check of the preset tokens (tokens.css)**: 7/10 old combos failed (button text 2.17-4.22) → deepened the brands (tet #cc231f, quoc-khanh #ad1a14, he #d96407, dong #276aa5) + added a dark on-brand for dark (noel/quoc-khanh/dong) - after the fix 10/10 preset × light/dark combos meet AA (buttons ≥4.97, strong text ≥4.50, UI ≥3.0). Playwright QA /shop/ noel light+dark: ribbon/motif/26 particles show correctly, 0 JS errors, 0 horizontal overflow. Operational GOTCHA: `wp option update` by root CLI hits the alloptions Redis race (the option is written to the DB but www-data reads the old cache, and wpfc renaming the old cache dir also gets Permission denied) → option operations + cache deletion MUST run as `sudo -u www-data wp eval` together with `wp_cache_delete("alloptions","options")`.
- **Block `tandoanh/biz-text`** - the address/hotline/email read Business Info (td_biz), with no more hard-coding in policy templates. 3 variants: `inline` (1 value, optionally wrapped in tel:/mailto:), `cta` (an eyebrow band + a paragraph with the tokens `{address}`/`{hq}`/`{name}` + a Call button + an email button, namespace tdp-policy/tdp-payoo), `contact` (a policy page contact card grid band - the default cards Sales ×2 / Accounting / Email / Address). Along with the shortcode `[td_biz field="..." link="1"]` for INLINE use in page content (delegated through the block). Replaced 3 hard-coded wp:html bands: page-chinh-sach-bao-hanh (warranty CTA + baohanh@), page-chinh-sach-van-chuyen (contact grid), page-bang-chi-phi-thanh-toan-tra-gop-payoo (CTA sales1 + kinhdoanh@) - verified live that all 3 pages render the right number/email/address from Business Info. Business Info gained the email key `baohanh` (baohanh@tandoanh.vn - a figure already in public use on the warranty page). The displayed hotline prefers `display_header`, falling back to `display`.
- **Video editing benchmark (PugetBench, Overall Score Standard):** 4 new categories `gpu_premiere` / `cpu_premiere` / `gpu_davinci` / `cpu_davinci` (30 devices per app). The VGA Performance tab gains "Dựng phim · Premiere Pro" (Video editing · Premiere Pro) + "Dựng phim · DaVinci"; CPU gains 2 corresponding metrics (shown only when ≥3 CPUs of the same line have data).
- **Promo Subscribers split into 2 tabs**: "Danh sách đăng ký" (Subscriber list) (the old table: search/CSV/delete) + a new tab **"Soạn email marketing" (Compose marketing email)** (`inc/admin/promo-email.php`): 3 ready-made templates (Promotion / New arrivals / Page introduction) that auto-fill the subject + a message with the token `{ten}` replaced by each customer's name; attach products (AJAX search by name, chips with images), product categories, pages/landing pages; a customizable CTA button. The HTML email is standard table + inline style (600px) in the **brand gold used on the site** (theme.json: gold #e3c56e / #8a6a00, ink #15151a - not the admin colors): an ink header with a gold logo + hotline + a gold underline, an eyebrow accent per template, product cards with a surface background image + gold price (a price-0 item automatically shows "Giá liên hệ" (Contact for price)), gold-toned category pills, a gold CTA in the style of the "Mua ngay" button + a secondary hotline line, an ink footer with social links, and a hidden preheader for the inbox preview line; the address/hotline/social read from Business Info (td_biz). Preview in an iframe, send a test to 1 email, send to all in batches of 25 people per round with progress. Each email carries an HMAC-signed **unsubscribe** link - when the customer clicks it, they are removed from the list automatically (tested end-to-end, a wrong signature returns 400).
### Interface
- **Mega Menu**: the editor was rewritten as a visual form identical to the mega menu on the homepage - the left pane is the list of main items, the right pane is the panel (group columns / a flat list following the frontend render rules); clicking any text opens an edit dialog for the label/URL together with reordering + delete. Verified that after saving the config, the mega menu HTML on the page does not change by a single byte (also patching 2 latent mismatches of the old config mode: the column-sorting script for "Linh kiện ráp máy" (PC assembly components) and the 5 special columns of the VGA panel).
- The admin is unified in a blue tone following the gradient header (#1a2e4a → #2563eb): removed all the scattered yellow/gold accents - the icons on the Payment + Installments pages, the Popup group titles, the Contact Footer focus border, the Mega Menu accent + number badge, the "Hiệu năng" (Performance) group badge on the Features page, and the MED tag on the Speed page. Red (error) and green (OK) are kept since they carry status meaning.
- Tried 2 admin skins (gold editorial, navy) within the day and then removed both - keeping the original style and only standardizing the colors as above.
- The menu "Thông tin DN" was renamed **"Business Info"** - aligned in English with the other items of the TD Theme menu.
- **The header of every admin tab was rewritten with one consistent formula** for non-technical people: what this page manages on the site → what you can do → what is affected after editing. Applied to 12 pages: Dashboard, Speed, Mega Menu, Homepage, Contact Footer, Payment, Installments, Popup, Language translation, Features (stating it is for reference only), Subscribers, Benchmark Data (removing the technical table name from the description); developer words ("native", "FSE", "dock", plugin names) were removed from the descriptions.
- **QA + control standardization across the whole admin (13 pages, an audit script measuring computed style - 0 issues)**: shared rules in admin.css - EVERY input/dropdown/textarea has a 1.5px border + 8px radius + 40px height (not depending on each page attaching a class); every `.hplt-btn` button is at least 40px tall, centered; the WP `.button` buttons (Choose logo/Remove/×) take the hplt style. Specific fixes: Installments - the "Mức tối thiểu" (Minimum) field auto-formats thousands separators while typing (3000000 → 3.000.000, the server still receives it correctly), the × delete-bank button became a 40px icon, the "Chọn logo"/"Gỡ" buttons were replaced with 30px FontAwesome icons (image/×) sitting IN LINE with the logo; the Minimum field shrank to 104px and the bank table padding was reduced (12px → 7/6px) for tidiness; Contact - separated "Bật dock" (Enable dock) and "Độ trễ" (Delay) with a horizontal rule + spacing; Compose email - the template cards are 240px wide so the word "sự kiện" no longer wraps; the Benchmark Data pager is aligned at 40px.
- WP notices (class `.notice`) on the Mega Menu + Homepage pages were hoisted by WordPress to right after the H1 → a white box inserted in the middle of the blue header; changed to the theme's `hplt-notice` (WP does not move it) - notices now sit neatly under the header in the admin style.
- Buttons placed in the card header (e.g. "Xuất CSV" (Export CSV) on the Subscribers page): the icon is no longer invisible (the rule `.hplt-card__head .fa-solid{color:#fff}` overrode the icon to white on a white-background button → scoped `color:inherit`), and the hover changed to a light background `#dbeafe` instead of `#2563eb`, which matched the header color (the button sank into the background).
### Bug fixes
- QA of the 3 landing pages (pc-gaming / ai-workstation / creator-studio): creator-studio had **98 waveform rects with NEGATIVE height** baked into the landing-clone HTML (the generator computed sin without clamping) → every view logged 98 console errors, with invisible bars; fixed by normalizing `height=|h|, y=y+h` (bars rising from the center line). After the fix: 0 JS errors on all 3 pages × light/dark. QA also: 24 combos (4 viewports × 2 modes × 3 pages) 0 horizontal overflow; hovering 42 interactive elements 0 text-on-same-color errors; PSI Perf 94-99 / A11y 100 / BP 100 / CLS≈0 (SEO 66 because staging deliberately blocks indexing); only pc-gaming mobile LCP ~4.4s because of a 502KB clone + 2,039 SVG elements - noted, awaiting the block-native port.
- The Instagram icon in the footer showed as a solid white block: the global rule `svg { fill: currentColor }` (base.css) overrode the `fill="none"` attribute of the outline icon → changed to `svg:not([fill="none"])`, also fixing base.purged.css. QA of the footer at 10 viewports × light/dark: 0 horizontal overflow, the 1/2/4 column layout is correct at each breakpoint.
- The NVIDIA workstation cards (RTX 5880/4500/3500 Ada) slipped into the "same-line comparison" table of the gaming card under the truncated name "RTX 5880": the extractor now keeps the Ada suffix (the displayed name is correctly "RTX 5880 Ada") + meta blocks x8xx-style numbers from the consumer family.
- The comparison card title "Năng lực AI so với các card cùng dòng" (AI capability versus cards of the same line) became "Hiệu năng làm việc so với các card cùng dòng" (Workload performance versus cards of the same line) - the selector now covers 3D rendering + video editing, not just AI.
- The Business Info page had a PHP 8 fatal (`"$n[key]"` in a double-quoted string = string offset access) - 16 places were changed to string concatenation.
### Notes
- Cleaned up the CHANGELOG: merged each day into one version, with the older entries within the same day grouped together.

## [1.7.3] - 2026-07-07 (Performance benchmark system + refactor to reduce hard-coding)
### New
- **The admin page "Thông tin DN" (Business info)** (option `tandoanh_business_info`): company/tax code/head office, showroom, 6 hotlines (displayed by context: footer/header/PDP), email, working hours, social + the PDP chat channels, 13 policy links, MOIT/DMCA, copyright year. The helpers `td_biz()` / `td_biz_hotline()` / the alias `tandoanh_contact()` - thankyou.php no longer shows the fake number "1900 000 000". The footer/topbar became dynamic blocks reading from the option, with markup identical byte for byte and the /en/ translation intact; the "people viewing" random value is stable by time slot (min/max adjustable in the admin).
- **A benchmark system in the Performance tab** - all from official sources, with provenance:
  - Render: Blender Open Data 5.1 (80 GPUs + 210 CPUs, median samples per minute by scene), OctaneBench 2025 (162 GPUs), V-Ray Benchmark v6 (598 GPUs + 865 CPUs, filtered to single-socket/single-GPU).
  - CPU: Geekbench 6 Single/Multi-core (132 CPUs, for missing chips the median of a search n=50 is used - 100% coverage of the shop's CPUs), x265 encode, 7-Zip compression, CrossMark office - 9 metrics in total for same-line comparison.
  - AI: a "Model AI phổ biến" (Popular AI models) panel (Can-I-Run LLM, 10 models 1.5B→32B, badges Runs smoothly/Runs well/Acceptable, models exceeding VRAM marked "Cần VRAM lớn hơn" (Needs more VRAM)) + a "Chạy LLM · Llama 3.1 8B" (Run LLM · Llama 3.1 8B) metric.
  - Fallback of Intel F/KF variants ↔ the base version (same silicon, differing only in iGPU).
- A self-built reCAPTCHA v3 (module tandoanh-code/cf7-recaptcha-v3, replacing the third-party v2 plugin) for the CF7 Contact + Recruitment + career forms; a hidden badge + Google notice, lazy-loaded against TBT.
- PDP desktop: an overly long short description collapses by itself + a "Xem thêm" (See more) button.
### Changed
- Refactor to reduce hard-coding: the seed table of 32 installment banks moved to 1 source `inc/tragop-data.php`; the default gift packaging to 1 function; an SVG icon registry `inc/icons.php` shared by 10 sets (render byte-identical, verified by md5); repeated UI strings wrapped in `__()`; Vietnamese text in JS moved through `wp_localize_script`; the newsband source has a filter.
- The PDP chat buttons were finalized to match production: Messenger `m.me/TanDoanh` (PC goes to the web) + the deep link `fb-messenger://` jumping straight into the app on iOS/Android/iPad (also catching iPadOS pretending to be a Macintosh UA); Zalo goes to `zalo.me/0783666668`. Verified on all 6 device types.
### Bug fixes
- The global `a:hover` overrode button text colors (.td-btn / Mua ngay) to blue - the buttons were excluded from a:hover (also fixing base.purged.css); reviewed CSS after QA (hover, dark mode contrast, buildpc).
- The AI Model panel: chips with several VRAM variants (5060 Ti 8/16GB…) prefer the variant matching the VRAM in the product name, avoiding showing the number of the larger-VRAM version.
- Removed the "Cho thuê PC, máy chủ render" (PC and render server rental) card pointing to a dead link in the tan-doanh template (dead code, no longer rendered).

## [1.1.8] - 2026-07-05 (Admin control center + bilingual VI/EN)
### Added
- **The "TD Theme" admin menu** gathers all theme settings in one place: Dashboard (real-time system status + quick access + logs), Speed, Mega Menu, Homepage, Contact Footer, Payment, Installments, Popup, Language translation, Promo sign-ups, Features. A consistent set of cards/tabs/modals/accordions, with a Font Awesome 6 bundled locally for the admin.
- **Mega Menu**: a 3-level drag-and-drop editor (main item → group → link) saved in its own option; the first time it loads automatically from the source menu so it is identical to the current state; rendering keeps the `.td-mega__*` markup as is.
- **Contact Footer**: a configurable floating contact dock (drag-and-drop channels, on/off, media icons) - a vertical pill on desktop, a round button that expands the channels on mobile.
- **Popup**: page-based banners with trigger types (immediately / after N seconds / scrolling to % / exit intent), a cookie that hides it for N days, and flexible display scope; the CSS/JS load only on pages with a matching popup.
- **Payment**: redesigned as cards + switches, adding the option "Mặc định bật hoá đơn VAT" (Enable VAT invoice by default) (Tân Doanh prefers issuing invoices; it works even without JS).
- **Features**: a page listing 27 theme features by group, with a filter + a detail window + a button that opens the configuration page; the single data source is `features-registry.php`.
- **The "Homepage" tab** was rewritten into a block-based homepage control panel (a section overview through `parse_blocks()`, quick details, buttons to open the editor/Site Editor/clear the cache) - it does NOT overwrite the homepage content.
- **Bilingual VI/EN** `/en/` SSR (SEO-friendly, opt-in, OFF by default): an admin page "Dịch ngôn ngữ" (Language translation) with a master switch + 4 tabs (Content translation / multi-provider AI Providers with fallback / auto-translate Cron / Interface strings); about 120 UI strings translated by hand so the /en/ interface is fully in English; hreflang + canonical are emitted in wp_head.
### Performance
- Deferred third-party JS (GA/GTM/Facebook/Clarity) until the first interaction or 12 seconds; deferred WPFC Combined JS (removing ~1,350ms of render blocking); the Speed page was reorganized into 5 tab groups with optimizations such as async CSS, turning off Cart Fragments, emoji, preconnect, and font preload.
- Fixed homepage CLS from 0.6 → 0: the Combined JS defer skips the homepage because the hero slider needs JS early (other pages still defer).
### Changed
- Updated to the official Payoo installment fee schedule: 32 banks, 8 terms (before: 23 banks, 5 terms); the card swipe fee 1.833% → 1.8%.
- A 3-state dark mode (Follow system / Light / Dark); reordered the admin menu; moved "Promo sign-ups" from the WooCommerce menu to TD Theme.

## [0.9.9.9] - 2026-07-04 (Landing + homepage)
### Added
- 3 dedicated landing pages: PC Gaming, AI Workstation, Creator Studio - 5 editable blocks, SVG mockups that animate by variation.
- Block `tandoanh/newsband`: the 4 latest posts from news.tandoanh.vn (30-minute cache, images loaded only on desktop).
### Changed
- The featured categories block on the homepage changed to 8 tiles, adding CPU/Mainboard/VGA/RAM/SSD icons.
