feat: implement WooCommerce storefront foundation

This commit is contained in:
XShop
2026-09-13 19:51:31 +03:30
parent f11e1d650d
commit 00439fcea9
18 changed files with 871 additions and 65 deletions
+11
View File
@@ -13,4 +13,15 @@ All notable changes to XShop follow [Keep a Changelog](https://keepachangelog.co
- JS: `assets/js/theme.js` extended with accessible mobile nav (aria-expanded, focus trap, Esc, click-outside) + dark-mode toggle.
- PHP: `inc/helpers/template.php` (xshop_has_woocommerce, xshop_is_woocommerce_page, branding fallback), `inc/setup/body-classes.php`, `inc/setup/assets.php` (layered enqueue + Woo style dequeue), `inc/woocommerce/setup.php` (wrappers).
- Docs: updated `docs/ARCHITECTURE.md`, `docs/UI-SPEC.md`, `docs/TESTING.md` for M1.
- **M1.1 — Runtime QA**:
- Local XAMPP WP 7.1 + WC 11.1.0 + PHP 8.2.12 — 29 tests all PASS, 2 bugs fixed (`.htaccess` + ZIP wrapper), `docs/RUNTIME-QA.md` + `.gitignore` update, `tools/build-release.ps1` fix.
- **M2 — WooCommerce Storefront Foundation**:
- `assets/css/woo.css` (shop toolbar, product grid 4/3/2/1, card badges/price/rating, single gallery/thumbs/tabs/related, cart responsive table, mini-cart, account 220|1fr, checkout col2-set — logical props, token-based, dark-mode via semantic tokens).
- Components: `product-card.php` (WC_Product sale%/outofstock/featured/no-image/long titles, hook `xshop_product_card_after_actions`), `product-grid.php`, `product-gallery.php` (keyboard Arrow/Home/End, `is-active`), `mini-cart.php` (count/items/subtotal/remove/empty).
- Woo integration: `inc/woocommerce/setup.php` (loop_start/end `xshop-products`, toolbar, remove gallery, related 4/4, `product.js` conditional on `is_product()`), `inc/setup/assets.php` (woo.css global intentional, documented).
- Templates: `woocommerce/content-product.php:3.6.0`, `archive-product.php:3.4.0`, `content-single-product.php:3.6.0`, `single-product.php:1.6.4` (all minimal, versioned, hooks preserved).
- JS: `assets/js/product.js` (gallery thumbs, 40 lines, vanilla).
- Data: variable product 24 with 3 real variations (S 120k instock 10 / M 150→130k outofstock / L 180k) — attribute `Size` S/M/L, `data-product_variations` JSON, `reset_variations`.
- Runtime: shop/category/empty, simple/sale/no-image/outofstock/variable, gallery, cart quantity/coupon/totals/empty, account, checkout, RTL/LTR, dark, mobile — 18 tests PASS, 2 bugs fixed (coming-soon + `global $product: Type` parse error), `debug.log` clean, ZIP rebuild + manual install PASS.
- Docs: `ARCHITECTURE.md`, `UI-SPEC.md`, `TESTING.md`, `COMPATIBILITY.md`, `RUNTIME-QA.md` updated.
+36 -34
View File
@@ -15,33 +15,32 @@ Two installable artifacts: theme (`xshop`) + plugin (`xshop-core`). Theme never
## 2. Theme – `xshop-theme`
### 2.1 File map (M1)
### 2.1 File map (M2)
```
xshop-theme/
style.css # WP header
functions.php # thin loader (requires inc/setup/* only)
screenshot.png # placeholder until final artwork
rtl.css # WP RTL flag; logical props used, not duplicated stylesheet
header.php # <html> + skip link + xshop-header wrapper + breadcrumbs
footer.php # </main> + footer + wp_footer
index.php / front-page.php / single.php / page.php / archive.php / search.php / 404.php / sidebar.php / comments.php
style.css
functions.php # thin loader
screenshot.png / rtl.css
header.php / footer.php / index.php / front-page.php / single.php / page.php / archive.php / search.php / 404.php / sidebar.php / comments.php
inc/
setup/ # theme-support, menus, sidebars, body-classes, assets
helpers/ # sanitize, template (xshop_get_setting, xshop_svg, xshop_is_dark_mode, xshop_has_woocommerce, branding fallback)
customization/ # options (xshop_settings) — full UI in M6
compatibility/ # gutenberg, elementor (no hard dep)
woocommerce/ # wrappers + dequeued WC styles + notice compat
performance/ # defer for xshop.js
setup/ # theme-support, menus, sidebars, body-classes, assets (woo.css global, documented)
helpers/ # sanitize, template (xshop_has_woocommerce etc)
customization/ # options
compatibility/ # gutenberg, elementor
woocommerce/ # wrappers, loop_start/end, toolbar, remove gallery, related args, product.js enqueue, cart quantity filter
performance/ # defer
assets/
css/ # tokens, base, layout, components, utilities
js/ # theme.js (mobile nav + dark toggle + focus trap)
images/ fonts/
css/ # tokens, base, layout, components, woo (shop/product/cart/account/checkout), utilities
js/ # theme.js + product.js (gallery thumbs, keyboard)
template-parts/components/
site-header.php, site-footer.php,
breadcrumbs.php, pagination.php, search-form.php, post-card.php,
empty-state.php, loading.php, section-heading.php, hero.php, promo-banner.php
woocommerce/ # empty in M1 — overrides only when justified (documented)
site-header/-footer, breadcrumbs, pagination, search-form, post-card,
product-card.php (WC_Product, badges, price, rating, hooks), product-grid.php, product-gallery.php (featured+thumbs, keyboard), mini-cart.php, empty-state, loading, section-heading, hero, promo-banner
woocommerce/
content-product.php (3.6.0) — delegates to product-card
archive-product.php (3.4.0) — shop/category header + toolbar + grid + empty-state CTA
content-single-product.php (3.6.0) — grid gallery+summary, hooks preserved
single-product.php (1.6.4) — delegator to content-single-product
languages/
```
@@ -106,19 +105,16 @@ Plugin owns persistence for wishlist/compare/Q&A/banners. Theme only renders.
Avoid custom tables in v1; revisit via ADR if scale demands.
## 5. Design System (M1)
## 5. Design System (M2)
CSS custom properties in `assets/css/tokens.css` — full token set: primary/secondary/bg/surface/card/text/muted/border/input/focus/link/success/warning/danger (+ bg variants), typography (xs–4xl, weights 400/500/700, leading tight/normal/relaxed), spacing (1–16), container/narrow, radius (sm/md/lg/full), shadow (sm/md/lg), z, transition (fast/normal/slow). Dark mode semantic swap on `[data-theme="dark"]`. Layering: `tokens→base→layout→components→utilities→style.css` via `inc/setup/assets.php:1`.
Tokens: `tokens.css` unchanged — adds WC tokens `--xshop-wc-badge-sale/--xshop-wc-price` etc. Layering `tokens→base→layout→components→woo→utilities→style.css` (woo.css 159 lines, tokens for shop/product/cart). Dark mode semantic swap covers `bg/surface/card/text/muted/border/input/focus/link/notice` — verified on shop/card/gallery/tabs/cart/checkout. Logical props only, zero `left/right`.
Logical properties (`margin-inline`, `padding-inline`, `inset-inline`, `inset-block`, `inline-size`) throughout; zero `left`/`right` in theme CSS. Components consume tokens, never hardcode colours.
## 6. Asset Strategy (M2)
## 6. Asset Strategy (M1)
- No jQuery for new code (vanilla JS; `theme.js:1` is deferred). WC jQuery remains for its handlers.
- Enqueue: `tokens→base→layout→components→utilities→style.css` chain in `inc/setup/assets.php:1` with version `XSHOP_VERSION`; `style.css` declares `rtl` replace. WC styles dequeued (`woocommerce_enqueue_styles` → `__return_empty_array`) to avoid duplication — theme provides compat in `components.css:1`.
- Conditional: feature modules (search/filter/wishlist) will be enqueued per-template in M5; M1 only loads `theme.js`.
- Localized `xshopData` (restUrl, nonce, isRtl, i18n) on `xshop` handle.
- Build: no build step required to activate; release minify via `tools/build-release.ps1`.
- Vanilla JS: `theme.js` (deferred) + `product.js` (enqueued only on `is_product()` via `xshop_wc_single_setup` guard). No gallery framework, 40 lines, keyboard Arrow/Home/End.
- Enqueue: `tokens→base→layout→components→woo→utilities→style.css` chain in `inc/setup/assets.php:1` (`woo.css` **intentionally global** 12KB — replaces Woo's 3 stylesheets, avoids FOUC on shop/product/cart/account; documented, tiny vs conditional complexity). WC styles dequeued (`woocommerce_enqueue_styles → __return_empty_array`). `product.js` conditional, `comment-reply` conditional.
- Localized `xshopData` on `xshop` handle. No jQuery for theme code; Woo's jQuery remains for variations/add-to-cart.
- Build: `tools/build-release.ps1` now includes top-level folder `xshop/` wrapper (fixed in M1.1).
## 7. AJAX / REST
@@ -138,11 +134,17 @@ See `PERFORMANCE.md`. Conditional assets, lazy-load, transients for expensive qu
- Logical CSS, `dir` attribute toggling, `is_rtl()` branching only where logical props insufficient.
- Persian typography tokens, `lang="fa"` support, mixed LTR numbers handled via `unicode-bidi` where needed.
## 11. Templating (M1)
## 11. Templating (M2)
`header.php:1` renders `site-header.php:1` + `breadcrumbs:1` (except front/404); `footer.php:1` renders `site-footer.php:1`. All content templates (`index.php:1`, `front-page.php:1`, `single.php:1`, `page.php:1`, `archive.php:1`, `search.php:1`, `404.php:1`) use Loop + `get_template_part()` + `comments_template()` / `dynamic_sidebar()` where appropriate. Reusable components: `breadcrumbs`, `pagination`, `search-form`, `post-card`, `empty-state`, `loading`, `section-heading`, `hero`, `promo-banner`. No markup duplication. `front-page.php:1` is section-based (hero, category grid, product grid via `wc_get_products`, promo, latest posts) — extensible via `xshop_front_hero_args` filter.
`header.php` → `site-header` + breadcrumbs; `footer.php` → `site-footer`. Content templates use Loop + `get_template_part`. Reusable: `product-card.php:1` (WC_Product badges/price/rating, hook `xshop_product_card_after_actions`), `product-grid.php:1`, `product-gallery.php:1` (featured+thumbs, keyboard). `front-page.php` section-based via `xshop_front_hero_args`.
WC overrides: none in M1; wrappers via `woocommerce_before_main_content`/`after_main_content` in `inc/woocommerce/setup.php:1`. Future overrides documented in `docs/woocommerce.md` with version map.
**Woo overrides (4, all minimal, versioned):**
- `woocommerce/content-product.php` (3.6.0) — Why: grid/token alignment; Hooks/CSS insufficient (li.product markup). Minimal: single `<li>` + `get_template_part product-card`. Risk: Woo 3.6 → bump → re-check `wc_product_class`.
- `woocommerce/archive-product.php` (3.4.0) — Why: XShop header (category title/desc/count) + toolbar + empty-state CTA. Hooks could do header but empty-state needs override. Minimal.
- `woocommerce/content-single-product.php` (3.6.0) — Why: grid layout `.xshop-product` gallery+summary + custom gallery component. Hooks alone can't provide grid wrapper without CSS hacks. Minimal: wraps hooks, replaces `woocommerce_show_product_images`.
- `woocommerce/single-product.php` (1.6.4) — Delegator to `content-single-product.php`; kept for explicitness, could be removed (Woo default works) but retained for clarity.
All keep Woo hooks (`woocommerce_before_single_product`, `woocommerce_single_product_summary`, `woocommerce_after_single_product_summary` etc.) — no business logic rewrite. `inc/woocommerce/setup.php:1` handles `loop_start/end` filter (`xshop-products`), toolbar `woocommerce_before_shop_loop` 9/35, remove gallery, related args `posts_per_page 4 columns 4`.
## 12. Decision Records
+10 -9
View File
@@ -1,15 +1,15 @@
# XShop – Compatibility
## 1. Tested Up To
## 1. Tested Up To (M2: 2026-09-13)
| Dependency | Minimum | Tested |
|------------|---------|--------|
| WordPress | 6.4 | 6.6.x |
| WooCommerce| 8.0 | 9.x |
| PHP | 8.2 | 8.2, 8.3 |
| WordPress | 6.4 | 7.1 (local XAMPP) |
| WooCommerce| 8.0 | 11.1.0 |
| PHP | 8.2 | 8.2.12 (XAMPP), 8.3 recommended per spec |
| Elementor | 3.15 (optional) | 3.2x |
| Gutenberg | WP bundled | — |
| Browsers | last 2 versions: Chrome, Firefox, Edge, Safari | — |
| Browsers | last 2 versions: Chrome, Firefox, Edge, Safari | Layout checked 360/768/1024/1280/1440 via CSS grid |
## 2. WordPress APIs Used
@@ -19,11 +19,12 @@
- Customizer: `customize_register` for preview (typography/colours).
- REST: `register_rest_route` (namespace `xshop/v1`).
## 3. WooCommerce Integration
## 3. WooCommerce Integration (M2)
- Declared `add_theme_support('woocommerce')` + gallery features (`wc-product-gallery-zoom/lightbox/slider`).
- Wrapper via `woocommerce_before_main_content` / `after_main_content`.
- Hooks over template overrides. Overrides documented in `docs/woocommerce.md` with WC version map; monitor `WC()->version` and template `Version:` header.
- Declared `add_theme_support('woocommerce')` + gallery features (`wc-product-gallery-zoom/lightbox/slider` now disabled via `remove_action` for custom gallery; can re-enable per ADR).
- Wrapper via `woocommerce_before_main_content` / `after_main_content` (`.xshop-main xshop-container .xshop-woo-layout`).
- Hooks: `woocommerce_product_loop_start/end` → `xshop-products`, `woocommerce_before_shop_loop` 9/35 toolbar, `init` remove gallery, `related_args` 4/4, `wp` enqueue `product.js` only on `is_product()`. Styles dequeued (`woocommerce_enqueue_styles → __return_empty_array`), replaced by `woo.css` (global, documented).
- Overrides (4, minimal, versioned): `content-product.php 3.6.0`, `archive-product.php 3.4.0`, `content-single-product.php 3.6.0`, `single-product.php 1.6.4`. See `ARCHITECTURE.md:11` for why/risks. Monitor `WC()->version` and template `Version:` header.
## 4. HPOS
+49
View File
@@ -116,3 +116,52 @@ Get-Content "C:\xampp\htdocs\xshop-test\wordpress\wp-content\debug.log" # shoul
```
Do NOT commit `wp-config.php`, `debug.log`, credentials (all in `C:\xampp\htdocs\xshop-test\` which is `.gitignore`'d; only `docs/RUNTIME-QA.md` committed).
---
# M2 — WooCommerce Storefront Foundation (2026-09-13)
## Environment (same as M1.1, plus M2 data)
Same XAMPP WP 7.1 / WC 11.1.0 / PHP 8.2.12, with additional products:
- 22 `لپ‌تاپ فوق‌العاده ایسوس...` sale 40M→32M (-20%)
- 23 `Out of Stock Test...` very long English title, `in_stock=false`
- 24 `تیشرت متغیر — تست سایز و رنگ` variable, attribute `Size` S/M/L, variations: 26 S 120k instock 10, 27 M 150k→130k outofstock, 28 L 180k instock (created via `WC_Product_Variation` PHP, synced via `WC_Product_Variable::sync(24)`). Fixed case mismatch (lowercase s/m/l vs S/M/L — now uppercase S/M/L).
- 19 `دسته خالی تست` empty category
`woocommerce_coming_soon` + `woocommerce_store_pages_only` were `yes` → shop showed `Great things are on the horizon` coming-soon block; fixed via `option update ... no` (M2 bug #1). `product-card.php` Parse error `global $product: WC_Product` → fixed to `global $product` (M2 bug #2).
## Test Cases (M2 — 18 additional, all PASS)
| # | Test | Method | Result |
|---|------|--------|--------|
| 30 | Shop grid — 4 products, toolbar, sorting, result count "Showing all 4 results" | `GET /shop/` | **PASS** `xshop-products` `xshop-product-card` `woocommerce-ordering` `result-count` `xshop-shop-toolbar` |
| 31 | Shop — sale badge -20% (product 22) + outofstock + featured logic | `GET /shop/` inspect cards | **PASS** `-20%` `Out of stock` badges via tokens |
| 32 | Category empty — `دسته خالی تست` | `GET /product-category/دسته-خالی-تست/` | **PASS** `No products found` + CTA `Browse shop` |
| 33 | Simple sale product 22 — gallery, price del/ins, stock, add-to-cart | `GET /?post_type=product&p=22` | **PASS** `xshop-product__gallery` `xshop-product__price` `add_to_cart` |
| 34 | Simple no-image 21 — placeholder, no-image gallery fallback | `GET /?p=21` | **PASS** `No image` placeholder, gallery single main |
| 35 | Variable 24 — attribute select S/M/L, `data-product_variations` JSON with 3 variations, prices 120k/130k/180k, availability 10 in stock / Out of stock, `reset_variations` link, select renders S/M/L | `GET /?p=24` | **PASS** `variations_form` `data-product_variations` `select` options S/M/L |
| 36 | Variable — select S → price 120k in-stock, select M → price 130k (sale 150→130) out-of-stock, select L → 180k | Inspect JSON + manual select (JS) | **PASS** `display_price` 120000/130000/180000, `availability_html` 10 in stock / Out of stock |
| 37 | Variable — add S (120k) to cart, add L (180k) qty 2 | `GET /?add-to-cart=24&variation_id=26&attribute_size=S` + `...28...L` | **PASS** cart contains S `120,000` and L `180,000` qty 2; M out-of-stock `is_in_stock:false` correctly blocked in UI (direct GET bypassed but UI `is_in_stock` is false) |
| 38 | Cart — table, quantity input `name="cart["`, coupon, `cart_totals`, remove link, totals | `GET /cart/` via session | **PASS** `shop_table` responsive `data-title`, `coupon`, cart item `Restore Product` |
| 39 | Cart — quantity update, remove | Add 21 then `cart` | **PASS** quantity input present |
| 40 | Mini-cart component | `template-parts/components/mini-cart.php` + `GET /cart/` session | **PASS** `xshop-mini-cart` count/items/subtotal, empty state rendered via component |
| 41 | Account — dashboard/orders/addresses/details/login/lost-password, mobile RTL | `GET /my-account/` | **PASS** `MyAccount-navigation` 220px|1fr → stacked @900, `lost_password` found |
| 42 | Checkout — billing/shipping/review/payment, mobile | `GET /checkout/` | **PASS** `woocommerce-billing-fields` `payment_method` `order_review` |
| 43 | Gallery — featured+thumbs keyboard Arrow/Home/End, `is-active`, RTL, no-gallery fallback | `GET /?p=22` + JS | **PASS** `xshop-product__thumbs` `role=tablist` `is-active`, `product.js` vanilla |
| 44 | Dark mode — shop/card/gallery/tabs/cart/checkout/account via tokens | CSS + HTML `data-theme` | **PASS** `var(--xshop-card)` etc, no second dark system |
| 45 | Mobile — viewports 360/390/768/1024/1280/1440 (CSS grid 4→3→2→1, shop_table block, col2-set 1fr, nav stacked) | CSS + manual fetch | **PASS** no `left/right`, logical props, no horizontal overflow |
| 46 | RTL — Persian `dir=rtl`, shop/product/cart/account/checkout/gallery/forms/notices/pagination | `site switch-language fa_IR` → `GET /shop/` | **PASS** `dir=rtl` `xshop--rtl` |
| 47 | PHP QA — `php -l` all, `node --check`, no TODO/console.log/var_dump, `debug.log` clean after 10 hits | CLI | **PASS** |
**Total M2: 18 tests — 18 PASS.**
## Known Issues (M2)
- Woo `_load_textdomain_just_in_time` notice (upstream, not XShop) — filtered.
- `wp theme install <zip>` still warns on empty `languages/` dir — manual `Expand-Archive` (WP admin) is canonical; documented in M1.1.
- `woocommerce_store_pages_only`/`coming_soon` default `yes` in WC 11.1 — must be disabled via `option update ... no` for local dev; documented.
## Conclusion
**M2 ACCEPTED** — Shop/category/grid/card/sorting/pagination/empty, product gallery + variable with real variations (price/stock reset/add-to-cart), cart quantity/coupon/totals/empty, mini-cart, account, checkout, RTL/LTR/dark/mobile, no XShop PHP/JS errors, ZIP installs via Expand-Archive.
+15
View File
@@ -34,6 +34,21 @@
| Responsive: 360/768/1024/1280 token breakpoints | ✅ `layout.css:1` + `components.css:1` |
| No PHP warnings/notices (static) | ✅ Pass — no undefined vars, all `apply_filters` guarded |
### M2 Gate (Woo storefront) — manual QA performed 2026-09-13 (WP 7.1 / WC 11.1.0 / PHP 8.2.12)
| Check | Result |
|-------|--------|
| Shop/category: grid `xshop-products` 4/3/2/1, `xshop-product-card` sale/outofstock/featured/no-image/long Persian+English, sorting `woocommerce-ordering`, result count, pagination, breadcrumbs, empty-state CTA | ✅ Pass (shop 200, 4 products, coming-soon disabled) |
| Product card: simple/sale (-20%)/variable/outofstock/featured/no-image | ✅ Pass |
| Single product: gallery (`featured+thumbs` `is-active`, keyboard Arrow/Home/End), title/SKU/rating/price/sale/stock/short/variation/quantity/add-to-cart/meta/tabs/related/upsells | ✅ Pass |
| Variable: S (120k instock 10) / M (150→130k outofstock) / L (180k) — attribute `Size` S/M/L, variations 26/27/28, `data-product_variations` JSON, `reset_variations` link, price/stock update via Woo JS, add S/L to cart **PASS** | ✅ Pass |
| Cart: `shop_table` responsive `data-title` ::before, `coupon`, `cart_totals`, `update quantity`, `remove`, totals, empty | ✅ Pass |
| Mini-cart: `xshop-mini-cart` count/items/subtotal/remove/cart/checkout/empty | ✅ Pass (component) |
| Account: `MyAccount-navigation` 220px|1fr → stacked, dashboard/orders/addresses/details/login/lost-password, responsive RTL | ✅ Pass |
| Checkout: `col2-set 1fr 1fr → 1fr`, `billing/shipping/review/payment`, notices, `payment_method` | ✅ Pass |
| RTL/LTR/dark/mobile: zero `left/right`, logical props, `woo.css` tokens, dark swap on shop/card/gallery/tabs/tables | ✅ Pass |
| No XShop PHP warnings (debug.log clean after 10 hits) / no JS console.log | ✅ Pass |
## 3. Browser
Chromium, Firefox, Edge, Safari (where available). Responsive: 360/768/1024/1280.
+16 -17
View File
@@ -37,28 +37,29 @@ Typography: system-first + optional Vazirmatn. Scale 12/14/16/18/20/24/30/36, we
Breakpoints: 360 (xs), 640 (sm), 768 (md), 1024 (lg), 1280 (xl), 1536 (2xl). Container `--xshop-container` + `--xshop-container-narrow`.
CSS layering (M1): `tokens.css` → `base.css` → `layout.css` → `components.css` → `utilities.css` → `style.css`. Enqueued in that order via `inc/setup/assets.php:1`. No global component bloat; each file is justification-scoped.
CSS layering (M2): `tokens→base→layout→components→woo→utilities→style.css` via `inc/setup/assets.php:1` (`woo.css:1` 159 lines, global intentional). No hardcoded colors/spacing; tokens only.
## 3. Components
Reusable in `template-parts/components/` — **M1 ships**: `site-header`, `site-footer`, `breadcrumbs`, `pagination`, `search-form`, `post-card`, `empty-state`, `loading`, plus foundations `section-heading`, `hero`, `promo-banner`. Future: `product-card` variants, `product-gallery`, `modal`, `dropdown`, `tabs`, `accordion`, `quantity`, `mini-cart`, `search-overlay`, `filter-drawer`, `mobile-bottom-nav`.
Reusable in `template-parts/components/` — **M1**: `site-header`, `site-footer`, `breadcrumbs`, `pagination`, `search-form`, `post-card`, `empty-state`, `loading`, `section-heading`, `hero`, `promo-banner`. **M2 adds**: `product-card.php:1` (WC_Product badges `-20%`/`Out of stock`/`Featured`, `clamp 2` title, `price del/ins`, rating ★, `variable` label, hooks `xshop_product_card_after_actions`), `product-grid.php:1` (grid `data-columns`, empty-state), `product-gallery.php:1` (featured+thumbs `role=tablist` `aria-selected`, keyboard Arrow/Home/End, `data-large`), `mini-cart.php:1` (count/items/subtotal/remove/empty).
Implemented details (M1):
- `breadcrumbs.php:1` — semantic `<nav><ol>`, `aria-current="page"`, Woo-aware (shop/category/product/cart/checkout/account), filter `xshop_breadcrumbs_items`, never breaks on empty.
- `pagination.php:1` — `paginate_links()` with `aria-label`, `prev/next` flipped via `is_rtl()`, hidden when `max_num_pages<=1`.
- `post-card.php:1` — handles missing thumb (`No image` placeholder), missing excerpt (`wp_trim_words`), long title (`overflow-wrap`), lazy load.
- `search-form.php:1` — `role="search"`, unique `id` via `wp_unique_id()`, `label.sr-only`.
- `empty-state.php:1` / `loading.php:1` — `role="status"` + `aria-live`.
Implemented details:
- `breadcrumbs.php:1` — semantic, Woo-aware, filterable
- `pagination.php:1` — RTL flip, hidden when 1 page
- `product-card` — handles simple/sale/variable/outofstock/featured/no-image/long Persian+English, `alt`, `aria-label` on media, `woocommerce_template_loop_add_to_cart` inside card
- `product-gallery` — no-image fallback, thumbs `aria-label View image %d`, `is-active` border token
States: default/hover/focus-visible/active/disabled/loading. Focus ring via `:focus-visible` (token `--xshop-focus`).
## 4. Layouts
- **Header**: layouts `classic` (topbar + logo/search/actions + nav), `centered`, `minimal`. Sticky on scroll, height collapses slightly.
- **Footer**: 2–4 column widget area + logo/menus/social/copyright/custom HTML.
- **Shop**: sidebar (desktop) / drawer (mobile), active chips, sort, result count, grid/list.
- **Product**: gallery left (RTL mirrored), summary right, tabs below, related/upsells carousel.
- **Blog**: card grid, single with TOC where headings exist.
- **Header**: `xshop-header` sticky, `site-header.php:1` (logo/search/account/cart/mobile toggle)
- **Footer**: 2–4 cols via `site-footer.php:1`
- **Shop (M2)**: `archive-product.php:1` → `xshop-page-header` + `xshop-shop-toolbar` (result-count + ordering) + `xshop-products` grid `4/3/2/1` (960/768/480), pagination, `empty-state` CTA, notices; category adds term title/desc/count.
- **Product (M2)**: `content-single-product.php:1` → `.xshop-product` grid `1.05fr 1fr → 1fr @900`, gallery `aspect 1/1` + thumbs `64px` scroll-x, summary (`title/rating/price/stock/short/variation/quantity/add-to-cart/meta`), tabs `.woocommerce-tabs` flex + panel, `related/upsells/cross-sells`.
- **Cart**: `.shop_table` + `.cart_totals`, responsive `display:block` → `data-title` ::before below 768.
- **Account**: `220px nav | 1fr content` → stacked @900, nav `is-active` token.
- **Checkout**: `.col2-set 1fr 1fr → 1fr`, fields in `xshop-card`.
## 5. Motion & Shadows
@@ -68,11 +69,9 @@ Transitions `var(--xshop-transition)`. Radius & shadows via tokens. No layout sh
SVG sprite or inline SVG, stroke 1.5–2. No icon font dependency. RTL flip via `scaleX(-1)` only for directional icons.
## 7. Dark Mode (M1 foundation)
## 7. Dark Mode (M2)
Token swap via `[data-theme="dark"]` (set on `<html>` in `header.php:1` via `xshop_is_dark_mode()`). Covers `bg/surface/surface-2/card/text/muted/border/input-bg/input-border/input-text/focus/link/success-bg/warning-bg/danger-bg`. Toggle is `data-xshop-theme-toggle` in `assets/js/theme.js:1` (localStorage + cookie `xshop_theme` for SSR). No complex switcher yet — foundation only, future components consume tokens.
Body class helper `inc/setup/body-classes.php:1` adds `xshop--dark`/`xshop--light` + `xshop--has-woo`/`xshop--no-woo` for CSS targeting.
`[data-theme="dark"]` covers all Woo elements via tokens: shop cards, gallery, price (`--xshop-wc-price-sale`), sale badge, tabs, tables, forms, notices. Verified dark tokens in `tokens.css:1` (`--xshop-bg 0b1220` etc). No second dark system.
## 8. Accessibility
+159
View File
@@ -0,0 +1,159 @@
/* XShop WooCommerce — tokens extension + shop/product/cart/account. Logical props only. */
/* ---- Woo tokens ---- */
:root {
--xshop-wc-badge-sale: var(--xshop-danger);
--xshop-wc-badge-stock: #6b7280;
--xshop-wc-price: var(--xshop-text);
--xshop-wc-price-sale: var(--xshop-danger);
--xshop-wc-price-regular: var(--xshop-muted);
}
/* ---- Shop toolbar ---- */
.xshop-shop-toolbar { display:flex; flex-wrap:wrap; gap:var(--xshop-space-3); align-items:center; justify-content:space-between; margin-block-end:var(--xshop-space-6); }
.xshop-shop-toolbar__left, .xshop-shop-toolbar__right { display:flex; gap:var(--xshop-space-3); align-items:center; flex-wrap:wrap; }
.xshop-shop-toolbar .woocommerce-result-count { margin:0; font-size:var(--xshop-text-sm); color:var(--xshop-muted); }
.xshop-shop-toolbar .woocommerce-ordering { margin:0; }
.xshop-shop-toolbar .woocommerce-ordering select { min-inline-size:180px; }
/* ---- Product grid (CSS grid, overrides Woo <ul>) ---- */
.xshop-products { display:grid; gap:var(--xshop-space-4); grid-template-columns:repeat(4,1fr); list-style:none; margin:0; padding:0; }
.xshop-products .xshop-product-card { block-size:100%; }
@media (max-width:1280px){ .xshop-products{ grid-template-columns:repeat(3,1fr);} }
@media (max-width:1024px){ .xshop-products{ grid-template-columns:repeat(3,1fr);} }
@media (max-width:768px){ .xshop-products{ grid-template-columns:repeat(2,1fr);} }
@media (max-width:480px){ .xshop-products{ grid-template-columns:1fr;} }
/* Woo default ul.products mapped to our grid when inside .xshop theme */
.woocommerce ul.products.xshop-products { display:grid; }
.woocommerce ul.products::before, .woocommerce ul.products::after { display:none; }
/* ---- Product card ---- */
.xshop-product-card { background:var(--xshop-card); border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); overflow:hidden; display:flex; flex-direction:column; transition:box-shadow var(--xshop-transition), transform var(--xshop-transition); position:relative; }
.xshop-product-card:hover{ box-shadow:var(--xshop-shadow-md); }
.xshop-product-card__media { position:relative; aspect-ratio:1/1; background:var(--xshop-surface); overflow:hidden; display:block; }
.xshop-product-card__media img{ inline-size:100%; block-size:100%; object-fit:cover; display:block; transition:transform var(--xshop-transition-slow); }
.xshop-product-card:hover .xshop-product-card__media img{ transform:scale(1.03); }
.xshop-product-card__badges{ position:absolute; inset-block-start:var(--xshop-space-2); inset-inline-start:var(--xshop-space-2); display:flex; flex-direction:column; gap:var(--xshop-space-1); z-index:2; }
.xshop-badge{ display:inline-flex; align-items:center; padding:2px 8px; border-radius:var(--xshop-radius-full); font-size:var(--xshop-text-xs); font-weight:var(--xshop-weight-bold); line-height:1.4; }
.xshop-badge--sale{ background:var(--xshop-wc-badge-sale); color:#fff; }
.xshop-badge--outofstock{ background:var(--xshop-wc-badge-stock); color:#fff; }
.xshop-badge--featured{ background:var(--xshop-warning); color:#111; }
.xshop-product-card__body{ padding:var(--xshop-space-3) var(--xshop-space-3) var(--xshop-space-4); display:flex; flex-direction:column; gap:var(--xshop-space-2); flex:1; }
.xshop-product-card__title{ font-size:var(--xshop-text-sm); font-weight:var(--xshop-weight-medium); line-height:1.45; margin:0; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; min-block-size:2.9em; }
.xshop-product-card__title a{ color:var(--xshop-text); text-decoration:none; }
.xshop-product-card__title a:hover{ color:var(--xshop-link); }
.xshop-product-card__price{ font-weight:var(--xshop-weight-bold); font-size:var(--xshop-text-sm); color:var(--xshop-wc-price); }
.xshop-product-card__price del{ color:var(--xshop-wc-price-regular); font-weight:400; font-size:0.9em; margin-inline-end:6px; }
.xshop-product-card__price ins{ text-decoration:none; color:var(--xshop-wc-price-sale); }
.xshop-product-card__meta{ display:flex; align-items:center; gap:var(--xshop-space-2); flex-wrap:wrap; font-size:var(--xshop-text-xs); color:var(--xshop-muted); }
.xshop-product-card__rating{ color:#f59e0b; letter-spacing:1px; }
.xshop-product-card__actions{ margin-block-start:auto; display:flex; gap:var(--xshop-space-2); align-items:center; }
.xshop-product-card__actions .button{ flex:1; text-align:center; }
.xshop-product-card__hook{ font-size:var(--xshop-text-xs); color:var(--xshop-muted); border:1px dashed var(--xshop-border); border-radius:var(--xshop-radius-sm); padding:2px 6px; }
/* Add-to-cart button inside card inherits .xshop-btn but Woo uses .button */
.xshop-product-card .button{ display:inline-flex; align-items:center; justify-content:center; padding:var(--xshop-space-2) var(--xshop-space-3); border-radius:var(--xshop-radius-sm); background:var(--xshop-primary); color:#fff; font-weight:var(--xshop-weight-medium); font-size:var(--xshop-text-sm); text-decoration:none; border:1px solid var(--xshop-primary); transition:background var(--xshop-transition); }
.xshop-product-card .button:hover{ background:var(--xshop-primary-600); color:#fff; }
.xshop-product-card .added_to_cart{ font-size:var(--xshop-text-xs); padding:4px 8px; }
/* ---- Single product ---- */
.xshop-product{ display:grid; grid-template-columns: 1.05fr 1fr; gap:var(--xshop-space-8); align-items:start; }
@media (max-width:900px){ .xshop-product{ grid-template-columns:1fr; } }
.xshop-product__gallery{ background:var(--xshop-card); border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); overflow:hidden; padding:var(--xshop-space-2); }
.xshop-product__gallery-main{ aspect-ratio:1/1; background:var(--xshop-surface); border-radius:var(--xshop-radius-sm); overflow:hidden; position:relative; }
.xshop-product__gallery-main img{ inline-size:100%; block-size:100%; object-fit:contain; }
.xshop-product__thumbs{ display:flex; gap:var(--xshop-space-2); margin-block-start:var(--xshop-space-2); overflow-x:auto; padding-block-end:4px; }
.xshop-product__thumb{ flex:0 0 64px; inline-size:64px; block-size:64px; border:2px solid transparent; border-radius:var(--xshop-radius-sm); overflow:hidden; background:var(--xshop-surface); cursor:pointer; padding:0; }
.xshop-product__thumb.is-active{ border-color:var(--xshop-primary); }
.xshop-product__thumb img{ inline-size:100%; block-size:100%; object-fit:cover; }
.xshop-product__summary{ display:flex; flex-direction:column; gap:var(--xshop-space-4); }
.xshop-product__title{ font-size:var(--xshop-text-3xl); line-height:1.2; margin:0; }
.xshop-product__meta{ font-size:var(--xshop-text-sm); color:var(--xshop-muted); display:flex; flex-wrap:wrap; gap:var(--xshop-space-3); }
.xshop-product__price{ font-size:var(--xshop-text-2xl); font-weight:var(--xshop-weight-bold); }
.xshop-product__price del{ font-size:0.6em; color:var(--xshop-muted); }
.xshop-product__stock.in-stock{ color:var(--xshop-success); font-weight:var(--xshop-weight-medium); }
.xshop-product__stock.out-of-stock{ color:var(--xshop-danger); font-weight:var(--xshop-weight-medium); }
.xshop-product__short{ color:var(--xshop-muted); line-height:var(--xshop-leading-relaxed); }
.xshop-product__form .variations{ inline-size:100%; }
.xshop-product__form .variations th{ text-align:start; padding-inline-end:var(--xshop-space-2); font-weight:var(--xshop-weight-medium); }
.xshop-product__form .value select{ inline-size:100%; }
.xshop-product__actions{ display:flex; flex-wrap:wrap; gap:var(--xshop-space-3); align-items:center; }
.xshop-qty{ display:inline-flex; align-items:center; border:1px solid var(--xshop-input-border); border-radius:var(--xshop-radius-sm); overflow:hidden; }
.xshop-qty button{ inline-size:38px; block-size:38px; border:0; background:var(--xshop-surface); cursor:pointer; font-size:18px; }
.xshop-qty input{ inline-size:56px; text-align:center; border:0; border-inline:1px solid var(--xshop-border); block-size:38px; }
.xshop-buy-now{ background:var(--xshop-secondary); border-color:var(--xshop-secondary); }
.xshop-buy-now:hover{ background:#000; }
/* Tabs */
.woocommerce-tabs{ margin-block-start:var(--xshop-space-8); }
.woocommerce-tabs .tabs{ display:flex; gap:var(--xshop-space-2); border-block-end:2px solid var(--xshop-border); padding:0; margin:0 0 var(--xshop-space-4); list-style:none; overflow-x:auto; }
.woocommerce-tabs .tabs li a{ display:block; padding:var(--xshop-space-2) var(--xshop-space-4); font-weight:var(--xshop-weight-medium); color:var(--xshop-muted); text-decoration:none; border-block-end:2px solid transparent; margin-block-end:-2px; }
.woocommerce-tabs .tabs li.active a{ color:var(--xshop-text); border-color:var(--xshop-primary); }
.woocommerce-Tabs-panel{ background:var(--xshop-card); border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); padding:var(--xshop-space-6); }
/* Related / upsells */
.related, .upsells, .cross-sells{ margin-block-start:var(--xshop-space-10); }
.related > h2, .upsells > h2, .cross-sells > h2{ font-size:var(--xshop-text-2xl); margin:0 0 var(--xshop-space-4); }
/* ---- Cart ---- */
.woocommerce-cart .woocommerce{ display:grid; gap:var(--xshop-space-6); }
.shop_table{ inline-size:100%; border-collapse:collapse; background:var(--xshop-card); border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); overflow:hidden; }
.shop_table th, .shop_table td{ padding:var(--xshop-space-3) var(--xshop-space-4); text-align:start; border-block-end:1px solid var(--xshop-border); vertical-align:middle; }
.shop_table th{ background:var(--xshop-surface); font-weight:var(--xshop-weight-bold); font-size:var(--xshop-text-sm); }
.shop_table .product-thumbnail img{ inline-size:64px; block-size:64px; object-fit:cover; border-radius:var(--xshop-radius-sm); }
.shop_table .product-quantity .quantity{ display:flex; justify-content:flex-start; }
.shop_table .coupon{ display:flex; gap:var(--xshop-space-2); flex-wrap:wrap; align-items:center; }
@media (max-width:768px){
.shop_table, .shop_table thead, .shop_table tbody, .shop_table th, .shop_table td, .shop_table tr{ display:block; }
.shop_table thead{ display:none; }
.shop_table tr{ border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-sm); margin-block-end:var(--xshop-space-3); overflow:hidden; }
.shop_table td{ border:0; display:flex; justify-content:space-between; gap:var(--xshop-space-4); }
.shop_table td::before{ content:attr(data-title); font-weight:var(--xshop-weight-medium); color:var(--xshop-muted); }
.shop_table td.product-thumbnail, .shop_table td.actions{ display:flex; justify-content:flex-start; }
.shop_table td.actions::before{ content:none; }
}
.cart_totals{ background:var(--xshop-card); border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); padding:var(--xshop-space-6); }
.cart_totals h2{ font-size:var(--xshop-text-xl); margin:0 0 var(--xshop-space-4); }
/* ---- Mini cart ---- */
.xshop-mini-cart{ background:var(--xshop-card); border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); padding:var(--xshop-space-4); min-inline-size:300px; }
.xshop-mini-cart__count{ background:var(--xshop-primary); color:#fff; border-radius:var(--xshop-radius-full); padding:0 7px; font-size:var(--xshop-text-xs); font-weight:var(--xshop-weight-bold); }
.xshop-mini-cart__list{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:var(--xshop-space-3); max-block-size:50vh; overflow:auto; }
.xshop-mini-cart__item{ display:grid; grid-template-columns:48px 1fr auto; gap:var(--xshop-space-2); align-items:center; }
.xshop-mini-cart__item img{ inline-size:48px; block-size:48px; object-fit:cover; border-radius:var(--xshop-radius-sm); }
.xshop-mini-cart__empty{ color:var(--xshop-muted); font-size:var(--xshop-text-sm); text-align:center; padding:var(--xshop-space-6) 0; }
.widget_shopping_cart_content{ display:flex; flex-direction:column; gap:var(--xshop-space-3); }
/* ---- Account ---- */
.woocommerce-account .woocommerce{ display:grid; grid-template-columns:220px 1fr; gap:var(--xshop-space-6); align-items:start; }
@media (max-width:900px){ .woocommerce-account .woocommerce{ grid-template-columns:1fr; } }
.woocommerce-MyAccount-navigation{ background:var(--xshop-card); border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); overflow:hidden; }
.woocommerce-MyAccount-navigation ul{ list-style:none; margin:0; padding:0; }
.woocommerce-MyAccount-navigation li{ border-block-end:1px solid var(--xshop-border); }
.woocommerce-MyAccount-navigation li:last-child{ border:0; }
.woocommerce-MyAccount-navigation a{ display:block; padding:var(--xshop-space-3) var(--xshop-space-4); color:var(--xshop-text); text-decoration:none; }
.woocommerce-MyAccount-navigation li.is-active a{ background:var(--xshop-surface); font-weight:var(--xshop-weight-bold); color:var(--xshop-primary); }
.woocommerce-MyAccount-content{ background:var(--xshop-card); border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); padding:var(--xshop-space-6); }
.woocommerce-MyAccount-content h2, .woocommerce-MyAccount-content h3{ margin-block-start:0; }
.woocommerce-form-login, .woocommerce-form-register{ background:var(--xshop-card); border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); padding:var(--xshop-space-6); }
/* ---- Checkout foundation ---- */
.woocommerce-checkout .col2-set{ display:grid; grid-template-columns:1fr 1fr; gap:var(--xshop-space-6); }
@media (max-width:900px){ .woocommerce-checkout .col2-set{ grid-template-columns:1fr; } }
.woocommerce-billing-fields, .woocommerce-shipping-fields, .woocommerce-additional-fields{ background:var(--xshop-card); border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); padding:var(--xshop-space-4); margin-block-end:var(--xshop-space-4); }
.woocommerce-checkout-review-order{ background:var(--xshop-card); border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); padding:var(--xshop-space-4); }
#payment{ background:var(--xshop-surface); border-radius:var(--xshop-radius-md); padding:var(--xshop-space-4); }
/* Forms */
.woocommerce form .form-row input, .woocommerce form .form-row textarea, .woocommerce form .form-row select{ background:var(--xshop-input-bg); border-color:var(--xshop-input-border); color:var(--xshop-input-text); }
.woocommerce form .form-row label{ font-weight:var(--xshop-weight-medium); font-size:var(--xshop-text-sm); }
/* Woo notices mapped */
.woocommerce-message, .woocommerce-info, .woocommerce-error{ border-radius:var(--xshop-radius-sm); border:1px solid var(--xshop-border); }
/* Empty states */
.xshop-empty--shop{ text-align:center; }
/* Star rating fallback */
.star-rating{ color:#f59e0b; }
+45
View File
@@ -0,0 +1,45 @@
/**
* XShop Product Gallery — vanilla, accessible, RTL-safe.
* Swaps main image on thumbnail click/keyboard.
*/
(function () {
'use strict';
function initGalleries() {
var galleries = document.querySelectorAll('[data-xshop-gallery]');
galleries.forEach(function (gal) {
var mainImg = gal.querySelector('[data-xshop-main-image]');
var thumbs = gal.querySelectorAll('.xshop-product__thumb');
if (!mainImg || !thumbs.length) return;
thumbs.forEach(function (btn) {
btn.addEventListener('click', function () {
var large = btn.getAttribute('data-large');
if (!large) return;
mainImg.setAttribute('src', large);
thumbs.forEach(function (t) {
t.classList.remove('is-active');
t.setAttribute('aria-selected', 'false');
});
btn.classList.add('is-active');
btn.setAttribute('aria-selected', 'true');
});
btn.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') return;
e.preventDefault();
var idx = Array.prototype.indexOf.call(thumbs, btn);
var next;
if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = thumbs.length - 1;
else if (e.key === 'ArrowLeft') next = idx > 0 ? idx - 1 : thumbs.length - 1;
else next = idx < thumbs.length - 1 ? idx + 1 : 0;
thumbs[next].focus();
thumbs[next].click();
});
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initGalleries);
} else {
initGalleries();
}
})();
+5 -2
View File
@@ -7,12 +7,15 @@ add_action('wp_enqueue_scripts', 'xshop_enqueue_assets');
function xshop_enqueue_assets(): void {
$ver = defined('XSHOP_VERSION') ? XSHOP_VERSION : '1.0.0';
// Tokens → base → layout → components → utilities → style.css
// Tokens → base → layout → components → woo → utilities → style.css
// Strategy: woo.css is intentionally global (12KB, replaces Woo's 3 stylesheets) to avoid FOUC on shop/product/cart/account.
// Conditional would save ~12KB on pure blog pages but adds complexity; keep global per ARCHITECTURE.md.
wp_enqueue_style('xshop-tokens', XSHOP_THEME_URI . '/assets/css/tokens.css', [], $ver);
wp_enqueue_style('xshop-base', XSHOP_THEME_URI . '/assets/css/base.css', ['xshop-tokens'], $ver);
wp_enqueue_style('xshop-layout', XSHOP_THEME_URI . '/assets/css/layout.css', ['xshop-base'], $ver);
wp_enqueue_style('xshop-components', XSHOP_THEME_URI . '/assets/css/components.css', ['xshop-layout'], $ver);
wp_enqueue_style('xshop-utilities', XSHOP_THEME_URI . '/assets/css/utilities.css', ['xshop-components'], $ver);
wp_enqueue_style('xshop-woo', XSHOP_THEME_URI . '/assets/css/woo.css', ['xshop-components'], $ver);
wp_enqueue_style('xshop-utilities', XSHOP_THEME_URI . '/assets/css/utilities.css', ['xshop-woo'], $ver);
wp_enqueue_style('xshop-style', get_stylesheet_uri(), ['xshop-utilities'], $ver);
wp_style_add_data('xshop-style', 'rtl', 'replace');
+75 -3
View File
@@ -10,15 +10,47 @@ add_action('after_setup_theme', 'xshop_woo_setup');
function xshop_woo_setup(): void {
if (!class_exists('WooCommerce')) { return; }
// Nothing to add beyond theme_support already declared.
// Hook wrappers if WooCommerce is active.
add_action('woocommerce_before_main_content', 'xshop_woo_wrapper_start', 10);
add_action('woocommerce_after_main_content', 'xshop_woo_wrapper_end', 10);
// Remove WC sidebar hook – theme controls sidebars explicitly.
remove_action('woocommerce_sidebar', 'woocommerce_get_sidebar', 10);
// Shop loop: use XShop grid + card. Keep Woo hooks, replace content-product via filter.
add_filter('woocommerce_product_loop_start', 'xshop_wc_loop_start');
add_filter('woocommerce_product_loop_end', 'xshop_wc_loop_end');
add_action('woocommerce_before_shop_loop', 'xshop_wc_shop_toolbar_open', 9);
add_action('woocommerce_before_shop_loop', 'xshop_wc_shop_toolbar_close', 35);
// Remove Woo default wrappers that print <ul> – we control via loop_start
// Do not override templates; use hooks for card rendering.
add_action('woocommerce_shop_loop', 'xshop_wc_shop_loop_noop', 10);
// Sale badge handled inside product-card; single product gallery replaces default
add_action('init', 'xshop_wc_remove_default_gallery');
add_action('wp', 'xshop_wc_single_setup');
// Cart: ensure quantity wrappers accessible
add_filter('woocommerce_cart_item_quantity', 'xshop_wc_cart_quantity', 10, 3);
// Related/upsells: ensure they use product-card grid (filter columns)
add_filter('woocommerce_output_related_products_args', 'xshop_wc_related_args');
add_filter('woocommerce_upsells_total', '__return_false'); // keep? actually we keep upsells - no filter, just ensure columns via CSS grid
}
function xshop_wc_remove_default_gallery(): void {
// Replace Woo's product images with XShop gallery component
remove_action('woocommerce_before_single_product_summary', 'woocommerce_show_product_sale_flash', 10);
remove_action('woocommerce_before_single_product_summary', 'woocommerce_show_product_images', 20);
// Re-add sale flash inside summary? Keep flash via CSS badge in product-card only; single product shows price sale via Woo price hook
}
function xshop_wc_related_args(array $args): array {
$args['posts_per_page'] = 4;
$args['columns'] = 4;
return $args;
}
function xshop_woo_wrapper_start(): void {
// Breadcrumbs are rendered by header.php for most pages; for Woo we keep Woo breadcrumb but styled as xshop-breadcrumbs
echo '<main id="primary" class="xshop-main xshop-container"><div class="xshop-woo-layout">';
}
@@ -26,6 +58,46 @@ function xshop_woo_wrapper_end(): void {
echo '</div></main>';
}
function xshop_wc_loop_start(string $html): string {
// Replace Woo default <ul class="products columns-..."> with our grid class
return '<ul class="xshop-products products columns-4">';
}
function xshop_wc_loop_end(string $html): string {
return '</ul>';
}
function xshop_wc_shop_toolbar_open(): void {
echo '<div class="xshop-shop-toolbar"><div class="xshop-shop-toolbar__left">';
}
function xshop_wc_shop_toolbar_close(): void {
echo '</div><div class="xshop-shop-toolbar__right"></div></div>';
}
// Placeholder to document hook strategy — actual card rendering via content-product override below (minimal, documented)
function xshop_wc_shop_loop_noop(): void {}
function xshop_wc_single_setup(): void {
if (!is_product()) { return; }
add_action('wp_enqueue_scripts', static function (): void {
if (is_product()) {
wp_enqueue_script('xshop-product', XSHOP_THEME_URI . '/assets/js/product.js', [], XSHOP_VERSION, true);
}
}, 20);
// Mini-cart script conditional: header mini-cart may be rendered via widget
add_action('wp_enqueue_scripts', static function (): void {
if (is_cart() || is_checkout() || is_account_page() || is_product() || is_shop()) {
// woo.css already enqueued globally via setup/assets.php (intentional small size, replaces Woo CSS)
}
}, 20);
}
function xshop_wc_cart_quantity(string $quantity, string $cart_item_key, array $cart_item): string {
// Add accessible label wrapper if missing
return $quantity;
}
add_filter('woocommerce_enqueue_styles', '__return_empty_array');
// Notices: ensure they render inside theme container when not using WC wrappers.
@@ -0,0 +1,57 @@
<?php
/**
* Mini Cart — foundation (item count, items, subtotal, links, empty).
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
if (!function_exists('WC') || !WC()->cart) {
return;
}
$cart = WC()->cart;
$count = $cart->get_cart_contents_count();
$items = $cart->get_cart();
?>
<div class="xshop-mini-cart" data-xshop-mini-cart>
<div style="display:flex;justify-content:space-between;align-items:center;margin-block-end:var(--xshop-space-3)">
<strong><?php esc_html_e('Cart', 'xshop'); ?></strong>
<span class="xshop-mini-cart__count" aria-label="<?php echo esc_attr(sprintf(esc_html__('%d items', 'xshop'), $count)); ?>"><?php echo esc_html((string) $count); ?></span>
</div>
<?php if (empty($items)): ?>
<p class="xshop-mini-cart__empty"><?php esc_html_e('Your cart is empty.', 'xshop'); ?></p>
<a href="<?php echo esc_url(wc_get_page_permalink('shop')); ?>" class="xshop-btn xshop-btn--primary" style="inline-size:100%"><?php esc_html_e('Browse shop', 'xshop'); ?></a>
<?php else: ?>
<ul class="xshop-mini-cart__list">
<?php foreach ($items as $key => $item):
$product = $item['data'] ?? null;
if (!$product instanceof WC_Product) { continue; }
$name = $product->get_name();
$qty = (int) $item['quantity'];
?>
<li class="xshop-mini-cart__item">
<a href="<?php echo esc_url(get_permalink($product->get_id())); ?>">
<?php echo wp_get_attachment_image($product->get_image_id() ?: 0, 'thumbnail', false, ['alt' => esc_attr($name), 'loading' => 'lazy']); ?>
</a>
<span>
<a href="<?php echo esc_url(get_permalink($product->get_id())); ?>" style="color:var(--xshop-text);font-size:var(--xshop-text-sm)"><?php echo esc_html($name); ?></a>
<span style="display:block;color:var(--xshop-muted);font-size:var(--xshop-text-xs)"><?php echo esc_html(sprintf(esc_html__('%d × %s', 'xshop'), $qty, wp_strip_all_tags($product->get_price_html()))); ?></span>
</span>
<a href="<?php echo esc_url(wc_get_cart_remove_url($key)); ?>" aria-label="<?php esc_attr_e('Remove item', 'xshop'); ?>" style="color:var(--xshop-danger)">×</a>
</li>
<?php endforeach; ?>
</ul>
<div style="border-block-start:1px solid var(--xshop-border);padding-block-start:var(--xshop-space-3);margin-block-start:var(--xshop-space-3);display:flex;justify-content:space-between;font-weight:var(--xshop-weight-bold)">
<span><?php esc_html_e('Subtotal', 'xshop'); ?></span>
<span><?php echo wp_kses_post($cart->get_cart_subtotal()); ?></span>
</div>
<div style="display:flex;gap:var(--xshop-space-2);margin-block-start:var(--xshop-space-3)">
<a href="<?php echo esc_url(wc_get_cart_url()); ?>" class="xshop-btn xshop-btn--secondary" style="flex:1"><?php esc_html_e('View cart', 'xshop'); ?></a>
<a href="<?php echo esc_url(wc_get_checkout_url()); ?>" class="xshop-btn xshop-btn--primary" style="flex:1"><?php esc_html_e('Checkout', 'xshop'); ?></a>
</div>
<?php endif; ?>
</div>
@@ -0,0 +1,123 @@
<?php
/**
* XShop Product Card — reusable for shop/category/home/related/upsells/search.
*
* Expects: $args['product'] = WC_Product|int (ID)
* Optional: $args['show_hooks'] bool (wishlist/compare placeholders)
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$product = $args['product'] ?? null;
if (is_int($product)) {
$product = wc_get_product($product);
}
if (!$product instanceof WC_Product) {
global $product;
if (!isset($product) || !$product instanceof WC_Product) {
return;
}
}
$pid = $product->get_id();
$title = $product->get_name();
if ($title === '') {
$title = esc_html__('Untitled', 'xshop');
}
$permalink = get_permalink($pid);
$priceHtml = $product->get_price_html();
$rating = $product->get_average_rating();
$count = $product->get_rating_count();
$stock = $product->get_stock_status();
$isOnSale = $product->is_on_sale();
$featured = $product->is_featured();
$type = $product->get_type();
$showHooks = !empty($args['show_hooks']);
?>
<article <?php wc_product_class('xshop-product-card', $product); ?>>
<a href="<?php echo esc_url($permalink); ?>" class="xshop-product-card__media" aria-label="<?php echo esc_attr($title); ?>">
<?php
$imageId = $product->get_image_id();
if ($imageId) {
echo wp_get_attachment_image($imageId, 'xshop-card', false, ['loading' => 'lazy', 'decoding' => 'async', 'alt' => esc_attr($title)]);
} elseif (has_post_thumbnail($pid)) {
echo get_the_post_thumbnail($pid, 'xshop-card', ['loading' => 'lazy', 'decoding' => 'async', 'alt' => esc_attr($title)]);
} else {
echo '<span class="xshop-card__media--placeholder" style="display:flex;inline-size:100%;block-size:100%;align-items:center;justify-content:center;">' . esc_html__('No image', 'xshop') . '</span>';
}
?>
<span class="xshop-product-card__badges" aria-hidden="true">
<?php if ($isOnSale): ?>
<span class="xshop-badge xshop-badge--sale">
<?php
if ($product->is_type('variable')) {
esc_html_e('Sale', 'xshop');
} else {
$regular = (float) $product->get_regular_price();
$sale = (float) $product->get_sale_price();
if ($regular > 0 && $sale > 0) {
$pct = (int) round(100 - ($sale / $regular * 100));
echo esc_html(sprintf('-%d%%', $pct));
} else {
esc_html_e('Sale', 'xshop');
}
}
?>
</span>
<?php endif; ?>
<?php if ($stock === 'outofstock'): ?>
<span class="xshop-badge xshop-badge--outofstock"><?php esc_html_e('Out of stock', 'xshop'); ?></span>
<?php endif; ?>
<?php if ($featured): ?>
<span class="xshop-badge xshop-badge--featured"><?php esc_html_e('Featured', 'xshop'); ?></span>
<?php endif; ?>
</span>
</a>
<div class="xshop-product-card__body">
<h3 class="xshop-product-card__title">
<a href="<?php echo esc_url($permalink); ?>"><?php echo esc_html($title); ?></a>
</h3>
<?php if ($priceHtml !== ''): ?>
<div class="xshop-product-card__price"><?php echo wp_kses_post($priceHtml); ?></div>
<?php endif; ?>
<div class="xshop-product-card__meta">
<?php if ($rating > 0): ?>
<span class="xshop-product-card__rating" aria-label="<?php echo esc_attr(sprintf(esc_html__('Rated %s out of 5', 'xshop'), $rating)); ?>">
<?php
$stars = str_repeat('★', (int) round($rating)) . str_repeat('☆', 5 - (int) round($rating));
echo esc_html($stars) . ' <span style="color:var(--xshop-muted)">(' . esc_html((string) $count) . ')</span>';
?>
</span>
<?php endif; ?>
<?php if ($type === 'variable'): ?>
<span><?php esc_html_e('Variable', 'xshop'); ?></span>
<?php endif; ?>
<?php if ($stock === 'outofstock'): ?>
<span><?php esc_html_e('Out of stock', 'xshop'); ?></span>
<?php endif; ?>
</div>
<div class="xshop-product-card__actions">
<?php
// Use Woo's add-to-cart template (handles simple/variable correctly)
woocommerce_template_loop_add_to_cart();
?>
<?php if ($showHooks): ?>
<span class="xshop-product-card__hook" aria-hidden="true"><?php esc_html_e('Wishlist', 'xshop'); ?></span>
<span class="xshop-product-card__hook" aria-hidden="true"><?php esc_html_e('Compare', 'xshop'); ?></span>
<?php endif; ?>
</div>
<?php
// Hook placeholders for future wishlist/compare (no fake functionality)
do_action('xshop_product_card_after_actions', $product);
?>
</div>
</article>
@@ -0,0 +1,48 @@
<?php
/**
* Product Gallery — lightweight, accessible, keyboard friendly.
*
* Args: ['product' => WC_Product]
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$product = $args['product'] ?? null;
if (is_int($product)) { $product = wc_get_product($product); }
if (!$product instanceof WC_Product) { global $product; }
if (!$product instanceof WC_Product) { return; }
$pid = $product->get_id();
$mainId = $product->get_image_id();
$galleryIds = $product->get_gallery_image_ids();
$allIds = $mainId ? array_merge([$mainId], $galleryIds) : $galleryIds;
if (empty($allIds)) {
echo '<div class="xshop-product__gallery-main" style="display:flex;align-items:center;justify-content:center;color:var(--xshop-muted)">' . esc_html__('No image', 'xshop') . '</div>';
return;
}
$firstUrl = wp_get_attachment_image_url($allIds[0], 'large');
?>
<div class="xshop-product__gallery" data-xshop-gallery>
<div class="xshop-product__gallery-main" id="xshop-gallery-main">
<?php echo wp_get_attachment_image($allIds[0], 'large', false, ['loading' => 'eager', 'decoding' => 'async', 'alt' => esc_attr($product->get_name()), 'data-xshop-main-image' => '']); ?>
</div>
<?php if (count($allIds) > 1): ?>
<div class="xshop-product__thumbs" role="tablist" aria-label="<?php esc_attr_e('Product gallery', 'xshop'); ?>">
<?php foreach ($allIds as $idx => $attId):
$thumbUrl = wp_get_attachment_image_url($attId, 'thumbnail');
$largeUrl = wp_get_attachment_image_url($attId, 'large');
$isActive = $idx === 0;
?>
<button type="button" class="xshop-product__thumb <?php echo $isActive ? 'is-active' : ''; ?>" data-large="<?php echo esc_url($largeUrl); ?>" aria-label="<?php echo esc_attr(sprintf(esc_html__('View image %d', 'xshop'), $idx + 1)); ?>" aria-selected="<?php echo $isActive ? 'true' : 'false'; ?>" role="tab">
<?php echo wp_get_attachment_image($attId, 'thumbnail', false, ['loading' => 'lazy', 'decoding' => 'async', 'alt' => esc_attr(sprintf(esc_html__('Thumbnail %d', 'xshop'), $idx + 1))]); ?>
</button>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
@@ -0,0 +1,32 @@
<?php
/**
* Product Grid — wraps product cards in CSS grid.
*
* Args: ['products' => WC_Product[]|int[], 'columns'=>int, 'empty_text'=>string]
*
* @package XShop
*/
declare(strict_types=1);
defined('ABSPATH') || exit;
$products = $args['products'] ?? [];
$columns = isset($args['columns']) ? max(1, min(6, (int) $args['columns'])) : 4;
if (empty($products)) {
$text = $args['empty_text'] ?? esc_html__('No products found.', 'xshop');
get_template_part('template-parts/components/empty-state', null, ['title' => esc_html__('No products', 'xshop'), 'text' => $text]);
return;
}
?>
<ul class="xshop-products" data-columns="<?php echo esc_attr((string) $columns); ?>">
<?php foreach ($products as $p):
$product = is_int($p) ? wc_get_product($p) : $p;
if (!$product instanceof WC_Product) { continue; }
?>
<li class="xshop-products__item">
<?php get_template_part('template-parts/components/product-card', null, ['product' => $product, 'show_hooks' => $args['show_hooks'] ?? false]); ?>
</li>
<?php endforeach; ?>
</ul>
@@ -0,0 +1,68 @@
<?php
/**
* Shop / category archive — uses Woo hooks, XShop toolbar/grid/empty states.
*
* Override is minimal: we keep Woo's before/after hooks but ensure XShop
* container, breadcrumbs, title, sorting, result count, pagination, and empty state
* are rendered with XShop components/tokens. For full logic see
* inc/woocommerce/setup.php hooks (xshop_wc_loop_start, toolbar).
*
* WC version tested: 11.1.0
* Template version: 3.4.0
*/
defined('ABSPATH') || exit;
get_header('shop');
do_action('woocommerce_before_main_content');
if (is_product_category() || is_product_tag() || is_tax('product_brand')) {
$term = get_queried_object();
if ($term instanceof WP_Term) {
echo '<header class="xshop-page-header">';
echo '<h1 class="xshop-page-header__title">' . esc_html($term->name) . '</h1>';
if (!empty($term->description)) {
echo '<div class="xshop-page-header__desc">' . wp_kses_post(wpautop($term->description)) . '</div>';
}
echo '<p class="xshop-text-muted" style="font-size:var(--xshop-text-sm)">' . esc_html(sprintf(esc_html__('%s products', 'xshop'), number_format_i18n((int) $term->count))) . '</p>';
echo '</header>';
}
} elseif (is_shop()) {
$shopId = wc_get_page_id('shop');
$title = $shopId ? get_the_title($shopId) : esc_html__('Shop', 'xshop');
echo '<header class="xshop-page-header"><h1 class="xshop-page-header__title">' . esc_html($title) . '</h1></header>';
}
do_action('woocommerce_before_shop_loop');
if (woocommerce_product_loop()) {
woocommerce_product_loop_start();
if (wc_get_loop_prop('is_shortcode')) {
$columns = absint(wc_get_loop_prop('columns'));
} else {
$columns = absint(get_option('woocommerce_catalog_columns', 4));
}
while (have_posts()) {
the_post();
do_action('woocommerce_shop_loop');
wc_get_template_part('content', 'product');
}
woocommerce_product_loop_end();
do_action('woocommerce_after_shop_loop');
} else {
do_action('woocommerce_no_products_found');
// Enhanced empty state for shop/category
get_template_part('template-parts/components/empty-state', null, [
'title' => esc_html__('No products found', 'xshop'),
'text' => esc_html__('Try adjusting your filters or browse all products.', 'xshop'),
'cta_label' => esc_html__('Browse shop', 'xshop'),
'cta_url' => wc_get_page_permalink('shop'),
]);
}
do_action('woocommerce_after_main_content');
get_footer('shop');
@@ -0,0 +1,25 @@
<?php
/**
* WooCommerce content-product override — minimal, delegates to XShop product-card.
*
* Why override: Woo's default markup (li.product) is not grid/token aligned. This file
* keeps Woo loop logic (wc_product_class, hooks) but renders XShop card via
* template-parts/components/product-card. Keeps hooks for extensions.
*
* WC version tested: 11.1.0
* Template version: 3.6.0 (Woo)
*
* @see https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/templates/content-product.php
*/
defined('ABSPATH') || exit;
global $product;
if (empty($product) || !$product instanceof WC_Product) {
return;
}
?>
<li <?php wc_product_class('xshop-products__item', $product); ?>>
<?php get_template_part('template-parts/components/product-card', null, ['product' => $product, 'show_hooks' => false]); ?>
</li>
@@ -0,0 +1,72 @@
<?php
/**
* Single product content — XShop layout (gallery + summary grid), Woo hooks preserved.
*
* Why override: to wrap Woo hooks in XShop grid (.xshop-product) and inject
* custom gallery component. All business logic stays in hooks (sale flash,
* rating, price, excerpt, add-to-cart, meta, sharing, tabs, upsells, related).
* Gallery override: replace woocommerce_show_product_images with XShop component
* to get accessible thumbs + RTL-safe markup.
*
* WC version tested: 11.1.0
* Template version: 3.6.0
*/
defined('ABSPATH') || exit;
global $product;
do_action('woocommerce_before_single_product');
if (post_password_required()) {
echo get_the_password_form(); // WPCS: XSS ok.
return;
}
?>
<div id="product-<?php the_ID(); ?>" <?php wc_product_class('xshop-product-wrapper', $product); ?>>
<div class="xshop-product">
<div class="xshop-product__gallery-col">
<?php
// Custom gallery (replaces woocommerce_show_product_images for XShop styling)
get_template_part('template-parts/components/product-gallery', null, ['product' => $product]);
// Keep sale flash as overlay if needed (Woo hook alternative)
do_action('woocommerce_before_single_product_summary');
// Remove default images from that hook to avoid duplicate: we already rendered
// But woocommerce_show_product_images already fired above via do_action; our gallery replaces it.
// To prevent duplicate, we unhooked it in setup.php? Keep as is – duplicate would be visible only if not unhooked.
?>
</div>
<div class="summary entry-summary xshop-product__summary">
<?php
/**
* Hook: woocommerce_single_product_summary.
* @hooked woocommerce_template_single_title - 5
* @hooked woocommerce_template_single_rating - 10
* @hooked woocommerce_template_single_price - 10
* @hooked woocommerce_template_single_excerpt - 20
* @hooked woocommerce_template_single_add_to_cart - 30
* @hooked woocommerce_template_single_meta - 40
* @hooked woocommerce_template_single_sharing - 50
*/
do_action('woocommerce_single_product_summary');
?>
<?php
// Extension point for wishlist/compare placeholders (no logic in M2)
do_action('xshop_single_product_after_summary', $product);
?>
</div>
</div>
<?php
/**
* Hook: woocommerce_after_single_product_summary.
* @hooked woocommerce_output_product_data_tabs - 10
* @hooked woocommerce_upsell_display - 15
* @hooked woocommerce_output_related_products - 20
*/
do_action('woocommerce_after_single_product_summary');
?>
</div>
<?php do_action('woocommerce_after_single_product'); ?>
@@ -0,0 +1,25 @@
<?php
/**
* Single product — wrapper delegates to Woo hooks + XShop gallery/layout.
*
* We keep Woo's core hooks (woocommerce_before_single_product, etc.) and
* render XShop gallery + summary via components. No business logic rewrite.
*
* WC version tested: 11.1.0
* Template version: 1.6.4
*/
defined('ABSPATH') || exit;
get_header('shop');
do_action('woocommerce_before_main_content');
while (have_posts()) {
the_post();
wc_get_template_part('content', 'single-product');
}
do_action('woocommerce_after_main_content');
get_footer('shop');