thegioikeycap.vn — Chuyển sang WordPress thuần
Tài liệu kiến trúc + runbook cutover cho theme thegioikeycap (WP thuần, không Elementor, không Astro).
Trạng thái: ĐÃ CUTOVER 2026-09-04. Production chạy WP thuần, keycap-astro đã stop + disable, vhost test đã dọn.
1. Vì sao chuyển
Bản Astro headless hoạt động tốt nhưng phải tự code lại toàn bộ tầng thương mại (giỏ hàng, checkout, tài khoản, biến thể) trên REST API. Đó chính là nơi đã sinh lỗi "thanh toán rồi mà không xác nhận đơn": đơn do route Astro tạo qua REST, còn webhook SePay lại thuộc plugin WP — hai bên không cùng một chủ sở hữu dữ liệu.
Bản WP thuần đưa toàn bộ tầng đó về native WooCommerce: đơn hàng và webhook do cùng plugin SePay quản lý.
Đo thực tế (3 lần/URL, TTFB giây):
| URL | WP thuần | Astro |
|---|---|---|
/ |
0.12 | 0.18 |
/keycap/ |
0.14 | 0.19 |
/keycap-dream-back/ |
0.12 | 0.11 |
/tin-tuc/ |
0.12 | 0.28 |
2. Nguyên tắc thiết kế
- Không page builder. Chỉ template hierarchy + hook chuẩn WordPress.
- Tầng thương mại dùng native WooCommerce. Theme chỉ style. Không tự viết lại flow tiền bạc.
- URL giữ nguyên slug gốc như bản Astro (
/{slug}/cho sản phẩm, danh mục, bài viết) để không vỡ SEO. - Fail loud. Thiếu WooCommerce → hiện notice trong wp-admin, không âm thầm trắng trang.
3. Cấu trúc theme
wp-content/themes/thegioikeycap/
├── style.css # chỉ header khai báo theme; CSS thật ở assets/
├── functions.php # setup, enqueue, FIX PERMALINK, tạo trang mặc định
├── inc/
│ ├── site-config.php # tgk_site() — brand data, override qua option tgk_site_info
│ ├── helpers.php # tgk_icon(), tgk_data(), tgk_breadcrumb(), danh mục
│ ├── nav.php # nav header (menu wp-admin, fallback = danh mục WC)
│ ├── seo.php # meta danh mục, 301, noindex trang chức năng
│ └── woocommerce.php # hook WC: 5 cột, content box, freeship, badge giỏ
├── header.php footer.php # header 3 tầng, footer 4 cột
├── front-page.php # hero + dải danh mục + sp mới + tin tức
├── single.php # bài viết: header 2 cột, content full-width
├── page-tin-tuc.php # listing blog (loại bài đã 301)
├── page-test-ban-phim.php # công cụ test bàn phím (thay iframe cũ đã 404)
├── taxonomy/archive # index.php archive.php page.php search.php 404.php
├── woocommerce/
│ └── content-product.php # thẻ sản phẩm: SKU + tên + giá + nút icon
├── data/
│ ├── category-meta.json # meta title/desc bán hàng cho 10 danh mục
│ └── category-seo.json # nội dung content box (từ bài đã 301)
└── assets/{css/main.css, js/main.js, img/}
3b. Template WooCommerce đã override (KHÔNG dùng mặc định)
Toàn bộ giao diện thương mại được port theo bản Astro, không để nguyên template WooCommerce:
| File override | Thay cho |
|---|---|
cart/cart.php |
bảng shop_table → danh sách item + hộp tổng bên phải. ⚠️ Ô số lượng (woocommerce_quantity_input()) tuyệt đối KHÔNG bọc wp_kses_post() — ngữ cảnh 'post' không cho phép thẻ <input>, bọc vào là ô nhập số lượng biến mất sạch, không lỗi gì (đã sập thật 17/09/2026). Echo thẳng như core, markup đã tự escape rồi. |
cart/cart-empty.php |
1 dòng chữ → hộp căn giữa có icon 🛒 + CTA |
notices/success.php |
thêm bước lọc (xem §3e), markup giữ nguyên |
notices/notice.php |
thêm bước lọc (xem §3e), markup giữ nguyên |
notices/error.php |
thêm bước lọc (xem §3e), markup giữ nguyên |
cart/cart-totals.php |
bảng tổng → các dòng flex |
checkout/form-checkout.php |
xếp dọc → 2 cột, tóm tắt đơn dính (sticky) |
checkout/review-order.php |
bảng không ảnh → danh sách có thumbnail |
checkout/payment.php |
list trơn → thẻ chọn cổng, viền coral khi chọn |
checkout/form-login.php |
form thô thiếu captcha → card chuẩn thương hiệu, prefill email, Cloudflare Turnstile, nút Google |
myaccount/my-account.php |
float → grid 260px sidebar |
myaccount/navigation.php |
menu tiếng Anh → "Xin chào {tên}" + nhãn Việt |
myaccount/dashboard.php |
2 dòng chữ → thẻ thông tin + đơn gần đây |
myaccount/orders.php |
shop_table → grid 4 cột, pill trạng thái có màu |
myaccount/view-order.php |
bảng → header + pill + list có ảnh + khối người nhận |
myaccount/form-login.php |
2 khối float lệch trái → card căn giữa 420px |
myaccount/form-edit-account.php |
list <p> → hàng Họ/Tên 2 cột + fieldset đổi mật khẩu |
myaccount/form-lost-password.php |
lệch trái → card căn giữa |
content-product.php |
thẻ mặc định → SKU + tên + giá + nút icon. Số tồn kho cố ý không hiện (06/09/2026); còn/hết hàng báo bằng badge trên ảnh |
Nguyên tắc khi sửa các file này: giữ nguyên mọi name=, id=, nonce và hook
woocommerce_*. Đổi id="payment", id="place_order" hay tên field là mất cổng thanh
toán / vỡ checkout. Chỉ đổi phần khung và class CSS.
Bẫy đã gặp khi override
review-order.phpKHÔNG chứa phần thanh toán. WooCommerce móc 2 hàm vàowoocommerce_checkout_order_review:woocommerce_order_review(10 → review-order.php) vàwoocommerce_checkout_payment(20 → payment.php). Nhồi#paymentvào review-order làm nút đặt hàng hiện 2 lần, và biến$order_button_textcũng không tồn tại ở đó.- Đừng ẩn nút "Cập nhật giỏ hàng" bằng CSS. WordPress không thêm class
no-jsở frontend, nên ẩn bằng CSS sẽ khoá luôn khách tắt JavaScript — họ không còn cách nào đổi số lượng. Ẩn bằng JS (main.js) mới degrade đúng. - Dải danh mục trang chủ từng cứng
repeat(8, 1fr)trong khi chỉ có 6 danh mục có sản phẩm → 6 ô chiếm 6 cột, chừa trống 2 cột, trông như dồn sang trái. Nayfront-page.phpin ra--cats-countđể CSS chia đều theo số thật. - Lazyload bóp chết LCP: Gán
loading="lazy"cho toàn bộ thẻ sản phẩm khiến ảnh hàng đầu tiên bị trình duyệt trì hoãn tải. Đã sửa: 5 sản phẩm đầu không lazyload, 2 ảnh đầu cófetchpriority="high". - Heading chrome giao diện & WooCommerce tabs (04/10/2026):
- 3 tiêu đề cột footer (Sản phẩm, Thông tin, Liên hệ) đổi từ
<h2>sang<p class="ftr__title">để tránh chèn H2 rác vào outline của bot trên mọi trang. - Bỏ H2 "Mô tả" (woocommerce_product_description_heading) và H2 "Thông tin bổ sung" (woocommerce_product_additional_information_heading) mặc định của WooCommerce tab panels để tránh trùng lặp với H2 trong nội dung sản phẩm. - Ẩn tab "Thông tin bổ sung" nếu sản phẩm không có thuộc tính biến thể (chỉ có 1 dòng cân nặng tính ship) quawoocommerce_product_tabs. - Thống nhất một bộ quy tắc CSS heading H2/H3 dùng chung cho cả 3 container:.post__content,.cat-seo__body,.woocommerce-tabs .panel(theoCLAUDE.md). - Địa chỉ shop công bố được ghi vàoinc/site-config.php(address), tự động hiển thị ở cột Liên hệ footer. - Bẫy profile nhà sản xuất 1688 (04/10/2026):
Nhà sản xuất 1688 ghi
原厂高度(nguyên xưởng / original) có thể thực tế là OEM chứ không phải Cherry. Điển hình: bộ Dark Fragrance (#1483) và Lake Light Mountain Color (#1484) xưởng ghi 原厂高度 nhưng shop kiểm hàng thực tế là khuôn OEM. Luôn hỏi shop kiểm hàng thực tế trước khi kết luận profile hoặc chuyển danh mục.
3b. Tối ưu Hiệu năng & Web Vitals (2026-09-24)
- Preload LCP Image & Picture WebP:
- Trang chủ tự preload
hero-cover.webp(67KB, giảm 45% so với JPG 122KB) trong<head>vớifetchpriority="high". - PDP tự preload ảnh đại diện sản phẩm (woocommerce_single) trong<head>. - Loại bỏ Render Blocking:
-
tgk-main(CSS) không còn phụ thuộc vào Google Fontstgk-fonts. CSS theme nạp và áp dụng ngay lập tức mà không chờ mạng ngoại vi. - Bổ sungpreconnecttớifonts.googleapis.comvàfonts.gstatic.com. - Dọn sạch Bloat Scripts trên Frontend:
- Gỡ bỏ
jquery-migratetrên toàn bộ frontend (tiết kiệm 1 request + 10KB blocking). - Gỡ bỏ chuỗi Gutenberg dependencies thừa thãi (underscore,wp-util,wp-api-request,wp-api-fetch,wp-polyfill,wp-url, v.v.) do pluginwoo-variation-swatcheskhai báo thừa. - Tắt tính năngwoocommerce_feature_order_attribution_enabledcủa core (bỏsourcebuster.jsvàorder-attribution.js) vìtgk-trackingđã quản lý attribution qua LocalStorage. - Loại bỏ Query DB lặp lại trong Footer:
- Thay 5 lời gọi
get_page_by_path()bằng URL tĩnh trực tiếp trongfooter.php.
3c. Đăng nhập / Đăng ký / Xác thực email
Code ở inc/account.php + woocommerce/myaccount/form-login.php.
Trang đăng nhập
| URL | Nội dung |
|---|---|
/dang-nhap/ |
form đăng nhập |
/dang-ky/ |
form đăng ký |
/tai-khoan/ |
tài khoản (đã đăng nhập) / form đăng nhập (chưa) |
/dang-nhap/ và /dang-ky/ là rewrite rule thật (inc/account.php), trỏ về trang
tài khoản kèm query var tgk_auth. Không dùng ?form= — URL cứng thì chia sẻ được, đặt
vào menu/nút được. Nút "Đăng nhập / Đăng ký" ở header trỏ thẳng /dang-nhap/.
Việc chọn form do server quyết (query var, hoặc phát hiện $_POST['register']), JS chỉ
đổi cho mượt. Lý do: submit đăng ký lỗi thì phải quay lại đúng form đăng ký để khách thấy
lỗi, và tắt JavaScript vẫn dùng được.
flush_rewrite_rules() chỉ chạy một lần (cờ tgk_auth_rewrite) — gọi mỗi request là
rất nặng.
Form đăng ký
- Không có ô "Tên đăng nhập" riêng — dùng email làm tên đăng nhập
(
woocommerce_registration_generate_username = yes, WooCommerce tự sinh username từ phần trước dấu @, tự thêm số khi trùng). - Hai ô mật khẩu (nhập + nhập lại), có checklist yêu cầu tick dần khi gõ.
- Ô tick đồng ý Chính sách bảo mật, có link thật.
Quy tắc mật khẩu định nghĩa một lần ở tgk_password_rules() rồi dùng cho cả kiểm tra
server lẫn checklist hiển thị — để danh sách trên màn hình không bao giờ lệch với thứ
server thật sự bắt buộc. Hiện tại: ≥8 ký tự, có chữ thường, chữ hoa, chữ số.
Mọi kiểm tra đều lặp lại ở server. required và JS chỉ là trợ giúp giao diện; đã test
bằng cách POST thẳng bỏ qua trình duyệt: mật khẩu yếu / hai ô lệch / thiếu ô tick đều bị
chặn và không tạo user.
Bẫy: hook
woocommerce_registration_errorscũng chạy khi khách tạo tài khoản trong lúc thanh toán — form đó không có ô nhập lại mật khẩu và ô tick. Kiểm tra vô điều kiện là chặn luôn việc đặt hàng. Tất cả kiểm tra đều bọc trongif ( empty( $_POST['register'] ) ).
Đã gỡ đoạn văn bản quyền riêng tư mặc định của WooCommerce khỏi form đăng ký
(remove_action( 'woocommerce_register_form', 'wc_registration_privacy_policy_text', 20 ))
— nó lặp ý với ô tick ngay bên dưới. Đoạn ở trang thanh toán là hook khác, vẫn giữ.
Xác thực email
| Kiểu đăng ký | Bắt xác thực? |
|---|---|
| Form trên web | Có — phải bấm link trong email mới đăng nhập được |
| Social (Nextend) | Không — Facebook/Google đã xác thực email hộ (hook nsl_*_register_new_user) |
Cơ chế an toàn bắt buộc: khi bật tính năng, tất cả tài khoản CÓ TRƯỚC đó được đánh dấu
đã xác thực (chạy 1 lần, cờ tgk_verify_migrated). Không có bước này thì toàn bộ khách cũ
bị khoá đăng nhập ngay lập tức — kiểu lỗi mất khách mà không ai báo. Quản trị viên
(manage_options) cũng luôn được bỏ qua, tránh tự khoá mình ra khỏi site.
Chi tiết khác:
- Token lưu dạng SHA-256 hash, không lưu thô → rò rỉ DB cũng không kích hoạt hộ được.
- So sánh bằng hash_equals() — không rò rỉ thời gian, chống dò token.
- Hết hạn sau 48 giờ; có link "Gửi lại email kích hoạt".
- Đăng ký xong không tự đăng nhập (woocommerce_registration_auth_new_customer → false),
nếu không thì việc bắt xác thực thành vô nghĩa.
- Đã tắt email "tài khoản mới" của WooCommerce cho user chưa xác thực, và thay câu thông
báo mặc định của WC ("Thông tin đăng nhập đã gửi về email") — câu đó sai vì email ấy đã bị
tắt, khách sẽ ngồi chờ một lá thư không tồn tại.
- Chức năng "gửi lại" trả cùng một câu dù tài khoản có tồn tại hay không → không biến
trang này thành công cụ dò xem email nào đã đăng ký.
Bẫy: thông báo bị mất khi redirect
Với khách chưa đăng nhập, WooCommerce chưa mở session nên chưa gửi cookie —
wc_add_notice() chỉ nằm trong bộ nhớ rồi mất sạch khi chuyển trang. Hậu quả: bấm link kích
hoạt hỏng/hết hạn sẽ ra trang trơn không lời giải thích. Dùng tgk_notice() (ép
set_customer_session_cookie(true) trước) thay cho wc_add_notice() ở mọi nhánh dành cho
khách chưa đăng nhập.
Bẫy: trang WooCommerce đi qua page.php
Tài khoản / giỏ hàng / thanh toán là page chứa shortcode, nên đi qua page.php. Nếu
page.php in breadcrumb + <h1> tiêu đề page thì:
- trùng tiêu đề (template WC đã tự in "Đăng nhập", "Giỏ hàng"…), và hiện ra tên page gốc
tiếng Anh ("My account");
- .page-single__content bó 820px căn trái → form đăng nhập 420px lệch hẳn sang trái
so với khung 1240px.
page.php nay bỏ qua breadcrumb + h1 cho 3 trang này (page-single--wc bỏ luôn giới hạn
820px), và mỗi template WC tự in H1 của nó (.cart__title, .co__title, .acc__title,
tiêu đề trong .auth__card). Kiểm tra lại: mọi trang đúng 1 H1.
Tên page cũng đã đổi sang tiếng Việt (Tài khoản / Giỏ hàng / Thanh toán).
Bẫy: hộp lồng hộp ở form đăng nhập
WooCommerce tự bọc form.login / form.register trong khung có viền riêng → nằm trong
.auth__card thành hai lớp khung, nhìn rối và mờ. CSS đã bỏ border/padding của khung đó.
Nút đăng nhập social
Plugin Nextend Social Login đã cài, tự chèn nút vào hook woocommerce_login_form_end /
woocommerce_register_form_end — template đã gọi sẵn 2 hook này nên nút hiện đúng chỗ ngay
khi cấu hình xong. CSS đã có sẵn (.auth__form .nsl-container, có dải phân cách "hoặc").
Chưa hoạt động vì chưa có App ID/Secret của Facebook và Google — phải tạo app bên Facebook Developers / Google Cloud Console.
3d. Trang sản phẩm — hàng mua hàng
inc/product-buybox.php + module PDP trong assets/js/main.js.
Bố cục: [ô số lượng] [Thêm vào giỏ] [Mua ngay].
Không override template nào — chỉ móc hai hook có sẵn trong cả
simple.php lẫn variation-add-to-cart-button.php:
woocommerce_before_add_to_cart_quantity (mở <div class="pdp-buy">) và
woocommerce_after_add_to_cart_button (chèn nút Mua ngay + đóng div).
"Thêm vào giỏ" — AJAX
Gọi đúng endpoint gốc ?wc-ajax=add_to_cart của WooCommerce rồi thay fragment
y như wc-add-to-cart.js vẫn làm, và phát sự kiện jQuery added_to_cart.
Đi đúng đường đó thì hai thứ tự chạy mà không phải viết thêm gì:
- badge giỏ ở header nhảy số (fragment
span.hdr__cart-badge-wrap), - plugin
tgk-trackingbắn event Meta AddToCart (nó ngheadded_to_cart).
⚠️ WC_AJAX::add_to_cart nhận product_id là ID BIẾN THỂ với hàng có biến
thể — nó tự suy ra sản phẩm cha và thuộc tính, KHÔNG đọc variation_id hay
attribute_* từ POST. Gửi ID cha là thêm nhầm hàng.
Không còn dải thông báo "đã thêm vào giỏ" của WooCommerce vì trang không tải lại. Phản hồi thay thế: nhãn nút đổi thành "Đã thêm ✓" 1,8 giây + badge nảy một nhịp.
Server từ chối (hết hàng, vượt giới hạn) thì JS nộp form thật để WooCommerce hiện đúng lý do, thay vì im lặng không có gì xảy ra. Tắt JS cũng vẫn mua được — form submit bình thường.
"Mua ngay" — cố ý KHÔNG dùng AJAX
Submit form thật, woocommerce_add_to_cart_redirect đổi đích sang trang thanh
toán. Đằng nào cũng rời trang, mà đi đường native thì mọi validate và thông báo
lỗi của WooCommerce giữ nguyên.
- Không xoá giỏ đang có — chỉ thêm món này rồi sang thanh toán. Xoá giỏ hộ khách là phá dữ liệu, không làm nếu chủ shop chưa yêu cầu.
- Notice "đã thêm vào giỏ" bị tắt riêng cho luồng này (
wc_add_to_cart_message_html), nếu không khách đang trả tiền lại thấy lời mời quay về giỏ hàng. - Hàng biến thể: nút khoá sẵn, JS mở khi WooCommerce bắn
show_variation— bám đúng cơ chế WC dùng để khoá nút thêm giỏ.
⚠️ Bẫy khi bọc div bằng hook: external.php bắn
woocommerce_after_add_to_cart_button nhưng KHÔNG bắn hook số lượng. Đóng div
mù ở đó là nhả một </div> thừa. Code dùng cờ $GLOBALS['tgk_buybox_open'].
⚠️ BẪY ĐÃ SẬP THẬT (11/09/2026) — "Mua ngay" không hoạt động trên sản phẩm
simple. Nguyên nhân: simple.php gốc chỉ gắn add-to-cart=<id> vào CHÍNH
nút submit gốc (<button name="add-to-cart" value="…">), không có ô ẩn nào
mang giá trị đó. HTML form chỉ gửi name/value của nút được bấm, không gửi
của nút khác cùng form — nên bấm "Mua ngay" (tên tgk_buy_now) thì
add-to-cart không hề có trong POST, và add_to_cart_action() của WooCommerce
return ngay dòng đầu: không lỗi, không log, chỉ im lặng không làm gì, khách ở
nguyên trang cũ.
Sản phẩm biến thể KHÔNG dính bẫy này — variation-add-to-cart-button.php vốn
đã có sẵn một ô ẩn add-to-cart không gắn với nút nào, nên mọi nút bấm trong
form đều kéo theo nó. Đây là lý do bug chỉ lộ ra ở một loại sản phẩm chứ không
phải cả hai — kiểm bằng tay ở đúng một loại là bỏ sót loại kia.
Fix: thêm đúng một ô ẩn <input type="hidden" name="add-to-cart" value="<id>">
cho riêng sản phẩm simple (mục 1b trong product-buybox.php). Trùng giá trị
với nút gốc nên vô hại khi cả hai cùng có mặt trong POST.
Bài học quy trình: test bằng curl -X POST --data 'add-to-cart=1512&…tgk_buy_now=1'
ở phiên trước đã BÁO ĐÚNG (302 redirect) — vì gõ tay đúng cả add-to-cart lẫn
tgk_buy_now cùng lúc, thứ mà một cú click thật trên nút tgk_buy_now
không bao giờ tạo ra. Test bằng cách tự dựng payload thay vì mô phỏng đúng
hành vi click sẽ bỏ lọt đúng loại bug này. Từ nay QA nút bấm phải bằng trình
duyệt thật (Playwright, xem docs/OPS.md §Chụp màn hình / QA), không suy từ
request tự tay ghép.
3e. Thông báo WooCommerce — mặc định TẮT
Chủ shop chốt 11/09/2026: bỏ hết mấy dải thông báo vụn của WooCommerce ("… đã xóa. Khôi phục?", "Chưa có sản phẩm nào trong giỏ hàng.", "Đã cập nhật giỏ hàng."). Chúng nói lại đúng thứ khách vừa nhìn thấy tận mắt mà chiếm một dải to trên đầu trang.
Quy tắc nằm ở inc/notices.php — sửa ở đó. Ba template
woocommerce/notices/*.php chỉ thêm một dòng lọc rồi giữ nguyên markup gốc.
| Loại | Mặc định |
|---|---|
success (xanh, "đã …") |
tắt |
notice (xanh dương, thông tin) |
tắt |
error (đỏ) |
giữ |
⚠️ error cố ý không tắt. Đó là cách khách biết vì sao không đặt được hàng
("mã giảm giá không hợp lệ", "kho chỉ còn 2 cái"). Tắt đi là khách bấm nút mà
không có gì xảy ra, không hiểu tại sao, rồi bỏ đi — mất đơn mà không ai biết.
Bật lại một thông báo cụ thể
Thêm một mẩu chữ đặc trưng của nó vào tgk_notice_allowlist():
function tgk_notice_allowlist() {
return apply_filters( 'tgk_notice_allowlist', [
'Mã ưu đãi', // "Mã ưu đãi đã được áp dụng thành công."
'đã được cập nhật', // lưu địa chỉ / đổi thông tin tài khoản
] );
}
Khớp theo chuỗi con, không phân biệt hoa thường.
Vì sao chặn ở template chứ không ở filter
WooCommerce có bộ filter woocommerce_add_{success|notice|error}. Dùng chúng
chỉ chặn được một nửa:
wc_add_notice()(đường session) có kiểm tra chuỗi rỗng → chặn được.wc_print_notice()(in thẳng tại chỗ) KHÔNG kiểm tra → trả chuỗi rỗng là nó vẫn render template và in ra một hộp viền trống không có chữ, tệ hơn lúc đầu.
Cả hai đường đều đi qua templates/notices/{loại}.php, và mỗi template đã sẵn
có if ( ! $notices ) return;. Nên chặn ở đó là một điểm duy nhất, đúng cho cả
hai đường, không sinh hộp rỗng.
⚠️ Đừng nhầm woocommerce_add_notice là filter chung cho mọi thông báo — nó là
filter của riêng loại notice và chỉ nhận 1 tham số. Khai 2 tham số đã
từng làm sập trang giỏ hàng bằng ArgumentCountError (HTTP 500, log trống).
3f. Trang thanh toán
Code ở inc/checkout.php.
Địa chỉ 2 cấp (sau cải cách 01/07/2025)
Việt Nam đã bỏ cấp quận/huyện, còn hai cấp: Tỉnh/Thành phố (34) → Phường/Xã (3.321).
WooCommerce không có sẵn tỉnh nào cho VN — get_states('VN') trả mảng rỗng, state và
city đều là ô text tự do. Nên phải tự nạp:
| Field WooCommerce | Vai trò | Nguồn |
|---|---|---|
billing_state |
Tỉnh / Thành phố | data/vn-provinces.json qua filter woocommerce_states |
billing_city |
Phường / Xã | data/vn-wards.json, JS đổ theo tỉnh đã chọn |
billing_address_1 |
số nhà, tên đường | — |
Dữ liệu lấy từ provinces.open-api.vn/api/v2 (đã là cấu trúc mới). File phường 120KB
nên không nhúng vào trang — main.js nạp lười bằng fetch khi khách chạm vào ô tỉnh
(gzip còn ~27KB).
Kiểm tra ở server, không chỉ dropdown: woocommerce_after_checkout_validation đối chiếu
phường có thuộc đúng tỉnh đã chọn không. Dropdown chỉ chặn ở trình duyệt; POST thẳng là qua
được, mà địa chỉ sai tỉnh thì đơn không giao được.
Bẫy: format địa chỉ mặc định của WooCommerce cho VN là
{name}\n{company}\n{address_1}\n{address_2}\n{city} {postcode}\n{country}— không có{state}. Hợp lý khi WC chưa có tỉnh nào cho VN, nhưng sau khi ta thêm 34 tỉnh thì địa chỉ in ra đơn/email thiếu hẳn tỉnh/thành, shipper không biết giao đi đâu. Đã sửa quawoocommerce_localisation_address_formats, kèm đảo thứ tự tên:{name}của WooCommerce là "Tên Họ" kiểu Tây, người Việt viết "Họ Tên đệm Tên".
Thứ tự field
Ai nhận → liên hệ thế nào → giao ở đâu. Ô ngắn ghép đôi cho form đỡ dài:
| Dòng | Field |
|---|---|
| 1 | Họ · Tên đệm và tên |
| 2 | Số điện thoại (bắt buộc) · Email |
| 3 | Tỉnh/Thành phố · Phường/Xã |
| 4 | Địa chỉ cụ thể |
Đã ẩn: Quốc gia (chỉ bán trong nước — chuyển sang hidden với giá trị VN, không xoá
field vì WooCommerce cần country để tính thuế/vận chuyển), mã bưu điện, "Căn hộ/dãy phòng",
tên công ty.
Đã dọn khỏi giao diện
- Dải "Bạn có mã ưu đãi? Ấn vào đây…" ở đầu trang → thay bằng ô nhập ngay dưới bảng "Đơn hàng của bạn", áp mã bằng AJAX không tải lại trang.
- Tiêu đề "Thông tin nhận hàng" / "Thông tin thanh toán" / "Thông tin bổ sung" — cột trái chỉ có một khối field, tiêu đề không phân biệt được gì.
- Đoạn "Dữ liệu cá nhân của bạn sẽ được sử dụng để xử lý đơn hàng…" ngay trên nút đặt hàng.
Đã kiểm chứng
| Test | Kết quả |
|---|---|
| Mã giảm giá áp ở giỏ → sang checkout | Vẫn giữ (450.000 → 405.000) ✅ |
| Phường không thuộc tỉnh đã chọn (POST thẳng) | Chặn ✅ |
| Phường bịa ra | Chặn ✅ |
| Thiếu số điện thoại | Chặn ✅ |
| Hà Nội → 126 phường; TP.HCM → 168 phường | Đúng ✅ |
4. Hai bug WooCommerce âm thầm (ĐÃ FIX — đừng gỡ)
Cả hai đều từng làm sập site Subaru Đồng Nai. Code trong functions.php.
4.1 product_base = '.'
Để sản phẩm ở URL gốc /{slug}/, trực giác là đặt product_base = '/' hoặc ''.
Sai: chuỗi rỗng làm WooCommerce đặt rewrite = false, tắt hẳn rewrite của post type.
Dùng '.'. Rewrite thành ./(...) — không bao giờ khớp URL thật, nên không sinh URL trùng lặp. Việc phân giải do filter request đảm nhiệm.
Kèm 2 filter bắt buộc:
- post_type_link / term_link: bỏ /./ khỏi URL sinh ra. Không có nó, redirect_canonical() tự 301 sang URL sai.
- request: slug 1 cấp → tra DB, khớp product thì trả post_type=product, khớp product_cat thì trả product_cat. Đây mới là thứ thực sự route URL gốc.
4.2 woocommerce_coming_soon
WooCommerce mặc định bật "Coming Soon", che toàn bộ trang shop/sản phẩm bằng block placeholder đè lên mọi template theme. Phải update_option('woocommerce_coming_soon', 'no').
5. Quyết định đáng lưu ý
| Quyết định | Lý do |
|---|---|
| Cart/checkout dùng shortcode, không dùng Cart/Checkout Block | Đúng tinh thần WP thuần (không thêm tầng React); khớp CSS theme; server-render nên verify được bằng curl trước cutover — Block render bằng JS thì không kiểm chứng được. SePay hỗ trợ cả hai, nên đổi lại được bất cứ lúc nào. |
| GIỮ CSS WooCommerce (khác theme Subaru vốn dequeue) | Site này bán hàng thật; form biến thể/giỏ/checkout phụ thuộc nặng CSS gốc WC. |
Bỏ wp-block-library (~137KB) có điều kiện |
0/24 bài viết dùng block. Có kiểm tra tgk_content_uses_blocks() nên nếu sau này soạn bằng block editor, CSS tự được giữ — không vỡ âm thầm. |
KHÔNG cố bỏ wc-blocks-style (~13KB) |
WooCommerce enqueue muộn hơn mọi hook theme; chặn được chỉ bằng hack dễ vỡ khi WC update. |
Tắt redirect_guess_404_permalink |
WP đoán permalink theo tiền tố: /product-category/keycap/ bị 301 sang bài keycap-double-shot-la-gi. Thà 404 rõ ràng hơn 301 sai. |
| Không giữ nút burger mobile | Nav cuộn ngang là hành vi đã được duyệt ở bản Astro; không đổi UX đã chốt trong lúc migrate. |
Meta trang chủ giữ nguyên (title = "Thế Giới Keycap") |
Không đổi tín hiệu SEO trong lúc migrate. Tối ưu là việc riêng sau. |
6. Bản đồ URL
| URL | Nguồn |
|---|---|
/ |
front-page.php (page trang-chu, ID 180) |
/{slug}/ |
product → product_cat → post → page (filter request) |
/san-pham/ |
trang shop WC (ID 1710 — tạo lại, ID 13 cũ đã bị xoá khỏi DB) |
/tin-tuc/ |
page tin-tuc (ID 1709, template page-tin-tuc.php) |
/gio-hang/ /thanh-toan/ /tai-khoan/ |
trang WC, shortcode cổ điển |
/tai-khoan/don-hang/ /thong-tin/ /dia-chi/ /quen-mat-khau/ |
endpoint WC đã Việt hoá |
/test-ban-phim/ |
page ID 1584, template riêng |
301 đang áp
- 4 bài định nghĩa → danh mục tương ứng (gom intent mua):
keycap-la-gi→/keycap/,profile-keycap-{moa,oem,xda}-la-gi→ danh mục tương ứng. Đã loại khỏi sitemap. - Route cũ bản Astro:
/tai-khoan/dang-nhap|dang-ky/→/tai-khoan/,/trang-chu/→/ - Base cũ WooCommerce:
/product/{slug}/,/product-category/{slug}/,/danh-muc/{slug}/,/product-tag/{slug}/→ slug gốc
7. Kết quả kiểm chứng (cổng test localhost)
- Parity sitemap Astro → WP: 61/62 = 200. URL còn lại
/lien-he/là bug có sẵn của sitemap Astro (hardcode trongstaticPathsnhưng trang chưa từng tồn tại — bản live cũng 404). - Sitemap WP (Rank Math): 65/65 = 200
- H1: đúng 1 trên mọi loại trang
- Meta danh mục: đúng bản viết theo hướng bán hàng
- Content box: giữ nguyên format, 0 H1 (H1 là tên danh mục)
- Giỏ hàng → checkout: 2 dòng sp, freeship đúng ngưỡng 500K, đủ COD + SePay
- PDP: gallery native, biến thể + swatch, khối cam kết
- Đã KHÔNG đặt đơn test: plugin
send-apphookwoocommerce_new_order→ sẽ bắn thông báo thật.
8. Runbook cutover
# 1) Áp config
cp /srv/thegioikeycap.vn/docker/nginx-thegioikeycap.vn.CUTOVER.conf \
/etc/nginx/sites-enabled/thegioikeycap.vn.conf
nginx -t && systemctl reload nginx
# 2) Tắt Astro
systemctl stop keycap-astro && systemctl disable keycap-astro
# 3) Kiểm tra
curl -sI https://thegioikeycap.vn/ | head -1
curl -s https://thegioikeycap.vn/keycap/ | grep -c pcard__media # kỳ vọng 20
# 4) Dọn cổng test
rm /etc/nginx/sites-enabled/keycap-wp-test.conf
nginx -t && systemctl reload nginx
Rollback (~10 giây)
cp /srv/thegioikeycap.vn/docker/nginx-thegioikeycap.vn.ASTRO-ROLLBACK.conf \
/etc/nginx/sites-enabled/thegioikeycap.vn.conf
nginx -t && systemctl reload nginx
systemctl start keycap-astro
Không cần đổi theme khi rollback: bản Astro là headless, chỉ đọc REST API.
9. Việc còn lại (cần bạn quyết)
- Đơn test thật — cần tắt tạm
send-appđể không bắn thông báo cho khách/chủ shop. - Phí vận chuyển — hiện không có shipping zone nào, nên WooCommerce tự tắt yêu cầu vận chuyển: mọi đơn đều không thu phí ship (giống bản Astro). Nếu muốn thu phí cho đơn dưới 500K thì cần tạo zone "Việt Nam" + Flat rate + Free shipping (min 500.000).
- Trang
/lien-he/— đang trong sitemap Astro nhưng chưa từng tồn tại (Google đã nhận 404 một thời gian). Nên tạo trang liên hệ thật. - Gỡ plugin không còn cần —
jwt-authentication-for-wp-rest-apichỉ để bản Astro đăng nhập; sau cutover thì bỏ được.
10. Bẫy khi sửa file trong www/
Sửa file bằng tool (Edit/Write) làm reset owner về root:root. PHP-FPM chạy www-data → không đọc được → trắng trang "Permission denied", không có log. Đã làm sập production 1 phút vì lỗi này.
Sau mọi lần ghi file trong www/:
chown -R www-data:www-data /srv/thegioikeycap.vn/www/wp-content/themes/thegioikeycap
OPcache: revalidate_freq = 60 → thay đổi PHP mất tới 60 giây mới có hiệu lực. Đừng kết luận "code không chạy" trước khi chờ đủ.