Delete directory 'shop'
This commit is contained in:
@@ -1,15 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
[*.{js,css,json,yml,yaml}]
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
release/*.zip
|
|
||||||
release/*.log
|
|
||||||
# Local test installs (M1.1) – never commit wp-config / debug logs / DB
|
|
||||||
xshop-test/
|
|
||||||
wp-config.php
|
|
||||||
debug.log
|
|
||||||
node_modules/
|
|
||||||
vendor/
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
.cache/
|
|
||||||
coverage/
|
|
||||||
.sass-cache/
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to XShop follow [Keep a Changelog](https://keepachangelog.com/) and [SemVer](https://semver.org/).
|
|
||||||
|
|
||||||
## [1.0.0] – Unreleased
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Phase 0: product spec, architecture, feature matrix, roadmap, UI spec, database model, security, performance, testing, compatibility docs + ADRs.
|
|
||||||
- **M1 — Theme Foundation**:
|
|
||||||
- Templates: `header.php`, `footer.php`, `index.php`, `front-page.php` (section-based hero/category/product/promo/latest), `single.php`, `page.php`, `archive.php`, `search.php`, `404.php`, `sidebar.php`, `comments.php` (threaded, paginated, accessible).
|
|
||||||
- Components: `template-parts/components/site-header.php`, `site-footer.php`, `breadcrumbs.php` (Woo-aware, filterable), `pagination.php` (RTL-safe), `search-form.php`, `post-card.php`, `empty-state.php`, `loading.php`, `section-heading.php`, `hero.php`, `promo-banner.php`.
|
|
||||||
- Design system: finalized `assets/css/tokens.css` (full palette/typography/spacing/radius/shadow/z/transition + dark mode semantic swap), `base.css`, `layout.css`, `components.css`, `utilities.css` — logical properties, zero `left`/`right`.
|
|
||||||
- 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.
|
|
||||||
- **M3 — WooCommerce AJAX Product Search (REST-first)**:
|
|
||||||
- Endpoint `GET /wp-json/xshop/v1/search` (`xshop-core/includes/REST/Search.php:1`, `Core/Plugin.php`) — public `__return_true`, sanitized `search` (strip_tags/sanitize_text_field/max 100) + `limit` 1..20, min 2 → empty, `WP_Query s` + `_sku` LIKE, `publish` only, `no_found_rows` `fields ids` deduped to limit default 6, response `{items:[id,title,url,image,price_html,type,in_stock],total}` + `Cache-Control: public, max-age=60`, no private/draft/customer/order, SKU + title/content search, HPOS-safe.
|
|
||||||
- UI: `template-parts/components/ajax-search.php:1` reusable combobox (`role=combobox` `aria-expanded/controls/autocomplete` `listbox` `aria-live` `data-*`, view-all `?s=&post_type=product`, hidden `post_type` when Woo active, RTL via logical, dark via tokens).
|
|
||||||
- JS: `assets/js/search.js:1` vanilla debounce 250ms, `AbortController`, stale `seq`, dedupe `lastQuery`, `fetch`, loading/empty/error/view-all, keyboard `ArrowUp/Down/Enter/Escape`, `click outside`, `aria-activedescendant`, no `console.log`.
|
|
||||||
- Style: `assets/css/woo.css:1` dropdown `absolute→fixed@640` `max 60vh` `token` search item grid `48px 1fr` + price/stock.
|
|
||||||
- Asset: `inc/setup/assets.php:1` enqueue `search.js` only where `data-xshop-search` (header front-end, init guard), versioned.
|
|
||||||
- Security: strict sanitization/validation, bounded limit/length, no private data, escaped output, abuse note in `SECURITY.md` (rate limit via proxy).
|
|
||||||
- Performance: debounce, bounded, stale cancel, dedupe, `Cache-Control`, minimal payload, documented in `PERFORMANCE.md`.
|
|
||||||
- Runtime: endpoint Persian/English/SKU/no-result/1-char/empty/xss/limit, Woo on/off fallback to posts, desktop/mobile/RTL/dark, no stale overwrite, `debug.log` clean.
|
|
||||||
- Docs: `ARCHITECTURE.md:7` `UI-SPEC.md:3,8` `SECURITY.md:11` `PERFORMANCE.md:8` `TESTING.md:M3` `RUNTIME-QA.md:M3`.
|
|
||||||
- **M4 — WooCommerce AJAX Product Filtering (REST-first, shared ProductQuery)**:
|
|
||||||
- Query: `xshop-core/includes/Query/ProductQuery.php:1` — `sanitize_search_param` `map_orderby` `build_product_query_args` (search/category/pa_* attributes/price/stock/sale/rating/orderby/page/per_page → `tax_query` + `meta_query` + `orderby` + `Cache-Control`), `format_product` `execute_query`, no raw SQL, no N+1, HPOS-safe.
|
|
||||||
- Endpoint `GET /wp-json/xshop/v1/products` (`REST/Products.php:1`, `Core/Plugin.php`) — public, `search` max 100, `category` slug/ID, dynamic `pa_*` (only `taxonomy_exists`), `min/max_price` 0..99M + `min<=max` 400, `stock all/instock/outofstock`, `on_sale 0/1` via `_sale_price OR _min_variation_sale_price`, `rating 0..5`, `orderby` allow-list, `page 1..100` `per_page 1..24` default 12, response `{items:[id,title,url,image,price_html,type,in_stock], pagination:{page,per_page,total,total_pages}}` + `public, max-age=30`, M3 refactored to use `Query`.
|
|
||||||
- UI: `template-parts/components/product-filters.php:1` — `data-xshop-filters` + `chips` + `form method=get` no-JS fallback (category radio, `pa_*` checkbox, price number, stock radio, sale checkbox, rating radio, orderby select, `Apply/Clear all`, hidden `s`), `woocommerce/archive-product.php:1` — `xshop-shop` layout `sidebar 280px|1fr` + `drawer dialog` + `overlay` + `toolbar` + `grid` + `chips` + `loading/error`.
|
|
||||||
- JS: `assets/js/filter.js:1` (8KB) — `parseStateFromURL` `stateToParams` `stateToEndpoint` `renderProductCard` (reuses `product-card` structure), `renderChips` + `Clear all`, `URLSearchParams` + `history.pushState` + `popstate` back/forward, `debounce 300ms` for price, `AbortController` + `seq` stale guard, `click outside` `Escape` drawer `focus trap` + `aria-expanded/controls` `role=dialog` `aria-modal`.
|
|
||||||
- Style: `assets/css/woo.css:1` — `.xshop-shop__layout` responsive, `.xshop-chip`, `.xshop-filter` card, `.xshop-drawer` fixed, RTL via `inset-inline`, dark via tokens.
|
|
||||||
- Asset: `inc/setup/assets.php:1` — `filter.js` front-end with DOM guard (`data-xshop-filters`), versioned, `woo.css` global intentional.
|
|
||||||
- Server fallback: `inc/woocommerce/setup.php:1` `pre_get_posts` `xshop_wc_filter_main_query` — `ProductQuery::build_product_query_args` → `tax_query/meta_query` for no-JS `?stock=instock` etc.
|
|
||||||
- Security: strict sanitization/validation, bounded, `taxonomy_exists` allow-list, `post_status publish` only, `post__in` for sale via Woo lookup, no private data, `permission_callback __return_true` public read-only, documented.
|
|
||||||
- Performance: bounded `per_page 24`, `no_found_rows` `fields ids` for search, `Cache-Control`, debounce, stale cancel, dedupe, minimal payload.
|
|
||||||
- Runtime: direct REST `category/price/stock/sale/rating/price_asc/pagination/search+filter` all PASS, server `?stock=instock` PASS, browser E2E (Playwright) category/sale/price/stock/sort/pagination/rapid/back/forward/drawer/search+filter all PASS (minor duplicate form fix), `debug.log` clean.
|
|
||||||
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# XShop — Persian-first WooCommerce Theme + Companion Plugin
|
|
||||||
|
|
||||||
Commercial, RTL/LTR, responsive, accessible, performant. Sold on RTL-Theme / راستچین.
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
- `xshop-theme/` — presentation (templates, design system, RTL, header/footer builders).
|
|
||||||
- `xshop-core/` — functionality (wishlist, compare, Q&A, AJAX search/filter, banners, demo import).
|
|
||||||
|
|
||||||
## Docs
|
|
||||||
|
|
||||||
- `docs/PRODUCT-SPEC.md` — product spec
|
|
||||||
- `docs/ARCHITECTURE.md` — architecture
|
|
||||||
- `docs/FEATURE-MATRIX.md` — feature matrix
|
|
||||||
- `docs/ROADMAP.md` — roadmap & phases
|
|
||||||
- `docs/UI-SPEC.md` — design system
|
|
||||||
- `docs/DATABASE-MODEL.md` — data model
|
|
||||||
- `docs/SECURITY.md` — security
|
|
||||||
- `docs/PERFORMANCE.md` — performance
|
|
||||||
- `docs/TESTING.md` — testing
|
|
||||||
- `docs/COMPATIBILITY.md` — compatibility
|
|
||||||
- `docs/ADR/` — architecture decision records
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- WordPress 6.4+, WooCommerce 8.0+, PHP 8.2+, modern browsers.
|
|
||||||
|
|
||||||
## Quick Start (dev)
|
|
||||||
|
|
||||||
1. Copy `xshop-theme/` to `wp-content/themes/xshop/`.
|
|
||||||
2. Copy `xshop-core/` to `wp-content/plugins/xshop-core/`.
|
|
||||||
3. Activate theme + plugin. Run setup wizard (XShop → Setup Wizard).
|
|
||||||
|
|
||||||
## Development Principles
|
|
||||||
|
|
||||||
See `docs/ARCHITECTURE.md` and `AGENTS.md` (if present). No TODO placeholders in releases. WC is source of truth for commerce; WP for content.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Commercial. Third-party assets documented in `xshop-theme/docs/` and `CHANGELOG.md`.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# ADR 001 – No Custom Tables in v1
|
|
||||||
|
|
||||||
**Status**: Accepted
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Wishlist/compare/Q&A/banners could use custom tables for performance. Customs add migration/upgrade burden and risk for a commercial theme sold on RTL-Theme where host environments vary.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Store all v1 data in WP primitives: CPT (`xshop_banner`, `xshop_qa`), `wp_usermeta` + cookies for wishlist/compare, single option `xshop_settings`. No custom tables.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Simpler install/uninstall, no `dbDelta` fragility.
|
|
||||||
- Query performance acceptable at ≤100k products; revisit if profiling shows bottleneck.
|
|
||||||
- Future ADR can introduce tables with migration path.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# ADR 002 – REST API as Primary for AJAX
|
|
||||||
|
|
||||||
**Status**: Accepted
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Search/filter/wishlist/compare/Q&A need async endpoints. `admin-ajax.php` is uncacheable and fires `admin_init`. REST is cache-friendly and has `permission_callback`.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
- REST (`xshop/v1/*`) for search, filter, wishlist, compare, Q&A.
|
|
||||||
- Keep `admin-ajax` only where WC core expects it (cart fragments/mini-cart) or as fallback.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Requires nonce via `X-WP-Nonce` + localized `wpApiSettings` or custom `xshopData.nonce`.
|
|
||||||
- Better compatibility with caching plugins; clearer permission model.
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
# XShop – Architecture (v1.0.0)
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
```
|
|
||||||
repo/
|
|
||||||
docs/ # product specs & ADRs
|
|
||||||
xshop-theme/ # presentation layer
|
|
||||||
xshop-core/ # companion plugin
|
|
||||||
release/ # built zips (not committed)
|
|
||||||
tools/ # build / QA scripts
|
|
||||||
```
|
|
||||||
|
|
||||||
Two installable artifacts: theme (`xshop`) + plugin (`xshop-core`). Theme never stores business logic that outlives theme activation (wishlist/compare/Q&A/banners live in plugin).
|
|
||||||
|
|
||||||
## 2. Theme – `xshop-theme`
|
|
||||||
|
|
||||||
### 2.1 File map (M4)
|
|
||||||
|
|
||||||
```
|
|
||||||
xshop-theme/
|
|
||||||
inc/setup/assets.php — tokens→…→woo→utilities + theme.js + search.js + filter.js (front-end, DOM guard, documented)
|
|
||||||
inc/woocommerce/setup.php — loop, toolbar, gallery, related, product.js, pre_get_posts no-JS fallback
|
|
||||||
assets/css/woo.css — + search + filters (.xshop-shop, _filters, _chip, _drawer, responsive)
|
|
||||||
assets/js/search.js — debounce 250ms, AbortController, stale seq
|
|
||||||
assets/js/filter.js — URL sync, chips, debounce 300ms for price, AbortController, drawer a11y
|
|
||||||
template-parts/components/ajax-search.php + product-filters.php (sidebar+drawer, no-JS fallback)
|
|
||||||
woocommerce/archive-product.php — shop/category with filters layout + chips + AJAX grid
|
|
||||||
xshop-core/
|
|
||||||
includes/Query/ProductQuery.php — shared sanitize, map_orderby, build_product_query_args, format_product, execute_query
|
|
||||||
includes/REST/Search.php — refactored to use Query
|
|
||||||
includes/REST/Products.php — xshop/v1/products (category, pa_*, price, stock, sale, rating, orderby, pagination, search)
|
|
||||||
includes/Core/Plugin.php — loads Query + both REST
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 Bootstrap
|
|
||||||
|
|
||||||
`functions.php` is a thin loader:
|
|
||||||
|
|
||||||
```php
|
|
||||||
require_once __DIR__ . '/inc/setup/theme-support.php';
|
|
||||||
require_once __DIR__ . '/inc/setup/menus.php';
|
|
||||||
// ... each file exposes a single setup function hooked appropriately
|
|
||||||
```
|
|
||||||
|
|
||||||
No giant `functions.php`. Each `inc/*` file is namespaced or prefixed `xshop_`.
|
|
||||||
|
|
||||||
### 2.3 Namespacing & Prefixing
|
|
||||||
|
|
||||||
- Text domain: `xshop`
|
|
||||||
- PHP functions/options/actions/meta: `xshop_` prefix.
|
|
||||||
- Classes: `XShop\Theme\...` and `XShop\Core\...` (PSR-4 style even if manual require).
|
|
||||||
|
|
||||||
### 2.4 Hook Strategy
|
|
||||||
|
|
||||||
- Use `after_setup_theme` for theme support, `init` for CPT/taxonomy (only banners/Q&A).
|
|
||||||
- Use `wp_enqueue_scripts` with conditional loading.
|
|
||||||
- Use WC hooks for pricing, badges, loops – avoid overriding templates unless necessary.
|
|
||||||
|
|
||||||
## 3. Plugin – `xshop-core`
|
|
||||||
|
|
||||||
```
|
|
||||||
xshop-core/
|
|
||||||
xshop-core.php # plugin header + loader
|
|
||||||
includes/
|
|
||||||
Core/ # plugin bootstrap, constants
|
|
||||||
Modules/
|
|
||||||
Wishlist/
|
|
||||||
Compare/
|
|
||||||
QA/
|
|
||||||
Search/
|
|
||||||
Filter/
|
|
||||||
Banners/
|
|
||||||
Badges/
|
|
||||||
DemoImport/
|
|
||||||
SetupWizard/
|
|
||||||
SystemStatus/
|
|
||||||
Widgets/
|
|
||||||
Admin/ # menu, dashboard, settings pages
|
|
||||||
REST/ # REST endpoints (search/filter/wishlist/compare/QA)
|
|
||||||
Compatibility/
|
|
||||||
assets/ css/ js/
|
|
||||||
languages/
|
|
||||||
templates/ # wishlist, compare, banners front-end fragments
|
|
||||||
```
|
|
||||||
|
|
||||||
Plugin owns persistence for wishlist/compare/Q&A/banners. Theme only renders.
|
|
||||||
|
|
||||||
## 4. Data Ownership
|
|
||||||
|
|
||||||
- **WC**: products, variations, orders, customers, coupons, shipping, payment, taxes, cart, checkout.
|
|
||||||
- **WP**: users, posts, pages, media, menus, comments.
|
|
||||||
- **Custom**: banners (CPT `xshop_banner`), Q&A (CPT or comments extension – see DATABASE-MODEL.md), wishlist/compare (user meta + cookie + transient for guests), theme settings (single option `xshop_settings` + mods).
|
|
||||||
|
|
||||||
Avoid custom tables in v1; revisit via ADR if scale demands.
|
|
||||||
|
|
||||||
## 5. Design System (M2)
|
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
## 6. Asset Strategy (M2)
|
|
||||||
|
|
||||||
- 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 (M4)
|
|
||||||
|
|
||||||
- **Search** (M3): `GET /wp-json/xshop/v1/search?search=&limit=` — public, `search` max 100 + `limit` 1..20, min 2, `WP_Query s` + `_sku LIKE`, `publish` only, `no_found_rows` `fields ids`, `{items:[id,title,url,image,price_html,type,in_stock],total}` + `Cache-Control: public, max-age=60`, shared `Query\ProductQuery`.
|
|
||||||
- **Products** (M4): `GET /wp-json/xshop/v1/products?search=&category=&pa_*=&min_price=&max_price=&stock=&on_sale=&rating=&orderby=&page=&per_page=` — public, sanitized `search` + `category` slug/ID + dynamic `pa_*` (only `taxonomy_exists`), `min/max` numeric 0..99M + `min<=max` 400, `stock all/instock/outofstock`, `on_sale 0/1` via meta OR, `rating 0..5` via `_wc_average_rating`, `orderby` allow-list `default/latest/price_asc/desc/popularity/rating/date` → `map_orderby`, `page 1..100` `per_page 1..24` default 12, shared `ProductQuery::build_product_query_args` → `WP_Query` `tax_query` + `meta_query` + `orderby` + `post__in` for sale, `execute_query` → `{items, pagination:{page,per_page,total,total_pages}}` + `Cache-Control: public, max-age=30`, no private data, no N+1.
|
|
||||||
- Prefer REST for search/filter, fallback `admin-ajax` for cart.
|
|
||||||
|
|
||||||
## 8. Security Model
|
|
||||||
|
|
||||||
See `SECURITY.md`. TL;DR: sanitize in, escape out, nonces, capability checks, prepared statements, no `unserialize`, no open redirects.
|
|
||||||
|
|
||||||
## 9. Performance Model
|
|
||||||
|
|
||||||
See `PERFORMANCE.md`. Conditional assets, lazy-load, transients for expensive queries, no N+1, `wp_cache`.
|
|
||||||
|
|
||||||
## 10. i18n / RTL
|
|
||||||
|
|
||||||
- All strings via `__()`, `_e()`, `esc_html__()` with text domain `xshop`.
|
|
||||||
- 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 (M2)
|
|
||||||
|
|
||||||
`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`.
|
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
ADRs in `docs/ADR/` – one per major decision (e.g., “No custom tables for Q&A in v1”).
|
|
||||||
|
|
||||||
## 13. Release
|
|
||||||
|
|
||||||
`release/xshop.zip` + `release/xshop-core.zip` built via `tools/build-release.ps1`. Excludes `.git`, `node_modules`, `.env`, logs, temp.
|
|
||||||
|
|
||||||
## 14. Versioning
|
|
||||||
|
|
||||||
SemVer 1.0.0. `CHANGELOG.md` follows Keep a Changelog.
|
|
||||||
|
|
||||||
## 15. Risks & Mitigations
|
|
||||||
|
|
||||||
- WC API churn → pin tested versions, monitor template versions, use hooks not overrides.
|
|
||||||
- RTL regressions → logical props + visual regression checklist.
|
|
||||||
- Demo import timeouts → chunked importer with resume.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# XShop – Compatibility
|
|
||||||
|
|
||||||
## 1. Tested Up To (M4: 2026-09-13)
|
|
||||||
|
|
||||||
| Dependency | Minimum | Tested |
|
|
||||||
|------------|---------|--------|
|
|
||||||
| WordPress | 6.4 | 7.1 |
|
|
||||||
| WooCommerce| 8.0 | 11.1.0 |
|
|
||||||
| PHP | 8.2 | 8.2.12, 8.3 recommended |
|
|
||||||
| Elementor | 3.15 (optional) | 3.2x |
|
|
||||||
| Gutenberg | WP bundled | — |
|
|
||||||
| Browsers | last 2 versions | 360/390/768/1024/1280/1440 via Playwright + CSS |
|
|
||||||
|
|
||||||
## 2. WordPress APIs Used
|
|
||||||
|
|
||||||
- Theme support: `add_theme_support('title-tag','post-thumbnails','html5','custom-logo','woocommerce'...)`
|
|
||||||
- Menus/sidebars/widgets: `register_nav_menus`, `register_sidebar`, `widgets_init`.
|
|
||||||
- Settings: `register_setting` + single option `xshop_settings` (Settings API).
|
|
||||||
- Customizer: `customize_register` for preview (typography/colours).
|
|
||||||
- REST: `register_rest_route` (namespace `xshop/v1`).
|
|
||||||
|
|
||||||
## 3. WooCommerce Integration (M4)
|
|
||||||
|
|
||||||
- `add_theme_support('woocommerce')` + wrappers, loop `xshop-products`, toolbar, `product.js` conditional, `search.js`+`filter.js` front-end (DOM guard).
|
|
||||||
- REST: `xshop/v1/search` + `xshop/v1/products` (shared `ProductQuery`), HPOS via `wc_get_products`/`WP_Query`, no raw SQL.
|
|
||||||
- Overrides (4): `content-product.php 3.6.0`, `archive-product.php 3.4.0` (now with `xshop-shop` layout + `product-filters` sidebar/drawer + `pre_get_posts` no-JS fallback), `content-single-product.php 3.6.0`, `single-product.php 1.6.4`.
|
|
||||||
|
|
||||||
## 4. HPOS
|
|
||||||
|
|
||||||
- Uses `wc_get_products` CRUD, not direct post table writes. Compatible with High-Performance Order Storage. No direct `wp_posts` manipulation for orders.
|
|
||||||
|
|
||||||
## 5. Elementor
|
|
||||||
|
|
||||||
- Widgets registered via `\Elementor\Plugin::instance()->widgets_manager->register` if Elementor active. Namespaced `XShop\Core\Elementor\Widgets\*`. No hard dependency – `is_plugin_active` guard.
|
|
||||||
|
|
||||||
## 6. Gutenberg
|
|
||||||
|
|
||||||
- Block styles via `register_block_style`, theme.json opt-in later. No classic-editor dependency.
|
|
||||||
|
|
||||||
## 7. Multisite
|
|
||||||
|
|
||||||
- Not primary target; options are per-site (no network options in v1).
|
|
||||||
|
|
||||||
## 8. Upgrade Notes
|
|
||||||
|
|
||||||
- Template version bumps checked via `WC template version` comment. Override only where necessary; changelog notes any override update.
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# XShop – Database Model
|
|
||||||
|
|
||||||
## 1. Philosophy
|
|
||||||
|
|
||||||
Reuse WP/WC primitives. No custom tables in v1.0 – revisit via ADR if query scale warrants. Document every key.
|
|
||||||
|
|
||||||
## 2. WordPress-Owned
|
|
||||||
|
|
||||||
- **Users** (`wp_users`, `wp_usermeta`): native. XShop adds: `xshop_wishlist` (array of product IDs), `xshop_compare` (array), `xshop_recently_viewed` (capped list).
|
|
||||||
- **Posts/Pages** (`wp_posts`, `wp_postmeta`): native.
|
|
||||||
- **Media** (`wp_posts` attachment): native.
|
|
||||||
- **Menus** (`wp_terms` nav_menu): native.
|
|
||||||
- **Comments/Reviews**: WC reviews = WP comments on `product` post type. XShop Q&A moderation hooks onto comment moderation flow where applicable – but primary store is custom (below).
|
|
||||||
|
|
||||||
## 3. WooCommerce-Owned
|
|
||||||
|
|
||||||
- **Products** (`wp_posts` post_type `product` + `wp_postmeta` + `wp_wc_*` where HPOS enabled). Variations are `product_variation` children.
|
|
||||||
- **Orders/Customers/Coupons/Shipping/Payment/Taxes/Cart/Checkout**: WC owned (HPOS tables if enabled). XShop never writes directly to WC order tables except via WC APIs.
|
|
||||||
|
|
||||||
## 4. XShop-Core Custom Data
|
|
||||||
|
|
||||||
### 4.1 Banners – CPT `xshop_banner`
|
|
||||||
|
|
||||||
- `post_type = xshop_banner` (non-public, `show_ui` true under XShop menu).
|
|
||||||
- Meta:
|
|
||||||
- `_xshop_banner_image` (attachment ID, int)
|
|
||||||
- `_xshop_banner_mobile_image` (attachment ID, int, nullable)
|
|
||||||
- `_xshop_banner_link` (url, esc_url_raw)
|
|
||||||
- `_xshop_banner_cta` (string)
|
|
||||||
- `_xshop_banner_position` (enum: `home_hero`, `home_strip`, `shop_top`, `product_sidebar`, etc.)
|
|
||||||
- `_xshop_banner_active` (`1`|`0`)
|
|
||||||
- `_xshop_banner_start` / `_xshop_banner_end` (datetime Y-m-d H:i:s, UTC)
|
|
||||||
- `_xshop_banner_order` (int)
|
|
||||||
- Scheduling checked on render (skip if outside window or inactive).
|
|
||||||
|
|
||||||
### 4.2 Questions & Answers – CPT `xshop_qa`
|
|
||||||
|
|
||||||
Decision: CPT (not comments) for cleaner moderation + threading without polluting reviews.
|
|
||||||
|
|
||||||
- `post_type = xshop_qa`, `post_parent` = product ID (or meta `_xshop_qa_product_id` for query flexibility – both stored, parent is canonical).
|
|
||||||
- Post fields: `post_title` empty, `post_content` = question body, `post_status` = `pending`|`publish`|`trash`, `post_author` = asker.
|
|
||||||
- Meta:
|
|
||||||
- `_xshop_qa_product_id` (int, indexed via meta query)
|
|
||||||
- `_xshop_qa_answer` (string, nullable – single answer for v1; extension to multiple answers uses child `xshop_qa` rows with `post_parent` = question ID in v1.1)
|
|
||||||
- `_xshop_qa_answered_by` (int user ID)
|
|
||||||
- `_xshop_qa_answered_at` (datetime)
|
|
||||||
- Alternative for multiple answers (future): child posts `post_type=xshop_qa`, `post_parent=question ID`.
|
|
||||||
- Spam: honeypot + nonce + rate limit (transient per IP/user, 60s).
|
|
||||||
|
|
||||||
### 4.3 Wishlist & Compare
|
|
||||||
|
|
||||||
- **Logged-in**: `wp_usermeta` keys `xshop_wishlist`, `xshop_compare` (serialized array of ints, capped: wishlist 200, compare 4).
|
|
||||||
- **Guest**: cookie `xshop_wishlist` / `xshop_compare` (JSON, HttpOnly false for JS count, SameSite Lax, 30 days) + optional transient `xshop_guest_<token>`.
|
|
||||||
- On login: merge cookie → user meta (union, cap), clear cookie.
|
|
||||||
- Counts exposed via REST and localized script data.
|
|
||||||
|
|
||||||
### 4.4 Recently Viewed
|
|
||||||
|
|
||||||
- Cookie `xshop_recently_viewed` (JSON array of product IDs, capped 20, 30 days). No DB write for guests. Logged-in also stored in `xshop_recently_viewed` user meta for cross-device.
|
|
||||||
|
|
||||||
## 5. Options
|
|
||||||
|
|
||||||
- Single option `xshop_settings` (array) – all Theme Options. Autoload `yes`. Individual mods also mirrored via `theme_mod` for Customizer preview where needed, but source of truth is `xshop_settings`.
|
|
||||||
- Option `xshop_settings_backup` (for import/export).
|
|
||||||
- Transient `xshop_search_cache_<hash>` (5 min) for AJAX search results.
|
|
||||||
- Transient `xshop_filter_counts_<hash>` (5 min) for filter counts.
|
|
||||||
|
|
||||||
Do NOT scatter random options.
|
|
||||||
|
|
||||||
## 6. Indexes & Query Patterns
|
|
||||||
|
|
||||||
- Banners: query by `post_type` + meta `position`+`active`+date range → meta query; small cardinality.
|
|
||||||
- Q&A: `WP_Query` by `post_type=xshop_qa` + `post_parent=product_id` + `post_status=publish` (index on `post_parent` + `post_type` already exists).
|
|
||||||
- Wishlist/Compare: user meta single-row lookup – O(1).
|
|
||||||
- Search: `WP_Query` with `s` + `post_type=product` + `tax_query` for category, `meta_query` for SKU (`_sku`), limited to 8 suggestions, `no_found_rows` true, `fields=ids` then prime caches.
|
|
||||||
|
|
||||||
## 7. Migrations & Compatibility
|
|
||||||
|
|
||||||
- HPOS: use `wc_get_products()` / CRUD, not direct `wp_postmeta` writes for product data.
|
|
||||||
- On plugin deactivate: data retained (no destructive cleanup). Uninstall hook offered to purge (`uninstall.php` with confirmation).
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# XShop – Feature Matrix (v1.0.0)
|
|
||||||
|
|
||||||
Legend: ✅ v1.0 · 🔜 v1.x · ❌ out-of-scope
|
|
||||||
|
|
||||||
## Frontend Pages
|
|
||||||
|
|
||||||
| Page | Status | Notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| Home | ✅ | Hero, promos, category grid, product carousels, banners |
|
|
||||||
| Shop / Archive | ✅ | Grid/list, filters, sort, pagination |
|
|
||||||
| Category | ✅ | Same as shop, category header |
|
|
||||||
| Tag / Archive | ✅ | — |
|
|
||||||
| Search Results | ✅ | AJAX search + fallback |
|
|
||||||
| Single Product | ✅ | Gallery/zoom, variations, badges, tabs, Q&A |
|
|
||||||
| Cart | ✅ | WC cart + cross-sells |
|
|
||||||
| Checkout | ✅ | WC checkout, coupons, shipping |
|
|
||||||
| Order Received | ✅ | WC thank-you |
|
|
||||||
| My Account | ✅ | WC account endpoints |
|
|
||||||
| Login / Register / Lost Password | ✅ | WC forms |
|
|
||||||
| Wishlist | ✅ | `xshop-core`, guest+auth |
|
|
||||||
| Compare | ✅ | Table view |
|
|
||||||
| Blog + Single | ✅ | — |
|
|
||||||
| Page / About / Contact | ✅ | Gutenberg-compatible |
|
|
||||||
| 404 | ✅ | Search + recent products |
|
|
||||||
|
|
||||||
## Product Features
|
|
||||||
|
|
||||||
| Feature | Status |
|
|
||||||
|---------|--------|
|
|
||||||
| Simple / Variable / Attributes / Variations | ✅ |
|
|
||||||
| Gallery + Zoom + Lightbox | ✅ |
|
|
||||||
| Sale/Regular price, Stock, Quantity, Add-to-cart | ✅ |
|
|
||||||
| Buy Now | ✅ |
|
|
||||||
| Wishlist / Compare / Quick View | ✅ |
|
|
||||||
| Recently Viewed / Related / Upsells / Cross-sells | ✅ |
|
|
||||||
| Reviews | ✅ (WC) |
|
|
||||||
| Q&A | ✅ |
|
|
||||||
| Badges / Labels | ✅ |
|
|
||||||
|
|
||||||
## Shop Features
|
|
||||||
|
|
||||||
| Feature | Status |
|
|
||||||
|---------|--------|
|
|
||||||
| AJAX search (title/SKU/category/tag) | ✅ |
|
|
||||||
| Category / Attribute / Price / Stock / Sale / Rating filters | ✅ |
|
|
||||||
| Sorting / Pagination / Load More | ✅ |
|
|
||||||
| Grid/List toggle / Result count / Active chips / Clear | ✅ |
|
|
||||||
|
|
||||||
## Header / Footer / Navigation
|
|
||||||
|
|
||||||
| Feature | Status |
|
|
||||||
|---------|--------|
|
|
||||||
| Desktop + Mobile header, Top bar, Sticky | ✅ |
|
|
||||||
| Search / Account / Wishlist / Cart / Mini-cart | ✅ |
|
|
||||||
| Navigation + Mega Menu | ✅ |
|
|
||||||
| Mobile bottom nav | ✅ |
|
|
||||||
| Footer builder (columns/menus/social/copyright/custom) | ✅ |
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
| Feature | Status |
|
|
||||||
|---------|--------|
|
|
||||||
| RTL + LTR, Responsive/mobile-first | ✅ |
|
|
||||||
| Typography / Colour / Spacing / Radius / Shadows / Z-index tokens | ✅ |
|
|
||||||
| Dark mode (tokens) | ✅ |
|
|
||||||
| Multiple header/footer/product-card layouts | ✅ (≥2 each) |
|
|
||||||
|
|
||||||
## Admin / Companion
|
|
||||||
|
|
||||||
| Feature | Status |
|
|
||||||
|---------|--------|
|
|
||||||
| Theme Options (General/Header/Footer/Typography/Colors/Shop/Product/Blog/Woo/Social/Performance/Custom CSS) | ✅ |
|
|
||||||
| Header/Footer Builder (config-based) | ✅ |
|
|
||||||
| Mega Menu manager | ✅ |
|
|
||||||
| Banner Manager | ✅ |
|
|
||||||
| Wishlist / Compare / Q&A / Badges | ✅ |
|
|
||||||
| AJAX Search / Filter | ✅ |
|
|
||||||
| Demo Import + Setup Wizard + System Status + Import/Export | ✅ |
|
|
||||||
| Extra widgets/components | ✅ |
|
|
||||||
|
|
||||||
## Integrations
|
|
||||||
|
|
||||||
| Integration | Status |
|
|
||||||
|-------------|--------|
|
|
||||||
| WooCommerce 8.0+ | ✅ |
|
|
||||||
| Gutenberg | ✅ |
|
|
||||||
| Elementor (optional, widgets in xshop-core) | ✅ |
|
|
||||||
|
|
||||||
## AJAX Candidates
|
|
||||||
|
|
||||||
search, filter, add-to-cart, mini-cart, wishlist, compare, quantity, remove-item, quick-view — all nonce-protected, loading/error states.
|
|
||||||
|
|
||||||
## v1.x Candidates
|
|
||||||
|
|
||||||
Subscriptions sub, multi-vendor, advanced analytics, PWA – via ADR.
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# XShop – Performance
|
|
||||||
|
|
||||||
## 1. Budgets
|
|
||||||
|
|
||||||
- LCP < 2.5s on mid-tier mobile (4G, Moto G4 class).
|
|
||||||
- Total blocking JS < 150ms.
|
|
||||||
- No render-blocking webfont swap beyond FOIT 100ms (use `font-display: swap`).
|
|
||||||
- DB queries: shop archive < 12 queries, product < 15 (WC baseline excluded).
|
|
||||||
|
|
||||||
## 2. Assets
|
|
||||||
|
|
||||||
- **Conditional enqueue**: each module (`search`, `filter`, `gallery`, `wishlist`) enqueued only where needed (`is_shop`, `is_product`, `is_search`, or presence of shortcode/block).
|
|
||||||
- **No global JS**: single `xshop.js` is ~5KB bootstrap; feature modules lazy-imported via `import()`.
|
|
||||||
- **CSS**: tokens + base always; component CSS split but concatenated in release (critical inline optional, not required v1).
|
|
||||||
- **No jQuery** for new code; WC jQuery remains for its own handlers.
|
|
||||||
- **Images**: `loading="lazy"` + `decoding="async"` + `srcset`, hero eager. Thumbnails via `wp_get_attachment_image`.
|
|
||||||
- **Fonts**: system stack default; optional Vazirmatn self-hosted, subset, no external request by default.
|
|
||||||
|
|
||||||
## 3. Queries
|
|
||||||
|
|
||||||
- Avoid N+1: prime post caches (`update_post_meta_cache`, `update_post_term_cache`), use `wc_get_products` with `return => ids` where only IDs needed, then `wc_get_product` in loop.
|
|
||||||
- Cache expensive counts: filter counts via transient (5 min), search suggestions via transient (5 min).
|
|
||||||
- No queries on `init` for frontend; defer to template.
|
|
||||||
|
|
||||||
## 4. Caching
|
|
||||||
|
|
||||||
- Transients for search/filter counts; `wp_cache` for in-request repeated lookups (e.g., `xshop_get_setting`).
|
|
||||||
- Compatibility with WP Rocket / LiteSpeed – no `DONOTCACHEPAGE` abuse.
|
|
||||||
|
|
||||||
## 5. Minification
|
|
||||||
|
|
||||||
- Release build minifies CSS/JS (`tools/build-release.ps1` runs cssnano/terser or PHP minify). Source maps for dev.
|
|
||||||
|
|
||||||
## 6. DOM
|
|
||||||
|
|
||||||
- Minimal wrappers, no deep nesting. No layout thrash (batch DOM reads/writes).
|
|
||||||
- Carousel: CSS scroll-snap, no heavy JS.
|
|
||||||
|
|
||||||
## 7. Monitoring
|
|
||||||
|
|
||||||
- `System Status` reports: PHP/WP/WC versions, active plugins, asset sizes, transient hit rate.
|
|
||||||
- Lighthouse CI optional (not required for v1).
|
|
||||||
|
|
||||||
## 8. Search Performance (M3)
|
|
||||||
|
|
||||||
- **Debounce**: 250ms in `assets/js/search.js:debounce` (200–300ms target). Prevents request on every keystroke.
|
|
||||||
- **Min length**: 2 chars (handler returns empty without query). Input `data-min-length`.
|
|
||||||
- **Bounded**: `limit` default 6, max 20, validated; handler over-fetches `limit*2` for dedup then slices to `limit`; max 40 IDs scanned.
|
|
||||||
- **Stale protection**: `AbortController` cancels previous fetch + monotonic `seq` counter ignores stale JSON; rapid `iph → iphone` cannot overwrite newer.
|
|
||||||
- **Dedupe**: identical query (`lastQuery`) reopens dropdown without fetch.
|
|
||||||
- **Query**: `WP_Query` `s` + `_sku` LIKE, `post_status publish`, `no_found_rows`, `fields ids`, no N+1, `wc_get_product` only for returned IDs (≤6).
|
|
||||||
- **Asset**: `search.js` enqueued only on front-end where `data-xshop-search` exists (header on all front-end pages, guarded via `initAll`); `woo.css` global intentional.
|
|
||||||
- **Caching**: server `Cache-Control: public, max-age=60`; no client cache beyond in-memory `lastQuery` (no stale invalidation issues).
|
|
||||||
- **Payload**: minimal 6 items × 6 fields (~2KB JSON).
|
|
||||||
|
|
||||||
## 9. Anti-Patterns Forbidden
|
|
||||||
|
|
||||||
- Global `wp_enqueue_script` without condition.
|
|
||||||
- Querying all products to compute filters.
|
|
||||||
- Unbounded `WP_Query` without `posts_per_page` / `no_found_rows`.
|
|
||||||
- External fonts/CDNs by default.
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# XShop – Product Specification (v1.0.0)
|
|
||||||
|
|
||||||
> Persian-first commercial WooCommerce theme. Original implementation, inspired by UX quality of modern Persian e-commerce themes such as BantaShop. Zero copying of source, assets, or branding from references.
|
|
||||||
|
|
||||||
## 1. Vision
|
|
||||||
|
|
||||||
XShop is a premium, performant, RTL/LTR, responsive WooCommerce theme + companion plugin (`xshop-core`) sold on RTL-Theme / راستچین. It must feel production-grade: polished defaults, robust demo import, accessible UI, secure code, and zero “coming soon” placeholders.
|
|
||||||
|
|
||||||
## 2. Non-goals
|
|
||||||
|
|
||||||
- Not a page-builder theme that forces Elementor.
|
|
||||||
- Not a WooCommerce fork – WC remains source of truth for products/orders/cart/checkout.
|
|
||||||
- Not a SaaS – fully self-hosted WordPress.
|
|
||||||
|
|
||||||
## 3. Target Audience
|
|
||||||
|
|
||||||
- Persian e-commerce stores (digital goods, electronics, fashion, general).
|
|
||||||
- Buyers on RTL-Theme expecting one-click demo import, Persian typography, and mobile-first UX.
|
|
||||||
- Developers extending via hooks/filters.
|
|
||||||
|
|
||||||
## 4. Personas
|
|
||||||
|
|
||||||
| Persona | Need |
|
|
||||||
|---------|------|
|
|
||||||
| Store Owner (non-technical) | Install, import demo, configure header/footer/colors, start selling in <30 min |
|
|
||||||
| Shop Manager | Manage banners, answer product Q&A, moderate wishlist/compare stats |
|
|
||||||
| Developer | Extend via hooks, child theme, documented APIs |
|
|
||||||
| Shopper (mobile RTL) | Fast search/filter, wishlist/compare, quick-view, smooth checkout |
|
|
||||||
|
|
||||||
## 5. Scope – Two Components
|
|
||||||
|
|
||||||
### 5.1 `xshop-theme` (presentation)
|
|
||||||
Layouts, templates, styling, responsive/RTL/LTR, design system, header/footer builders (config-based), typography/colour controls, blog/shop/product templates.
|
|
||||||
|
|
||||||
### 5.2 `xshop-core` (functionality)
|
|
||||||
Wishlist, compare, Q&A, AJAX search, AJAX filtering, banner manager, product badges, demo import, setup wizard, system status, Elementor/Gutenberg integrations, widgets.
|
|
||||||
|
|
||||||
WC is source of truth for products/variations/orders/customers/coupons/shipping/payment/taxes/cart/checkout. WP is source of truth for users/posts/pages/media/menus/comments.
|
|
||||||
|
|
||||||
## 6. User Stories (excerpt)
|
|
||||||
|
|
||||||
- As shopper I can AJAX-search products by title/SKU/category, see suggestions, navigate via keyboard, handle no-results gracefully.
|
|
||||||
- As shopper I can filter shop by category/attribute/price/stock/sale/rating, see active chips, clear all, use drawer on mobile without full reload.
|
|
||||||
- As shopper I can wishlist (guest + logged-in persistent), view count badge, manage on wishlist page.
|
|
||||||
- As shopper I can compare up to N products in a responsive comparison table.
|
|
||||||
- As shopper I can ask product questions, see moderated answers.
|
|
||||||
- As shopper I see product badges (sale/new/featured), gallery with zoom, variation selector, buy-now.
|
|
||||||
- As admin I run setup wizard → choose demo → import content/menus/widgets/settings → see result.
|
|
||||||
- As admin I manage banners (image, mobile image, link, CTA, schedule, position, active flag).
|
|
||||||
- As admin I configure header/footer via builder, toggle dark mode, typography, colours.
|
|
||||||
|
|
||||||
## 7. Functional Requirements
|
|
||||||
|
|
||||||
### Frontend pages
|
|
||||||
home, shop, category, tag/archive, search results, single product, cart, checkout, order-received, my-account, login, register, lost-password, wishlist, compare, blog, single post, page, about, contact, 404.
|
|
||||||
|
|
||||||
### Product types
|
|
||||||
simple, variable (attributes/variations), gallery, zoom, sale/regular price, stock, quantity, add-to-cart, buy-now, wishlist, compare, quick-view, recently-viewed, related, upsells, cross-sells, reviews, Q&A, badges.
|
|
||||||
|
|
||||||
### Shop
|
|
||||||
AJAX search + filtering (category/attribute/price/stock/sale/sorting), pagination/load-more, grid/list toggle, result count, active filters.
|
|
||||||
|
|
||||||
### Header
|
|
||||||
Desktop + mobile, top bar, search, account, wishlist, cart/mini-cart, navigation, mega-menu, sticky, bottom nav.
|
|
||||||
|
|
||||||
### Admin/Managers
|
|
||||||
Theme options (General/Header/Footer/Typography/Colors/Shop/Product/Blog/Woo/Social/Performance/Custom CSS), header/footer builder, mega-menu, banner manager, Q&A moderation, demo import, setup wizard, system status, import/export.
|
|
||||||
|
|
||||||
## 8. Non-Functional Requirements
|
|
||||||
|
|
||||||
- **Performance**: assets enqueued conditionally, lazy-load, minimal DOM, avoid N+1, handle WC asset loading.
|
|
||||||
- **Security**: escape/sanitize/validate everywhere, nonce + capability checks, no unsafe unserialize/redirect/upload.
|
|
||||||
- **Accessibility**: keyboard nav, visible focus, semantic HTML, ARIA only where needed, contrast, reduced-motion.
|
|
||||||
- **i18n**: text domain `xshop`, translation-ready, no hardcoded Persian UI strings in logic, Persian/English UI, number localisation where appropriate.
|
|
||||||
- **Compatibility**: WP 6.4+, WC 8.0+, PHP 8.2+, Gutenberg, Elementor (optional).
|
|
||||||
- **Responsiveness**: mobile-first, breakpoints 360/768/1024/1280/1536, container tokens.
|
|
||||||
|
|
||||||
## 9. Out-of-Scope for v1.0
|
|
||||||
|
|
||||||
- Native mobile apps, marketplace multi-vendor, subscription engine (extensions may be added later via ADR).
|
|
||||||
|
|
||||||
## 10. Success Criteria (Definition of Done)
|
|
||||||
|
|
||||||
Per spec §40: implemented + integrated + styled + responsive + RTL + LTR spot-check + accessible + secure + translated + documented + tested + no console errors + no PHP warnings + no perf regression.
|
|
||||||
|
|
||||||
## 11. References
|
|
||||||
|
|
||||||
- Reference UX: `https://bantashop-demo1.bantaco.ir/` (UX inspiration only).
|
|
||||||
- WC docs: product loops, variations, cart/checkout hooks, template versioning.
|
|
||||||
- WP docs: Settings API, REST API, security APIs.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# XShop – Roadmap
|
|
||||||
|
|
||||||
## Versioning
|
|
||||||
|
|
||||||
SemVer. Start `1.0.0`. `CHANGELOG.md` per Keep a Changelog (Added/Changed/Fixed/Security/Deprecated/Removed).
|
|
||||||
|
|
||||||
## Phases
|
|
||||||
|
|
||||||
### Phase 0 – Requirements & Discovery ✅ (current)
|
|
||||||
- Specs: PRODUCT-SPEC, ARCHITECTURE, FEATURE-MATRIX, ROADMAP, UI-SPEC, DATABASE-MODEL, SECURITY, PERFORMANCE, TESTING, COMPATIBILITY.
|
|
||||||
- Repo scaffolding, initial milestone, commit.
|
|
||||||
|
|
||||||
### Phase 1 – Theme Foundation (M1)
|
|
||||||
- Theme header (`style.css`), screenshot, `functions.php` loader, `inc/setup/*`, menus/sidebars/image sizes.
|
|
||||||
- Design tokens (`assets/css/tokens.css`), base + logical props, RTL check, dark-mode tokens.
|
|
||||||
- Core templates: `index.php`, `front-page.php`, `header.php`, `footer.php`, `sidebar.php`, `single.php`, `page.php`, `archive.php`, `search.php`, `404.php`, `comments.php`.
|
|
||||||
- Template parts + components skeleton.
|
|
||||||
- Enqueue strategy + conditional loading.
|
|
||||||
|
|
||||||
### Phase 2 – WooCommerce Integration (M2)
|
|
||||||
- WC setup, declared support, wrapper, breadcrumbs, pagination, sorting.
|
|
||||||
- Product card variants, loop, minimal WC template overrides (documented + version-tracked).
|
|
||||||
- Single product (gallery/zoom/variations/badges/tabs), cart/checkout/account templates parity.
|
|
||||||
- Notices, stock, pricing, coupons, shipping, payment gateway compat.
|
|
||||||
|
|
||||||
### Phase 3 – Design System & Layouts (M3)
|
|
||||||
- Typography/colour/spacing/radius/shadows/z-index/transition tokens finalized.
|
|
||||||
- Header builder (config, ≥3 layouts), footer builder, mega-menu, mobile nav + bottom nav.
|
|
||||||
- Blog/product/shop layouts, search overlay, filter drawer, modals, etc.
|
|
||||||
- Dark mode coverage, RTL/LTR visual pass.
|
|
||||||
|
|
||||||
### Phase 4 – xshop-core Modules (M4)
|
|
||||||
- Plugin header, Admin menu (Dashboard/Options/Banners/Questions/Wishlist/Demo Import/System Status/Docs).
|
|
||||||
- Wishlist (guest cookie + user meta, REST), Compare (REST, table), Q&A (CPT/comments, moderation, REST), Search (REST), Filter (REST), Banners (CPT), Badges.
|
|
||||||
- Widgets/Elementor widgets (namespaced, translation-ready).
|
|
||||||
|
|
||||||
### Phase 5 – AJAX & Interactivity (M5)
|
|
||||||
- Search/filter/cart/mini-cart/wishlist/compare/quantity/quick-view.
|
|
||||||
- Nonce/capability/validation, loading/error/empty states, URL state for filters.
|
|
||||||
|
|
||||||
### Phase 6 – Theme Options & Builders (M6)
|
|
||||||
- Options framework (single option `xshop_settings`, sanitization per field).
|
|
||||||
- Import/Export, Custom CSS, Performance toggles.
|
|
||||||
|
|
||||||
### Phase 7 – Demo Import & Setup Wizard (M7)
|
|
||||||
- Chunked importer (content/menus/widgets/settings), timeout/duplicate/partial handling, recovery docs.
|
|
||||||
- Setup wizard flow, required plugins notice.
|
|
||||||
|
|
||||||
### Phase 8 – Polish (M8)
|
|
||||||
- Accessibility pass, security review, performance audit, browser/RTL/LTR/dark-mode QA.
|
|
||||||
- Docs: installation, setup, demo-import, theme-options, header/footer, mega-menu, woocommerce, elementor, gutenberg, wishlist/compare/questions/ajax-*, troubleshooting.
|
|
||||||
- Release package.
|
|
||||||
|
|
||||||
## Milestone Reporting
|
|
||||||
|
|
||||||
Each milestone ends with: completed / files changed / tests run / problems found+fixed / remaining / next.
|
|
||||||
|
|
||||||
## Exit Criteria for 1.0.0
|
|
||||||
|
|
||||||
All Phase 8 items + Definition of Done per feature.
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
# XShop — Runtime QA (M1.1)
|
|
||||||
|
|
||||||
Date: 2026-09-13
|
|
||||||
Tester: local XAMPP
|
|
||||||
|
|
||||||
## 1. Environment
|
|
||||||
|
|
||||||
| Component | Version | Notes |
|
|
||||||
|-----------|---------|-------|
|
|
||||||
| PHP | 8.2.12 (cli, XAMPP) | Required 8.2+, preferred 8.3+. **Limitation documented**: XAMPP currently provides 8.2.12; theme uses PHP 8.2-compatible syntax only, tested successfully on 8.2.12. Recommend CI on 8.3 before final release. |
|
|
||||||
| WordPress | 7.1 (latest stable at test time, downloaded from wordpress.org) | `wp-includes/version.php` → `$wp_version = '7.1'` |
|
|
||||||
| WooCommerce | 11.1.0 (latest-stable.zip) | Active, `plugin list` confirms |
|
|
||||||
| MariaDB | 10.4.32 (XAMPP `C:\xampp\mysql\bin\mysql.exe`) | `SELECT VERSION()` → 10.4.32-MariaDB |
|
|
||||||
| Apache | 2.4.58 (XAMPP) | Listening on :80, `AllowOverride All` for `C:/xampp/htdocs`, custom `.htaccess` created for `/xshop-test/wordpress/` (`RewriteBase /xshop-test/wordpress/`) |
|
|
||||||
| WP-CLI | 2.12.0 (phar) | `C:\Users\hemn\AppData\Local\Temp\opencode\wp-cli.phar` |
|
|
||||||
| Node | v22.23.2 / npm 10.9.8 | JS `node --check` passed |
|
|
||||||
| Composer | not installed | Not required for M1 |
|
|
||||||
| Docker | not available | Not used; XAMPP preferred as reproducible local runtime |
|
|
||||||
| OS | Windows 11 (build 28000, AMD64) | |
|
|
||||||
| Browsers (manual fetch) | PowerShell `Invoke-WebRequest` (HTTP 200 checks) + `Invoke-WebRequest` for rendered HTML inspection | No Playwright yet; console check via static asset fetch |
|
|
||||||
|
|
||||||
### PHP / DB Setup
|
|
||||||
|
|
||||||
- DB `xshop_test` utf8mb4_unicode_ci, user `root` (no password, local only), `wp-config.php` with `WP_DEBUG=true`, `WP_DEBUG_LOG=true`, `WP_DEBUG_DISPLAY=false`, `FS_METHOD=direct`, salts from `api.wordpress.org`.
|
|
||||||
- Fixed BOM issue after initial `Set-Content -Encoding UTF8` (stripped 0xEF 0xBB 0xBF via `[System.IO.File]::WriteAllBytes`).
|
|
||||||
- Started `mysqld.exe` (PID 23480, :3306) and `httpd.exe` (PID 10196/23912, :80) manually; verified via `netstat`.
|
|
||||||
|
|
||||||
## 2. Installation Method
|
|
||||||
|
|
||||||
1. Extracted `https://wordpress.org/latest.zip` (37216004 bytes) to `C:\xampp\htdocs\xshop-test\wordpress`.
|
|
||||||
2. `wp core install --url=http://localhost/xshop-test/wordpress --title="XShop Test" --admin_user=xshop_admin --admin_email=xshop@test.local` (password `Xshop123!Test`, not committed).
|
|
||||||
3. `wp rewrite structure "/%postname%/"` + `wp rewrite flush` + manual `.htaccess` (WordPress flushed but Apache required explicit `.htaccess` with `RewriteBase /xshop-test/wordpress/`).
|
|
||||||
4. `wp language core install fa_IR` + `site switch-language` for RTL test, then back to `en_US`.
|
|
||||||
5. WooCommerce: downloaded `https://downloads.wordpress.org/plugin/woocommerce.latest-stable.zip` (18024069 bytes), extracted to `wp-content/plugins/woocommerce`, `wp plugin activate woocommerce`.
|
|
||||||
6. XShop theme: copied `C:\Users\hemn\Desktop\shop\xshop-theme` → `wp-content/themes/xshop`, `wp theme activate xshop`.
|
|
||||||
7. Sample data: pages `درباره ما`, `تماس با ما`; posts `سلام دنیا — تست عنوان خیلی طولانی فارسی...`, `Hello World LTR test...`; menus `Primary` assigned to `xshop-primary`; widgets `search`, `recent-posts` in `sidebar-main`; product cats `لپتاپ`, `گوشی موبایل`; WC products: simple sale (25,000,000 → 22,000,000), simple no-image, out-of-stock, variable `تیشرت متغیر`, plus restore product after delete test; comments threaded (2 → child 3).
|
|
||||||
|
|
||||||
## 3. Test Cases — Pass/Fail
|
|
||||||
|
|
||||||
| # | Test | URL / Method | Result | Notes |
|
|
||||||
|---|------|--------------|--------|-------|
|
|
||||||
| 1 | Home (LTR, products exist) | `GET /xshop-test/wordpress/` | **PASS** 200, `xshop-header` + `xshop-footer` found, `xshop-hero` renders, `Latest Products` grid OK | 45251 bytes |
|
|
||||||
| 2 | Home — no products | delete all products → `GET /` | **PASS** 200, `xshop-hero` present, `Latest Products` correctly absent (empty-safe) | Front-page.php checks `wc_get_products` non-empty |
|
|
||||||
| 3 | Home — RTL | `site switch-language fa_IR` → `GET /` | **PASS** `dir="rtl"`, `lang="fa"`, `xshop--rtl` body class, header/footer OK | LTR default verified before switch |
|
|
||||||
| 4 | Blog (index) | `GET /` (blog) / `post list` | **PASS** grid + pagination hidden when ≤1 page |
|
|
||||||
| 5 | Single post — long Persian title | `GET /?p=13` | **PASS** 200, title `overflow-wrap`, `xshop-breadcrumbs`, `xshop-comments` found, comment content rendered | |
|
|
||||||
| 6 | Page | `GET /?p=11` (`درباره ما`) | **PASS** 200, breadcrumbs found, `xshop-prose` |
|
|
||||||
| 7 | Archive | `GET /category/uncategorized/` | **PASS** 200 (after .htaccess fix) |
|
|
||||||
| 8 | Search — results | `GET /?s=لپ` | **PASS** 200, `xshop-breadcrumbs` |
|
|
||||||
| 9 | Search — no results | `GET /?s=nonexistentxyz` | **PASS** 200, `empty-state` |
|
|
||||||
| 10 | 404 | `GET /notfound-404-test-xyz/` | **PASS** HTTP 404, body contains `xshop-404` + `Recent posts` | |
|
|
||||||
| 11 | Comments — threading | create comment child → `GET /?p=13` | **PASS** `xshop-comments` + nested `.children` |
|
|
||||||
| 12 | Sidebar | `GET /` + `sidebar-main` widget list | **PASS** `search` + `recent-posts` rendered, empty-state suppressed when widgets exist |
|
|
||||||
| 13 | Header — skip / mobile nav | HTML inspection | **PASS** `xshop-skip-link` → `#xshop-main`, `data-xshop-mobile-toggle` + `aria-expanded` + `aria-controls="xshop-mobile-nav"` + `hidden`, `xshopTrapFocus` in JS |
|
|
||||||
| 14 | Footer | HTML inspection | **PASS** 4 cols, copyright `© 2026 XShop Test`, `xshop-footer__nav-list` |
|
|
||||||
| 15 | Shop | `GET /shop/` | **PASS** 200, `xshop-header/footer/breadcrumbs`, `W` count 28 (Woo inline) | Pretty permalinks required `.htaccess` fix |
|
|
||||||
| 16 | Simple product — sale | `GET /?post_type=product&p=17` | **PASS** 200, `price_html` with sale |
|
|
||||||
| 17 | Simple — no image | `GET /?post_type=product&p=18` | **PASS** 200, placeholder `woocommerce-placeholder.webp` |
|
|
||||||
| 18 | Out of stock | `GET /?post_type=product&p=19` | **PASS** 200, `in_stock=false` |
|
|
||||||
| 19 | Variable product | `GET /?post_type=product&p=20` (deleted/restored as 21) | **PASS** 200 |
|
|
||||||
| 20 | Cart / Checkout / My Account | `GET /cart/`, `/checkout/`, `/my-account/` | **PASS** 200 each |
|
|
||||||
| 21 | Woo notices | `GET /cart/` etc | **PASS** no fatal, `woocommerce` strings present (inline CSS), theme does not suppress notices |
|
|
||||||
| 22 | Mobile — viewports (static) | CSS inspection `layout.css:1` | **PASS** no `left`/`right`, logical props, grids `3→2→1` at 1024/640, footer `4→2→1` |
|
|
||||||
| 23 | Dark mode tokens | `GET tokens.css` 200 + HTML `data-theme="light"` | **PASS** `[data-theme="dark"]` covers bg/surface/card/text/muted/border/input/focus/link/notice bg, `xshop--light` body class |
|
|
||||||
| 24 | Asset versioning | HTML `?ver=1.0.0` | **PASS** `tokens/base/layout/components/utilities/style.css` + `theme.js` all `ver=1.0.0`, deferred `wc` scripts, `xshopData` localized, no duplicate CSS |
|
|
||||||
| 25 | RTL stylesheet | `rtl.css` 200 | **PASS** minimal flag, logical props used, WP `style.css` `rtl replace` |
|
|
||||||
| 26 | JS console | `theme.js` fetch 200 + `Select-String console.log` | **PASS** no `console.log`, `initMobileNav` found |
|
|
||||||
| 27 | PHP errors — XShop | `wp-content/debug.log` after clean hits | **PASS** 0 lines (no XShop fatals/warnings/notices). Pre-existing 2 lines only on first install: `HTTP_HOST` warning (WP-CLI CLI, not XShop) + `_load_textdomain_just_in_time` Woo notice (WP 6.7+ common, not XShop). Deprecated `header.php/footer.php` warnings occurred only when theme folder was temporarily missing during ZIP reinstall test (not XShop). |
|
|
||||||
| 28 | Theme ZIP | `release/xshop.zip` (35595 bytes after fix) + `xshop-core.zip` (5471) | **PASS** via `Expand-Archive` to `themes/xshop` + `wp theme activate` → `GET /` 200. **Known WP-CLI limitation**: `wp theme install <zip>` fails on empty `languages/` directory (`Warning: Could not copy file "xshop\languages\"`), but manual unzip (WP admin upload equivalent) works. Fixed `tools/build-release.ps1:1` to include top-level folder `xshop/` inside zip (previously created from inner folder only). |
|
|
||||||
| 29 | WP compat | `wp_head wp_footer wp_body_open body_class language_attributes post_class wp_nav_menu dynamic_sidebar custom_logo title-tag post thumbnails pagination editor` | **PASS** all markers found in rendered HTML (`<html lang>`, `body class="...xshop..."`, `post_class` on cards, `wp_nav_menu` fallback, `dynamic_sidebar` blocks, `custom_logo` placeholder, `paginate_links` via pagination component) |
|
|
||||||
|
|
||||||
**Total: 29 tests — 29 PASS, 0 FAIL attributable to XShop.**
|
|
||||||
|
|
||||||
## 4. Known Limitations & Fixes
|
|
||||||
|
|
||||||
| Issue | Severity | Fix / Mitigation |
|
|
||||||
|-------|----------|------------------|
|
|
||||||
| XAMPP PHP 8.2.12 vs target 8.3+ | Low | Documented; theme uses 8.2-compatible syntax, no 8.3-only features. Recommend CI on 8.3 before 1.0.0 release. |
|
|
||||||
| Initial `.htaccess` missing → 404 on pretty permalinks | Fixed | Created `C:\xampp\htdocs\xshop-test\wordpress\.htaccess` with `RewriteBase /xshop-test/wordpress/` |
|
|
||||||
| Product slug 404 via encoded URL in PowerShell `Invoke-WebRequest` (Persian URL) | Not a theme bug | `GET /?post_type=product&p=<id>` works 200; encoded slug works in browser but PS URL encoding differs. Shop/category/product via WP_Query ID param verified. |
|
|
||||||
| `build-release.ps1` previously built ZIP without top-level folder → `wp theme install` failed on empty `languages\` | Fixed | Changed `CreateFromDirectory((Join-Path $tmp "xshop"), $Dest)` → `CreateFromDirectory($tmp, $Dest)` to include `xshop/` wrapper; rebuilt `release/xshop.zip` (35595 bytes). Manual `Expand-Archive` install now passes. Note WP-CLI still warns on empty dirs but install via WP admin upload (Expand-Archive equivalent) works; WP-CLI install warning is upstream. |
|
|
||||||
| `WP-CLI` BOM after `Set-Content -Encoding UTF8` | Fixed | Stripped BOM via `[System.IO.File]::WriteAllBytes` |
|
|
||||||
| Woo `_load_textdomain_just_in_time` notice | Upstream Woo/WP 7.1 | Not XShop; Woo loads translations early. Filtered out of XShop checks. |
|
|
||||||
| No Docker / Composer | Low | Documented; XAMPP used as reproducible local runtime per spec fallback. |
|
|
||||||
|
|
||||||
## 5. Screenshots
|
|
||||||
|
|
||||||
Not captured via automated fetch; HTML saved for inspection:
|
|
||||||
- `C:\Users\hemn\AppData\Local\Temp\opencode\home.html` (LTR, 45251 bytes)
|
|
||||||
- `C:\Users\hemn\AppData\Local\Temp\opencode\home-rtl.html` (RTL, 45396 bytes)
|
|
||||||
|
|
||||||
Key HTML markers used as proxy:
|
|
||||||
- `xshop-header` / `xshop-footer` on all pages
|
|
||||||
- `xshop-breadcrumbs` on inner pages, absent on front (correct)
|
|
||||||
- `xshop-404` on 404 with HTTP 404
|
|
||||||
- `xshop-comments` with threaded `.children`
|
|
||||||
|
|
||||||
## 6. Conclusion
|
|
||||||
|
|
||||||
**M1.1 ACCEPTED** — Real WordPress 7.1 + WooCommerce 11.1.0 on PHP 8.2.12 + MariaDB 10.4.32:
|
|
||||||
|
|
||||||
- XShop activates, homepage + core templates render, WooCommerce does not break theme, RTL/LTR + mobile nav work, no XShop PHP warnings/notices/fatals, no JS console errors, assets load with versioning, installable ZIP works (manual Expand-Archive, i.e., WP admin upload).
|
|
||||||
|
|
||||||
Bugs found: 2 (`.htaccess` + ZIP folder wrapper) — both fixed and re-tested.
|
|
||||||
|
|
||||||
Next: M2 (WooCommerce shop/product/cart designs) only after this doc is committed.
|
|
||||||
|
|
||||||
## 7. Repro Steps
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# DB & WP already installed in C:\xampp\htdocs\xshop-test\wordpress
|
|
||||||
C:\xampp\php\php.exe "C:\Users\hemn\AppData\Local\Temp\opencode\wp-cli.phar" --path="C:\xampp\htdocs\xshop-test\wordpress" core version
|
|
||||||
C:\xampp\php\php.exe "C:\Users\hemn\AppData\Local\Temp\opencode\wp-cli.phar" --path="C:\xampp\htdocs\xshop-test\wordpress" plugin list
|
|
||||||
Invoke-WebRequest -Uri "http://localhost/xshop-test/wordpress/" -UseBasicParsing
|
|
||||||
Get-Content "C:\xampp\htdocs\xshop-test\wordpress\wp-content\debug.log" # should be empty
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# M3 — AJAX Search (2026-09-13)
|
|
||||||
|
|
||||||
## Endpoint
|
|
||||||
|
|
||||||
- `GET /wp-json/xshop/v1/search?search=Test&limit=6` → `200 {"items":[{"id":23,"title":"Out of Stock Test...","url":"...","image":"...","price_html":"...","type":"simple","in_stock":false}],"total":1}` — verified via `Invoke-WebRequest`.
|
|
||||||
- **Persian**: `search=لپ` → product 22 sale `-20%`; `search=Restore` → 21; `SKU XSHOP-SKU-123` → 21.
|
|
||||||
- **Validation**: `search` >100 → 400, `limit` >20 → 400, `search` 1-char / empty / whitespace → `{"items":[],"total":0}` 200, `limit` default 6 enforced.
|
|
||||||
- **Security**: `sanitize_text_field` + `wp_strip_all_tags` + `mb_substr 100`, `absint` 1..20; only `publish` products; no `post_password`/customer/order; `price_html` from Woo trusted; no `TODO`/`console.log`.
|
|
||||||
- **Woo inactive**: deactivated Woo → `search=Hello` → 2 posts `type post` with `price_html ""` (fallback), re-activated Woo → product search restored.
|
|
||||||
|
|
||||||
## UI
|
|
||||||
|
|
||||||
- Header `data-xshop-search` markup present on `GET /` → `xshop-search__dropdown` + `search.js` enqueued (`<script id="xshop-search-js" src=".../search.js?ver=1.0.0">`), hidden `post_type=product` when Woo active, `role=combobox` `aria-expanded=false` `aria-controls` `listbox` `aria-live polite`.
|
|
||||||
- **Dropdown**: `woo.css` absolute `60vh` → fixed `@640`, token colors, grid `48px 1fr`, price `del/ins`, stock indicator, view-all `?s=&post_type=product`.
|
|
||||||
|
|
||||||
## JS
|
|
||||||
|
|
||||||
- `debounce 250ms`, `AbortController` cancels previous, `seq` ignores stale (`iph` cannot overwrite `iphone`), dedupe `lastQuery` avoids duplicate fetch, loading/empty/error/view-all states, `ArrowDown/Up` `aria-selected`/`aria-activedescendant`, `Enter` selects active or submits form, `Escape` closes, `click outside` closes, `focus` management, no `console.log`, `node --check` pass.
|
|
||||||
|
|
||||||
## Accessibility / RTL / Dark / Mobile
|
|
||||||
|
|
||||||
- `role=search` + `label.sr-only` + `combobox` semantics correct; keyboard flow tested via JS logic; `prefers-reduced-motion` respected.
|
|
||||||
- RTL `fa_IR` → dropdown `inset-inline` logical; dark via `var(--xshop-card)` etc; mobile fixed bottom `55vh` no overflow.
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- `limit 6` bounded, `posts_per_page limit*2` max 40 IDs, `no_found_rows`, `fields ids`, `wc_get_product` only for 6, `Cache-Control: public, max-age=60`, payload ~2KB, `search.js` front-end only (init guard).
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
**M3 ACCEPTED** — AJAX search REST + UI + JS + a11y + RTL/dark/mobile + security/performance + Woo on/off + stale protection + canonical search fallback all pass. No private data, no XShop errors.
|
|
||||||
|
|
||||||
## Repro
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Invoke-WebRequest -Uri "http://localhost/xshop-test/wordpress/wp-json/xshop/v1/search?search=Restore&limit=6" -UseBasicParsing
|
|
||||||
# Woo inactive fallback
|
|
||||||
wp plugin deactivate woocommerce; Invoke-WebRequest -Uri "http://localhost/xshop-test/wordpress/wp-json/xshop/v1/search?search=Hello&limit=6" -UseBasicParsing
|
|
||||||
```
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# XShop – Security
|
|
||||||
|
|
||||||
## 1. Principles
|
|
||||||
|
|
||||||
- Sanitize in, validate, escape out.
|
|
||||||
- Nonce + capability for every state-changing request.
|
|
||||||
- Use WP/WC APIs; no hand-rolled auth/crypto.
|
|
||||||
|
|
||||||
## 2. Input
|
|
||||||
|
|
||||||
- `sanitize_text_field`, `sanitize_textarea_field`, `sanitize_email`, `wc_clean`, `absint`, `esc_url_raw` per type.
|
|
||||||
- Validate: enums via allow-list, URLs via `wp_http_validate_url` where remote, dates via `DateTimeImmutable`.
|
|
||||||
- No `$_GET`/`$_POST` without sanitization. No `extract()`.
|
|
||||||
|
|
||||||
## 3. Output
|
|
||||||
|
|
||||||
- `esc_html`, `esc_attr`, `esc_url`, `esc_html__`, `wp_kses_post` (only for intentionally HTML fields with allow-list), `wp_kses` for narrow HTML.
|
|
||||||
- No `echo $raw`.
|
|
||||||
|
|
||||||
## 4. CSRF
|
|
||||||
|
|
||||||
- Every POST/AJAX/REST mutation: `check_ajax_referer` / `wp_verify_nonce` / `X-WP-Nonce` header for REST.
|
|
||||||
- REST: `permission_callback` checks nonce and capability.
|
|
||||||
|
|
||||||
## 5. AuthZ
|
|
||||||
|
|
||||||
- Admin screens: `current_user_can('manage_options')` or `manage_woocommerce` where appropriate (documented per screen).
|
|
||||||
- Q&A: `edit_posts` for moderation; user can delete own question via `delete_post` cap check.
|
|
||||||
- Banners: `manage_options`.
|
|
||||||
|
|
||||||
## 6. XSS
|
|
||||||
|
|
||||||
- No inline `onclick` with untrusted data. No `innerHTML` with unsanitized server data – DOM via `textContent` or sanitized fragment.
|
|
||||||
- Stored XSS: banner CTA/link, Q&A body – sanitized on save, escaped on render.
|
|
||||||
|
|
||||||
## 7. SQLi
|
|
||||||
|
|
||||||
- No raw SQL unless via `$wpdb->prepare`. Prefer `WP_Query`/`WC` CRUD. No string-concatenated queries.
|
|
||||||
|
|
||||||
## 8. Special Cases
|
|
||||||
|
|
||||||
- **File uploads**: use `wp_handle_upload` + `wp_check_filetype`, no arbitrary file execution, no path traversal (`sanitize_file_name`, `realpath` check).
|
|
||||||
- **Redirects**: `wp_safe_redirect` + `wp_validate_redirect` allow-list.
|
|
||||||
- **Unserialize**: never `unserialize` user data; use JSON.
|
|
||||||
- **Secrets**: no inline secrets; no committed `.env`.
|
|
||||||
- **Remote import**: validate MIME, size cap, timeout, no SSRF (allow-list demo asset host if any).
|
|
||||||
|
|
||||||
## 9. Headers (theme-level)
|
|
||||||
|
|
||||||
- Theme does not override server headers; document recommended headers for host (CSP, etc.) in `docs/security.md`.
|
|
||||||
|
|
||||||
## 10. Checklist per Feature
|
|
||||||
|
|
||||||
- [ ] Inputs sanitized + validated
|
|
||||||
- [ ] Nonce verified
|
|
||||||
- [ ] Capability checked
|
|
||||||
- [ ] Outputs escaped
|
|
||||||
- [ ] No raw SQL
|
|
||||||
- [ ] No unsafe redirect/unserialize/upload
|
|
||||||
|
|
||||||
## 11. Search Security Review (M3 — 2026-09-13)
|
|
||||||
|
|
||||||
- **Endpoint**: `GET /wp-json/xshop/v1/search` `permission_callback __return_true` — public read-only, no auth needed (visitors must search). No private data: only `id/title/url/image/price_html/type/in_stock` from published products; no `post_password`, no customer/order/user, no draft/private (`post_status=publish` enforced), no internal meta beyond `_sku` LIKE. Verified via direct REST: draft products never returned.
|
|
||||||
- **Sanitization**: `search` via `sanitize_text_field` + `wp_strip_all_tags` + `mb_substr 100` + `trim`; `limit` via `absint` 1..20; bounded length prevents DOS via huge query.
|
|
||||||
- **Validation**: `search` max 100 chars, `limit` validate 1..20 else 400 (WP REST validation). Minimum length 2 enforced in handler returns empty 200 without DB query.
|
|
||||||
- **Escaping**: `title` via `html_entity_decode` + JS `textContent`, `url` via `get_permalink` + `esc_url` in handler `price_html` from Woo (trusted but passed as HTML; frontend injects via `innerHTML` after ensuring no script — price_html contains only Woo spans, no user input).
|
|
||||||
- **Abuse**: public endpoint can be crawled; mitigated via `Cache-Control: public, max-age=60`, limited `posts_per_page` (`limit*2` max 40), `no_found_rows:true`, no full table scan (indexed `s` + `meta_query` on `_sku` with LIKE). No N+1 (IDs then single `wc_get_product` per result, max 6). Recommend rate limiting at reverse proxy for high traffic.
|
|
||||||
- **XSS**: `search` never reflected unescaped; response JSON; frontend uses `textContent` for title.
|
|
||||||
|
|
||||||
## 12. Review Process
|
|
||||||
|
|
||||||
- PHPCS `WordPress.Security` + manual review before each release.
|
|
||||||
- Security note in CHANGELOG.md.
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# XShop – Testing
|
|
||||||
|
|
||||||
## 1. Levels
|
|
||||||
|
|
||||||
| Level | Tool | Scope |
|
|
||||||
|-------|------|-------|
|
|
||||||
| PHP lint | `php -l` | Every PHP file on build |
|
|
||||||
| PHPCS | `WordPress` standard | Security & WPCS violations |
|
|
||||||
| PHPStan | level 6 | Core plugin classes |
|
|
||||||
| JS lint | ESLint | Assets |
|
|
||||||
| Manual | Checklist | Features per Definition of Done |
|
|
||||||
|
|
||||||
## 2. Manual Matrix (must pass before 1.0.0)
|
|
||||||
|
|
||||||
- Activation/deactivation (theme + plugin), WC integration, products (simple/variable), cart/checkout/account, search/filter, wishlist/compare, Q&A, mobile menu, mega menu, RTL, LTR, dark mode, Elementor, Gutenberg.
|
|
||||||
|
|
||||||
### M1 Gate (foundation) — manual QA performed 2026-09-13
|
|
||||||
|
|
||||||
| Check | Result |
|
|
||||||
|-------|--------|
|
|
||||||
| `php -l` on all `xshop-theme/**/*.php` (PHP 8.2.12) | ✅ Pass (24 files incl. `inc/setup/body-classes.php:1`) |
|
|
||||||
| `node --check assets/js/theme.js` | ✅ Pass |
|
|
||||||
| No `TODO/FIXME/console.log/var_dump/print_r/lorem ipsum` in theme | ✅ Pass (heuristic scan) |
|
|
||||||
| Text domain `xshop` on all translatable strings | ✅ Pass |
|
|
||||||
| `xshop_` prefix for theme functions | ✅ Pass |
|
|
||||||
| `wp_head/wp_footer/wp_body_open/body_class/post_class/language_attributes/wp_nav_menu/dynamic_sidebar/comments_template/wp_link_pages/paginate_links` present | ✅ All present |
|
|
||||||
| No direct unsafe `echo $raw` | ✅ Pass (all `echo` via `esc_*`/`wp_kses_post`/`get_the_post_thumbnail`) |
|
|
||||||
| Header: skip link, `aria-expanded`/`aria-controls`, focus trap, Esc, click-outside, keyboard nav | ✅ `header.php:1` + `site-header.php:1` + `theme.js:1` |
|
|
||||||
| Footer: widgets, nav, copyright, accessible | ✅ `site-footer.php:1` |
|
|
||||||
| Templates: no-posts, long titles, missing thumb/excerpt, password protected, empty search, 404 recent posts | ✅ All covered |
|
|
||||||
| RTL/LTR: zero `left:/right:` in CSS, logical props, breadcrumbs/pagination `is_rtl()` flip | ✅ Pass |
|
|
||||||
| Woo active/inactive: wrappers, body classes `xshop--has-woo`, no fatal when inactive | ✅ `inc/woocommerce/setup.php:1` + `inc/helpers/template.php:1` guards |
|
|
||||||
| Dark mode tokens | ✅ `tokens.css:1` full coverage + `header.php:1` `data-theme` + `body-classes.php:1` |
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
### M3 Gate (AJAX Search) — manual QA performed 2026-09-13 (WP 7.1 / WC 11.1.0 / PHP 8.2.12)
|
|
||||||
|
|
||||||
| Check | Result |
|
|
||||||
|-------|--------|
|
|
||||||
| `GET /wp-json/xshop/v1/search?search=Restore&limit=6` → `200 {items:[{id,title,url,image,price_html,type,in_stock}],total:1}` | ✅ Pass |
|
|
||||||
| Persian `search=لپ` → sale product 22, `search=Restore` → 21, `search=XSHOP-SKU-123` SKU → 21, `limit` 1..20 validated (400 if >20), `search` >100 → 400, min length 2 → empty 200 | ✅ Pass |
|
|
||||||
| XSS `search=<script>` → 0 items, no private data (`post_password` etc) | ✅ Pass |
|
|
||||||
| Woo inactive fallback → posts (2 Hello) with `type post` | ✅ Pass |
|
|
||||||
| UI: `ajax-search.php` markup `data-xshop-search` `role=combobox` `aria-expanded/controls` `listbox` + hidden `post_type product` | ✅ Pass |
|
|
||||||
| JS: `search.js` debounce 250ms, `AbortController`, stale `seq`, dedupe `lastQuery`, loading/empty/error/view-all `?s=&post_type=product`, `Escape` `ArrowUp/Down` `Enter` `click-outside`, `aria-activedescendant` | ✅ Pass |
|
|
||||||
| Accessibility: `aria-expanded`, `listbox`, `aria-selected`, `aria-live polite`, focus stays, keyboard nav | ✅ Pass |
|
|
||||||
| RTL/LTR/dark/mobile: logical `woo.css` search dropdown (`fixed @640`), dark tokens, no overflow | ✅ Pass |
|
|
||||||
| No `console.log` / `php -l` clean / `debug.log` clean | ✅ Pass |
|
|
||||||
|
|
||||||
### M4 Gate (AJAX Filter) — manual QA 2026-09-13 (WP 7.1 / WC 11.1.0)
|
|
||||||
|
|
||||||
| Check | Result |
|
|
||||||
|-------|--------|
|
|
||||||
| `GET /wp-json/xshop/v1/products?per_page=12` no filter → 4 items | ✅ Pass |
|
|
||||||
| `category=uncategorized` →4, `min_price 100k max 200k` →1, `stock instock 3` `outofstock 1`, `on_sale 1` →1, `rating 4` →0, `orderby price_asc/desc` → sorted, `page 2 per_page 2` →2, `search Test`+category+sale →1, `min>max`→400, no private data | ✅ Pass |
|
|
||||||
| UI: `product-filters.php` `data-xshop-filters` `chips` `price debounce` `sale/stock/rating` `orderby` `Apply/Clear` `drawer dialog` `aria-modal` | ✅ Pass |
|
|
||||||
| JS: `filter.js` URL sync `history.pushState` `popstate` back/forward, `AbortController` stale `seq`, `debounce 300` price, `click outside` `Escape` focus trap, `aria-busy` `aria-live` | ✅ Pass |
|
|
||||||
| No-JS fallback `GET /shop/?stock=instock` → server `xshop-products` **PASS**, `?on_sale=1` **PASS** | ✅ Pass |
|
|
||||||
| Browser E2E (Playwright): category→ chips → remove → clear, sale→ grid, price, stock, sorting, pagination, rapid, back/forward, drawer open/close, search+filter coexist, no duplicate, no stale, no overflow 360/390/768/1024/1280 | ✅ Pass (duplicate form fixed) |
|
|
||||||
| RTL `fa_IR` `dir=rtl` `xshop--rtl` + dark `data-theme` + mobile 390 drawer | ✅ Pass |
|
|
||||||
| No `console.log` / `php -l` `filter.js` / `debug.log` clean | ✅ Pass |
|
|
||||||
|
|
||||||
## 3. Browser
|
|
||||||
|
|
||||||
Chromium, Firefox, Edge, Safari (where available). Responsive: 360/768/1024/1280.
|
|
||||||
|
|
||||||
## 4. Accessibility Checks
|
|
||||||
|
|
||||||
Keyboard nav, focus trap (modal/drawer), visible focus, semantic HTML, labels, screen reader (NVDA/VoiceOver spot), contrast, `prefers-reduced-motion`.
|
|
||||||
|
|
||||||
## 5. Performance Smoke
|
|
||||||
|
|
||||||
Lighthouse home/shop/product ≥ 85 perf (desktop). Query count via Query Monitor.
|
|
||||||
|
|
||||||
## 6. How to Run (local)
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
C:\xampp\php\php.exe -l xshop-theme\functions.php
|
|
||||||
# PHPCS (when installed)
|
|
||||||
vendor\bin\phpcs --standard=WordPress xshop-theme xshop-core
|
|
||||||
vendor\bin\phpstan analyse --level 6 xshop-core\includes
|
|
||||||
npx eslint assets\js --ext .js
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Release Gate
|
|
||||||
|
|
||||||
No PHP warnings/notices, no console errors, no PHPCS security errors.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# XShop – UI Specification
|
|
||||||
|
|
||||||
## 1. Design Principles
|
|
||||||
|
|
||||||
- Persian-first, minimal, high contrast, generous whitespace, card-based.
|
|
||||||
- Mobile-first, thumb-friendly, logical motion (150–250ms), no gratuitous animation.
|
|
||||||
- Original design system (not a clone of reference).
|
|
||||||
|
|
||||||
## 2. Tokens
|
|
||||||
|
|
||||||
Defined in `xshop-theme/assets/css/tokens.css` via CSS custom properties. **M1 finalizes tokens** — full coverage for dark mode and components:
|
|
||||||
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
--xshop-primary: #0ea5e9; --xshop-primary-600: #0284c7; --xshop-primary-700: #0369a1;
|
|
||||||
--xshop-secondary: #111827;
|
|
||||||
--xshop-bg: #ffffff; --xshop-surface: #f8fafc; --xshop-surface-2: #f1f5f9; --xshop-card: #ffffff;
|
|
||||||
--xshop-text: #0f172a; --xshop-muted: #64748b; --xshop-border: #e2e8f0;
|
|
||||||
--xshop-input-bg: #ffffff; --xshop-input-border: #cbd5e1; --xshop-input-text: #0f172a;
|
|
||||||
--xshop-focus: #0ea5e9; --xshop-link: #0ea5e9; --xshop-link-hover: #0284c7;
|
|
||||||
--xshop-success: #16a34a; --xshop-success-bg: #f0fdf4; --xshop-warning: #f59e0b; --xshop-warning-bg: #fffbeb; --xshop-danger: #dc2626; --xshop-danger-bg: #fef2f2;
|
|
||||||
--xshop-font-sans: system-ui, -apple-system, "Vazirmatn", "IRANSans", ...;
|
|
||||||
--xshop-text-xs: 0.75rem; --xshop-text-sm: 0.875rem; --xshop-text-base: 1rem; --xshop-text-lg: 1.125rem; --xshop-text-xl: 1.25rem; --xshop-text-2xl: 1.5rem; --xshop-text-3xl: 1.875rem; --xshop-text-4xl: 2.25rem;
|
|
||||||
--xshop-weight-regular: 400; --xshop-weight-medium: 500; --xshop-weight-bold: 700;
|
|
||||||
--xshop-leading-tight: 1.25; --xshop-leading: 1.6; --xshop-leading-relaxed: 1.75;
|
|
||||||
--xshop-space-1: 4px; ... --xshop-space-16: 64px; --xshop-container: 1280px; --xshop-container-narrow: 720px;
|
|
||||||
--xshop-radius-sm: 8px; --xshop-radius-md: 14px; --xshop-radius-lg: 22px; --xshop-radius-full: 9999px;
|
|
||||||
--xshop-shadow-sm: 0 1px 2px rgba(0,0,0,.06); --xshop-shadow-md: 0 8px 24px rgba(0,0,0,.08); --xshop-shadow-lg: 0 16px 40px rgba(0,0,0,.12);
|
|
||||||
--xshop-z-header: 40; --xshop-z-overlay: 50; --xshop-z-modal: 60;
|
|
||||||
--xshop-transition-fast: 120ms ease; --xshop-transition: 180ms ease; --xshop-transition-slow: 280ms ease;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] { /* semantic swap, not inversion — covers bg/surface/card/text/muted/border/input/link/notice bg */ }
|
|
||||||
@media (prefers-reduced-motion: reduce) { :root { --xshop-transition: 0ms; } }
|
|
||||||
```
|
|
||||||
|
|
||||||
Typography: system-first + optional Vazirmatn. Scale 12/14/16/18/20/24/30/36, weights 400/500/700, line heights 1.25–1.75.
|
|
||||||
|
|
||||||
Breakpoints: 360 (xs), 640 (sm), 768 (md), 1024 (lg), 1280 (xl), 1536 (2xl). Container `--xshop-container` + `--xshop-container-narrow`.
|
|
||||||
|
|
||||||
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**: `site-header`, `site-footer`, `breadcrumbs`, `pagination`, `search-form`, `post-card`, `empty-state`, `loading`, `section-heading`, `hero`, `promo-banner`. **M2**: `product-card`/`product-grid`/`product-gallery`/`mini-cart`. **M3**: `ajax-search.php:1`. **M4 adds**: `product-filters.php:1` (`data-xshop-filters` `chips` `form method=get` no-JS fallback, `category radio` `pa_* checkbox` `price number` `stock radio` `sale checkbox` `rating radio` `orderby select`, `data-filter` + `data-tax`, drawer `role=dialog` `aria-modal`).
|
|
||||||
|
|
||||||
Implemented details:
|
|
||||||
- `ajax-search.php` — as M3
|
|
||||||
- `product-filters.php` — `chips` `aria-live`, `price debounce`, `stock/sale/rating` radios, `orderby`, `Apply/Clear all`, hidden `s` preserve, category context, RTL logical, dark tokens
|
|
||||||
- `product-card` etc — as M2
|
|
||||||
|
|
||||||
States: default/hover/focus-visible/active/disabled/loading. Focus ring via `:focus-visible` (token `--xshop-focus`).
|
|
||||||
|
|
||||||
## 4. Layouts
|
|
||||||
|
|
||||||
- **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
|
|
||||||
|
|
||||||
Transitions `var(--xshop-transition)`. Radius & shadows via tokens. No layout shift on hover.
|
|
||||||
|
|
||||||
## 6. Icons
|
|
||||||
|
|
||||||
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 (M2)
|
|
||||||
|
|
||||||
`[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 (M4)
|
|
||||||
|
|
||||||
- Search (M3): `role=search` `combobox` + `aria-expanded/controls/activedescendant` + `listbox` `aria-live`, ArrowUp/Down `aria-selected`, Enter/Escape/click-outside.
|
|
||||||
- Filters (M4): `fieldset/legend`, `label` for all inputs, `aria-expanded/controls` for drawer `role=dialog` `aria-modal`, `Escape` focus return to toggle, `aria-busy` on grid, `aria-live` chips + result count, keyboard Tab/Enter/Space.
|
|
||||||
- General: landmarks, skip link, trap focus, `:focus-visible`, contrast AA, `prefers-reduced-motion`.
|
|
||||||
|
|
||||||
## 9. RTL/LTR
|
|
||||||
|
|
||||||
- Logical properties: `margin-inline`, `padding-inline`, `inset-inline`.
|
|
||||||
- Carousel/breadcrumb/pagination directions follow `dir`.
|
|
||||||
- Mixed Persian/English: numbers stay LTR via `unicode-bidi: plaintext` where needed.
|
|
||||||
|
|
||||||
## 10. Reference Inspiration (not cloning)
|
|
||||||
|
|
||||||
From reference analysis: deep mega-menu, promotional story strip, category icon grid, flash-sale carousel, banner pairs, WC-like product cards with discount badge – XShop reinterprets with its own spacing/typography/radius system.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Build release zips for XShop (PowerShell 5.1)
|
|
||||||
param(
|
|
||||||
[string]$Version = "1.0.0"
|
|
||||||
)
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
$root = Split-Path -Parent $PSScriptRoot
|
|
||||||
$outDir = Join-Path $root "release"
|
|
||||||
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
|
|
||||||
|
|
||||||
function New-ThemeZip {
|
|
||||||
param([string]$Src, [string]$Dest)
|
|
||||||
if (Test-Path $Dest) { Remove-Item -LiteralPath $Dest -Force }
|
|
||||||
Add-Type -AssemblyName System.IO.Compression
|
|
||||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
||||||
$tmp = Join-Path $env:TEMP ("xshop-build-" + [Guid]::NewGuid())
|
|
||||||
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
|
||||||
Copy-Item -Recurse -Force -Path $Src -Destination (Join-Path $tmp "xshop")
|
|
||||||
# Strip dev artifacts if present
|
|
||||||
@("node_modules",".git",".cache") | ForEach-Object {
|
|
||||||
$p = Join-Path $tmp ("xshop\" + $_)
|
|
||||||
if (Test-Path $p) { Remove-Item -Recurse -Force -LiteralPath $p }
|
|
||||||
}
|
|
||||||
# Include top-level folder `xshop` inside zip (WP expects folder wrapper)
|
|
||||||
[System.IO.Compression.ZipFile]::CreateFromDirectory($tmp, $Dest, [System.IO.Compression.CompressionLevel]::Optimal, $false)
|
|
||||||
Remove-Item -Recurse -Force -LiteralPath $tmp
|
|
||||||
Write-Host "Built $Dest"
|
|
||||||
}
|
|
||||||
function New-PluginZip {
|
|
||||||
param([string]$Src, [string]$Dest)
|
|
||||||
if (Test-Path $Dest) { Remove-Item -LiteralPath $Dest -Force }
|
|
||||||
$tmp = Join-Path $env:TEMP ("xshop-core-build-" + [Guid]::NewGuid())
|
|
||||||
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
|
||||||
Copy-Item -Recurse -Force -Path $Src -Destination (Join-Path $tmp "xshop-core")
|
|
||||||
[System.IO.Compression.ZipFile]::CreateFromDirectory($tmp, $Dest, [System.IO.Compression.CompressionLevel]::Optimal, $false)
|
|
||||||
Remove-Item -Recurse -Force -LiteralPath $tmp
|
|
||||||
Write-Host "Built $Dest"
|
|
||||||
}
|
|
||||||
|
|
||||||
New-ThemeZip -Src (Join-Path $root "xshop-theme") -Dest (Join-Path $outDir "xshop.zip")
|
|
||||||
New-PluginZip -Src (Join-Path $root "xshop-core") -Dest (Join-Path $outDir "xshop-core.zip")
|
|
||||||
Write-Host "Done. Version $Version"
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace XShop\Core;
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
final class Plugin {
|
|
||||||
|
|
||||||
private static ?self $instance = null;
|
|
||||||
|
|
||||||
public static function instance(): self {
|
|
||||||
if (self::$instance === null) {
|
|
||||||
self::$instance = new self();
|
|
||||||
}
|
|
||||||
return self::$instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function init(): void {
|
|
||||||
// Core modules are loaded lazily; v1 wiring is scaffolded.
|
|
||||||
// Each module file guards with `class_exists` checks.
|
|
||||||
$this->loadModules();
|
|
||||||
add_action('init', [$this, 'registerCpts']);
|
|
||||||
add_action('admin_menu', [$this, 'registerAdminMenu']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function loadModules(): void {
|
|
||||||
$mods = [
|
|
||||||
'Query/ProductQuery.php',
|
|
||||||
'REST/Search.php',
|
|
||||||
'REST/Products.php',
|
|
||||||
'Modules/Banners/CPT.php',
|
|
||||||
'Modules/QA/CPT.php',
|
|
||||||
];
|
|
||||||
foreach ($mods as $rel) {
|
|
||||||
$path = XSHOP_CORE_DIR . '/includes/' . $rel;
|
|
||||||
if (file_exists($path)) {
|
|
||||||
require_once $path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function registerCpts(): void {
|
|
||||||
do_action('xshop_core_register_cpts');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function registerAdminMenu(): void {
|
|
||||||
if (!current_user_can('manage_options')) { return; }
|
|
||||||
add_menu_page(
|
|
||||||
esc_html__('XShop', 'xshop'),
|
|
||||||
esc_html__('XShop', 'xshop'),
|
|
||||||
'manage_options',
|
|
||||||
'xshop',
|
|
||||||
[$this, 'renderDashboard'],
|
|
||||||
'dashicons-store',
|
|
||||||
58
|
|
||||||
);
|
|
||||||
// Submenus added by modules via `xshop_core_admin_submenus` hook in M4.
|
|
||||||
}
|
|
||||||
|
|
||||||
public function renderDashboard(): void {
|
|
||||||
if (!current_user_can('manage_options')) { return; }
|
|
||||||
echo '<div class="wrap"><h1>' . esc_html__('XShop — Dashboard', 'xshop') . '</h1>';
|
|
||||||
echo '<p>' . esc_html__('Welcome to XShop. Use the sidebar to manage theme features.', 'xshop') . '</p></div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function activate(): void {
|
|
||||||
// Flush rewrites for CPTs.
|
|
||||||
do_action('xshop_core_register_cpts');
|
|
||||||
flush_rewrite_rules();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function deactivate(): void {
|
|
||||||
flush_rewrite_rules();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace XShop\Core\Modules\Banners;
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
add_action('xshop_core_register_cpts', __NAMESPACE__ . '\\register_cpt');
|
|
||||||
add_action('add_meta_boxes', __NAMESPACE__ . '\\add_metabox');
|
|
||||||
add_action('save_post_xshop_banner', __NAMESPACE__ . '\\save_post', 10, 2);
|
|
||||||
|
|
||||||
function register_cpt(): void {
|
|
||||||
register_post_type('xshop_banner', [
|
|
||||||
'label' => esc_html__('Banners', 'xshop'),
|
|
||||||
'labels' => [
|
|
||||||
'name' => esc_html__('Banners', 'xshop'),
|
|
||||||
'singular_name' => esc_html__('Banner', 'xshop'),
|
|
||||||
'add_new_item' => esc_html__('Add New Banner', 'xshop'),
|
|
||||||
],
|
|
||||||
'public' => false,
|
|
||||||
'show_ui' => true,
|
|
||||||
'show_in_menu' => 'xshop',
|
|
||||||
'show_in_rest' => false,
|
|
||||||
'supports' => ['title'],
|
|
||||||
'capability_type' => 'post',
|
|
||||||
'map_meta_cap' => true,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function add_metabox(): void {
|
|
||||||
add_meta_box('xshop_banner_meta', esc_html__('Banner Details', 'xshop'), __NAMESPACE__ . '\\render_metabox', 'xshop_banner', 'normal', 'high');
|
|
||||||
}
|
|
||||||
|
|
||||||
function render_metabox(\WP_Post $post): void {
|
|
||||||
if (!current_user_can('edit_post', $post->ID)) { return; }
|
|
||||||
wp_nonce_field('xshop_banner_save', 'xshop_banner_nonce');
|
|
||||||
$image = (int) get_post_meta($post->ID, '_xshop_banner_image', true);
|
|
||||||
$mobileImage = (int) get_post_meta($post->ID, '_xshop_banner_mobile_image', true);
|
|
||||||
$link = (string) get_post_meta($post->ID, '_xshop_banner_link', true);
|
|
||||||
$cta = (string) get_post_meta($post->ID, '_xshop_banner_cta', true);
|
|
||||||
$position = (string) get_post_meta($post->ID, '_xshop_banner_position', true);
|
|
||||||
$active = get_post_meta($post->ID, '_xshop_banner_active', true);
|
|
||||||
$active = $active === '' ? '1' : $active;
|
|
||||||
?>
|
|
||||||
<p>
|
|
||||||
<label for="xshop_banner_position"><?php esc_html_e('Position', 'xshop'); ?></label><br>
|
|
||||||
<select id="xshop_banner_position" name="xshop_banner_position">
|
|
||||||
<?php foreach (['home_hero','home_strip','shop_top','product_sidebar','footer_top'] as $opt): ?>
|
|
||||||
<option value="<?php echo esc_attr($opt); ?>" <?php selected($position ?: 'home_hero', $opt); ?>><?php echo esc_html($opt); ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<label for="xshop_banner_link"><?php esc_html_e('Link URL', 'xshop'); ?></label><br>
|
|
||||||
<input type="url" id="xshop_banner_link" name="xshop_banner_link" value="<?php echo esc_attr($link); ?>" class="large-text" />
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<label for="xshop_banner_cta"><?php esc_html_e('CTA Text', 'xshop'); ?></label><br>
|
|
||||||
<input type="text" id="xshop_banner_cta" name="xshop_banner_cta" value="<?php echo esc_attr($cta); ?>" class="large-text" />
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<label><?php esc_html_e('Image ID', 'xshop'); ?></label><br>
|
|
||||||
<input type="number" name="xshop_banner_image" value="<?php echo esc_attr((string) $image); ?>" min="0" />
|
|
||||||
<span class="description"><?php esc_html_e('Use Media Library attachment ID.', 'xshop'); ?></span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<label><?php esc_html_e('Mobile Image ID (optional)', 'xshop'); ?></label><br>
|
|
||||||
<input type="number" name="xshop_banner_mobile_image" value="<?php echo esc_attr((string) $mobileImage); ?>" min="0" />
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<label><input type="checkbox" name="xshop_banner_active" value="1" <?php checked($active, '1'); ?> /> <?php esc_html_e('Active', 'xshop'); ?></label>
|
|
||||||
</p>
|
|
||||||
<?php
|
|
||||||
}
|
|
||||||
|
|
||||||
function save_post(int $postId, \WP_Post $post): void {
|
|
||||||
if (!isset($_POST['xshop_banner_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['xshop_banner_nonce'])), 'xshop_banner_save')) { return; }
|
|
||||||
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) { return; }
|
|
||||||
if (!current_user_can('edit_post', $postId)) { return; }
|
|
||||||
|
|
||||||
$position = isset($_POST['xshop_banner_position']) ? sanitize_key(wp_unslash($_POST['xshop_banner_position'])) : 'home_hero';
|
|
||||||
$allowed = ['home_hero','home_strip','shop_top','product_sidebar','footer_top'];
|
|
||||||
if (!in_array($position, $allowed, true)) { $position = 'home_hero'; }
|
|
||||||
update_post_meta($postId, '_xshop_banner_position', $position);
|
|
||||||
update_post_meta($postId, '_xshop_banner_link', isset($_POST['xshop_banner_link']) ? esc_url_raw(wp_unslash($_POST['xshop_banner_link'])) : '');
|
|
||||||
update_post_meta($postId, '_xshop_banner_cta', isset($_POST['xshop_banner_cta']) ? sanitize_text_field(wp_unslash($_POST['xshop_banner_cta'])) : '');
|
|
||||||
update_post_meta($postId, '_xshop_banner_image', isset($_POST['xshop_banner_image']) ? absint($_POST['xshop_banner_image']) : 0);
|
|
||||||
update_post_meta($postId, '_xshop_banner_mobile_image', isset($_POST['xshop_banner_mobile_image']) ? absint($_POST['xshop_banner_mobile_image']) : 0);
|
|
||||||
update_post_meta($postId, '_xshop_banner_active', isset($_POST['xshop_banner_active']) ? '1' : '0');
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace XShop\Core\Modules\QA;
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
add_action('xshop_core_register_cpts', __NAMESPACE__ . '\\register_cpt');
|
|
||||||
|
|
||||||
function register_cpt(): void {
|
|
||||||
register_post_type('xshop_qa', [
|
|
||||||
'label' => esc_html__('Questions & Answers', 'xshop'),
|
|
||||||
'labels' => [
|
|
||||||
'name' => esc_html__('Questions & Answers', 'xshop'),
|
|
||||||
'singular_name' => esc_html__('Question', 'xshop'),
|
|
||||||
],
|
|
||||||
'public' => false,
|
|
||||||
'show_ui' => true,
|
|
||||||
'show_in_menu' => 'xshop',
|
|
||||||
'supports' => ['title', 'editor', 'author'],
|
|
||||||
'capability_type' => 'post',
|
|
||||||
'map_meta_cap' => true,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* XShop ProductQuery — reusable composable Woo product query layer.
|
|
||||||
*
|
|
||||||
* Used by REST search (M3) and products filtering (M4). Keeps query logic in one place.
|
|
||||||
*
|
|
||||||
* @package XShop\Core\Query
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace XShop\Core\Query;
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize common search string.
|
|
||||||
*/
|
|
||||||
function sanitize_search_param($value): string {
|
|
||||||
$value = is_string($value) ? $value : '';
|
|
||||||
$value = trim(\wp_strip_all_tags($value));
|
|
||||||
if (\mb_strlen($value) > 100) {
|
|
||||||
$value = \mb_substr($value, 0, 100);
|
|
||||||
}
|
|
||||||
return \sanitize_text_field($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map frontend orderby to safe internal args.
|
|
||||||
*
|
|
||||||
* @param string $orderby
|
|
||||||
* @return array{orderby:string,order:string,meta_key:string}
|
|
||||||
*/
|
|
||||||
function map_orderby(string $orderby): array {
|
|
||||||
$map = [
|
|
||||||
'default' => ['orderby' => 'menu_order', 'order' => 'ASC', 'meta_key' => ''],
|
|
||||||
'latest' => ['orderby' => 'date', 'order' => 'DESC', 'meta_key' => ''],
|
|
||||||
'price_asc' => ['orderby' => 'meta_value_num', 'order' => 'ASC', 'meta_key' => '_price'],
|
|
||||||
'price_desc' => ['orderby' => 'meta_value_num', 'order' => 'DESC','meta_key' => '_price'],
|
|
||||||
'price' => ['orderby' => 'meta_value_num', 'order' => 'ASC', 'meta_key' => '_price'],
|
|
||||||
'popularity' => ['orderby' => 'meta_value_num', 'order' => 'DESC','meta_key' => 'total_sales'],
|
|
||||||
'rating' => ['orderby' => 'meta_value_num', 'order' => 'DESC','meta_key' => '_wc_average_rating'],
|
|
||||||
'date' => ['orderby' => 'date', 'order' => 'DESC', 'meta_key' => ''],
|
|
||||||
];
|
|
||||||
$key = strtolower(trim($orderby));
|
|
||||||
if ($key === '') { $key = 'default'; }
|
|
||||||
// Normalize aliases
|
|
||||||
if ($key === 'price_asc' || $key === 'price-asc') { $key = 'price_asc'; }
|
|
||||||
if ($key === 'price_desc' || $key === 'price-desc') { $key = 'price_desc'; }
|
|
||||||
return $map[$key] ?? $map['default'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build WP_Query args for product filtering, composable.
|
|
||||||
*
|
|
||||||
* All params already sanitized by REST layer; this function composes safely.
|
|
||||||
*
|
|
||||||
* @param array $params {
|
|
||||||
* @type string $search Keyword (title/content/SKU)
|
|
||||||
* @type string $category Slug or ID
|
|
||||||
* @type array $attributes Map slug => term slug(s) e.g. ['pa_color'=>['black']]
|
|
||||||
* @type float|null $min_price
|
|
||||||
* @type float|null $max_price
|
|
||||||
* @type string $stock all|instock|outofstock
|
|
||||||
* @type bool $on_sale
|
|
||||||
* @type int $rating 1-5 minimum
|
|
||||||
* @type string $orderby default|latest|price_asc|price_desc|popularity|rating|date
|
|
||||||
* @type string $order ASC|DESC (derived from orderby)
|
|
||||||
* @type int $page
|
|
||||||
* @type int $per_page
|
|
||||||
* }
|
|
||||||
* @return array WP_Query args
|
|
||||||
*/
|
|
||||||
function build_product_query_args(array $params): array {
|
|
||||||
$perPage = isset($params['per_page']) ? (int) $params['per_page'] : 12;
|
|
||||||
$perPage = max(1, min(24, $perPage));
|
|
||||||
$page = isset($params['page']) ? max(1, (int) $params['page']) : 1;
|
|
||||||
|
|
||||||
$orderby = sanitize_text_field((string) ($params['orderby'] ?? 'default'));
|
|
||||||
$orderMap = map_orderby($orderby);
|
|
||||||
|
|
||||||
$args = [
|
|
||||||
'post_type' => 'product',
|
|
||||||
'post_status' => 'publish',
|
|
||||||
'posts_per_page' => $perPage,
|
|
||||||
'paged' => $page,
|
|
||||||
'orderby' => $orderMap['orderby'],
|
|
||||||
'order' => $orderMap['order'],
|
|
||||||
'no_found_rows' => false, // need total for pagination
|
|
||||||
'fields' => 'ids',
|
|
||||||
];
|
|
||||||
if ($orderMap['meta_key'] !== '') {
|
|
||||||
$args['meta_key'] = $orderMap['meta_key'];
|
|
||||||
}
|
|
||||||
// Sorting via meta_value_num needs meta query? Woo uses meta_key directly; keep simple.
|
|
||||||
|
|
||||||
// Search keyword (title/content) + SKU handled as merging two queries? For filters we support keyword as `s` directly here (s handles title/content). SKU handled via separate meta_query OR via s already? We merge SKU via tax? Instead we add meta_query OR for SKU if search provided and we want SKU search. For simplicity, use `s` for keyword and also add SKU OR via meta_query with relation OR? But WP_Query s + meta_query with OR is complex. We handle SKU as additional IDs merging after query, similar to M3, but to keep single query path for filters, we instead use `s` only (title/content) for composable filters; SKU search is kept in dedicated search endpoint (M3). For products endpoint, search param is keyword on title/content only. Documented.
|
|
||||||
$search = isset($params['search']) ? sanitize_search_param((string) $params['search']) : '';
|
|
||||||
if ($search !== '' && \mb_strlen($search) >= 2) {
|
|
||||||
$args['s'] = $search;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category
|
|
||||||
$category = isset($params['category']) ? sanitize_text_field((string) $params['category']) : '';
|
|
||||||
if ($category !== '' && $category !== 'all') {
|
|
||||||
// Support slug or ID
|
|
||||||
if (is_numeric($category)) {
|
|
||||||
$args['tax_query'][] = [
|
|
||||||
'taxonomy' => 'product_cat',
|
|
||||||
'field' => 'term_id',
|
|
||||||
'terms' => [(int) $category],
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
$args['tax_query'][] = [
|
|
||||||
'taxonomy' => 'product_cat',
|
|
||||||
'field' => 'slug',
|
|
||||||
'terms' => [sanitize_title($category)],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attributes — dynamic: keys like pa_color, pa_size, or custom attribute taxonomy; values are term slugs
|
|
||||||
$attributes = $params['attributes'] ?? [];
|
|
||||||
if (is_array($attributes) && !empty($attributes)) {
|
|
||||||
$attrTaxQuery = [];
|
|
||||||
foreach ($attributes as $tax => $terms) {
|
|
||||||
$tax = sanitize_key((string) $tax);
|
|
||||||
if ($tax === '') { continue; }
|
|
||||||
// Only allow registered attribute taxonomies or product_cat/tag already handled
|
|
||||||
// We allow any pa_* taxonomy that exists, or custom taxonomies that are product attributes
|
|
||||||
$taxonomies = \get_taxonomies(['public' => true]);
|
|
||||||
// Allow pa_* even if not public? Woo registers them as public false but visible. So check existence via taxonomy_exists
|
|
||||||
if (!\taxonomy_exists($tax) && !str_starts_with($tax, 'pa_')) { continue; }
|
|
||||||
if (is_string($terms)) { $terms = [$terms]; }
|
|
||||||
if (!is_array($terms)) { continue; }
|
|
||||||
$cleanTerms = [];
|
|
||||||
foreach ($terms as $t) {
|
|
||||||
$t = sanitize_title((string) $t);
|
|
||||||
if ($t !== '') { $cleanTerms[] = $t; }
|
|
||||||
}
|
|
||||||
if (empty($cleanTerms)) { continue; }
|
|
||||||
$attrTaxQuery[] = [
|
|
||||||
'taxonomy' => $tax,
|
|
||||||
'field' => 'slug',
|
|
||||||
'terms' => $cleanTerms,
|
|
||||||
'operator' => 'IN',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (!empty($attrTaxQuery)) {
|
|
||||||
// Each attribute is AND, terms within same attribute are IN
|
|
||||||
foreach ($attrTaxQuery as $q) {
|
|
||||||
$args['tax_query'][] = $q;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Price
|
|
||||||
$minPrice = isset($params['min_price']) ? $params['min_price'] : null;
|
|
||||||
$maxPrice = isset($params['max_price']) ? $params['max_price'] : null;
|
|
||||||
$metaQueries = [];
|
|
||||||
if ($minPrice !== null && $minPrice !== '') {
|
|
||||||
$min = (float) $minPrice;
|
|
||||||
if ($min >= 0 && $min <= 99999999) {
|
|
||||||
$metaQueries[] = [
|
|
||||||
'key' => '_price',
|
|
||||||
'value' => $min,
|
|
||||||
'compare' => '>=',
|
|
||||||
'type' => 'DECIMAL(10,2)',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($maxPrice !== null && $maxPrice !== '') {
|
|
||||||
$max = (float) $maxPrice;
|
|
||||||
if ($max >= 0 && $max <= 99999999) {
|
|
||||||
$metaQueries[] = [
|
|
||||||
'key' => '_price',
|
|
||||||
'value' => $max,
|
|
||||||
'compare' => '<=',
|
|
||||||
'type' => 'DECIMAL(10,2)',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Stock handled separately via meta? Woo stock status is meta _stock_status, but better via tax? Use meta_query
|
|
||||||
$stock = isset($params['stock']) ? sanitize_key((string) $params['stock']) : 'all';
|
|
||||||
if ($stock === 'instock') {
|
|
||||||
$metaQueries[] = [
|
|
||||||
'key' => '_stock_status',
|
|
||||||
'value' => 'instock',
|
|
||||||
];
|
|
||||||
} elseif ($stock === 'outofstock') {
|
|
||||||
$metaQueries[] = [
|
|
||||||
'key' => '_stock_status',
|
|
||||||
'value' => 'outofstock',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$onSale = !empty($params['on_sale']);
|
|
||||||
if ($onSale) {
|
|
||||||
// Keep meta_query for simple products; variable on_sale will be handled via PHP filtering in REST layer (is_on_sale) to avoid WC lookup issues with stale transients
|
|
||||||
$metaQueries[] = [
|
|
||||||
'relation' => 'OR',
|
|
||||||
['key' => '_sale_price', 'value' => 0, 'compare' => '>', 'type' => 'DECIMAL(10,2)'],
|
|
||||||
['key' => '_min_variation_sale_price', 'value' => 0, 'compare' => '>', 'type' => 'DECIMAL(10,2)'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rating — Woo average rating stored in _wc_average_rating, filter via meta >= rating
|
|
||||||
$rating = isset($params['rating']) ? (int) $params['rating'] : 0;
|
|
||||||
if ($rating >= 1 && $rating <= 5) {
|
|
||||||
$metaQueries[] = [
|
|
||||||
'key' => '_wc_average_rating',
|
|
||||||
'value' => $rating,
|
|
||||||
'compare' => '>=',
|
|
||||||
'type' => 'DECIMAL(2,1)',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($metaQueries)) {
|
|
||||||
// If we added OR group for sale, we need to handle nesting. $metaQueries may contain OR group already.
|
|
||||||
// Ensure top-level relation is AND.
|
|
||||||
$hasOrGroup = false;
|
|
||||||
foreach ($metaQueries as $mq) {
|
|
||||||
if (isset($mq['relation']) && $mq['relation'] === 'OR') { $hasOrGroup = true; break; }
|
|
||||||
}
|
|
||||||
if ($hasOrGroup) {
|
|
||||||
// Top-level AND needs to wrap existing with relation AND
|
|
||||||
if (!isset($args['meta_query'])) { $args['meta_query'] = ['relation' => 'AND']; }
|
|
||||||
else { $args['meta_query']['relation'] = 'AND'; }
|
|
||||||
foreach ($metaQueries as $mq) { $args['meta_query'][] = $mq; }
|
|
||||||
} else {
|
|
||||||
$args['meta_query'] = $metaQueries;
|
|
||||||
if (count($metaQueries) > 1) { $args['meta_query']['relation'] = 'AND'; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tax query relation
|
|
||||||
if (isset($args['tax_query']) && is_array($args['tax_query']) && count($args['tax_query']) > 1) {
|
|
||||||
if (!isset($args['tax_query']['relation'])) {
|
|
||||||
$args['tax_query'] = array_merge(['relation' => 'AND'], $args['tax_query']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $args;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format product for minimal response — shared between search and products endpoints.
|
|
||||||
*/
|
|
||||||
function format_product(\WC_Product $product): array {
|
|
||||||
$pid = $product->get_id();
|
|
||||||
$imageId = $product->get_image_id();
|
|
||||||
$image = $imageId ? \wp_get_attachment_image_url($imageId, 'thumbnail') : '';
|
|
||||||
if (!$image) {
|
|
||||||
$image = \wc_placeholder_img_src('thumbnail') ?: '';
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
'id' => $pid,
|
|
||||||
'title' => \html_entity_decode($product->get_name(), ENT_QUOTES, 'UTF-8'),
|
|
||||||
'url' => \get_permalink($pid),
|
|
||||||
'image' => $image ?: '',
|
|
||||||
'price_html' => $product->get_price_html(),
|
|
||||||
'type' => $product->get_type(),
|
|
||||||
'in_stock' => $product->is_in_stock(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute query and return ids + totals.
|
|
||||||
*
|
|
||||||
* @param array $wpArgs WP_Query args from build_product_query_args
|
|
||||||
* @return array{ids:int[], total:int, total_pages:int}
|
|
||||||
*/
|
|
||||||
function execute_query(array $wpArgs): array {
|
|
||||||
$q = new \WP_Query($wpArgs);
|
|
||||||
$ids = array_map('intval', $q->posts);
|
|
||||||
$total = (int) $q->found_posts;
|
|
||||||
$perPage = (int) $wpArgs['posts_per_page'];
|
|
||||||
$totalPages = $perPage > 0 ? (int) ceil($total / $perPage) : 1;
|
|
||||||
\wp_reset_postdata();
|
|
||||||
return ['ids' => $ids, 'total' => $total, 'total_pages' => max(1, $totalPages)];
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* XShop REST Products — /xshop/v1/products
|
|
||||||
*
|
|
||||||
* AJAX filtering + sorting + pagination for shop. Shares ProductQuery with search.
|
|
||||||
*
|
|
||||||
* @package XShop\Core\REST
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace XShop\Core\REST;
|
|
||||||
|
|
||||||
use XShop\Core\Query as ProductQuery;
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
add_action('rest_api_init', __NAMESPACE__ . '\\register_products_route');
|
|
||||||
|
|
||||||
function register_products_route(): void {
|
|
||||||
register_rest_route('xshop/v1', '/products', [
|
|
||||||
'methods' => 'GET',
|
|
||||||
'callback' => __NAMESPACE__ . '\\handle_products',
|
|
||||||
'permission_callback' => '__return_true',
|
|
||||||
'args' => [
|
|
||||||
'search' => [
|
|
||||||
'required' => false,
|
|
||||||
'type' => 'string',
|
|
||||||
'sanitize_callback' => __NAMESPACE__ . '\\sanitize_search',
|
|
||||||
'validate_callback' => static fn($v) => is_string($v) && mb_strlen($v) <= 100,
|
|
||||||
],
|
|
||||||
'category' => [
|
|
||||||
'required' => false,
|
|
||||||
'type' => 'string',
|
|
||||||
'sanitize_callback' => 'sanitize_text_field',
|
|
||||||
'validate_callback' => static fn($v) => is_string($v) && mb_strlen($v) <= 100,
|
|
||||||
],
|
|
||||||
'min_price' => [
|
|
||||||
'required' => false,
|
|
||||||
'sanitize_callback' => __NAMESPACE__ . '\\sanitize_price',
|
|
||||||
'validate_callback' => __NAMESPACE__ . '\\validate_price',
|
|
||||||
],
|
|
||||||
'max_price' => [
|
|
||||||
'required' => false,
|
|
||||||
'sanitize_callback' => __NAMESPACE__ . '\\sanitize_price',
|
|
||||||
'validate_callback' => __NAMESPACE__ . '\\validate_price',
|
|
||||||
],
|
|
||||||
'stock' => [
|
|
||||||
'required' => false,
|
|
||||||
'type' => 'string',
|
|
||||||
'sanitize_callback' => 'sanitize_key',
|
|
||||||
'validate_callback' => static fn($v) => in_array($v, ['all','instock','outofstock'], true),
|
|
||||||
],
|
|
||||||
'on_sale' => [
|
|
||||||
'required' => false,
|
|
||||||
'type' => 'string',
|
|
||||||
'sanitize_callback' => __NAMESPACE__ . '\\sanitize_bool_string',
|
|
||||||
'validate_callback' => static fn($v) => in_array($v, ['0','1',''], true),
|
|
||||||
],
|
|
||||||
'sale' => [
|
|
||||||
'required' => false,
|
|
||||||
'type' => 'string',
|
|
||||||
'sanitize_callback' => __NAMESPACE__ . '\\sanitize_bool_string',
|
|
||||||
'validate_callback' => static fn($v) => in_array($v, ['0','1',''], true),
|
|
||||||
],
|
|
||||||
'rating' => [
|
|
||||||
'required' => false,
|
|
||||||
'type' => 'integer',
|
|
||||||
'sanitize_callback' => 'absint',
|
|
||||||
'validate_callback' => static fn($v) => in_array((int)$v, [0,1,2,3,4,5], true),
|
|
||||||
],
|
|
||||||
'orderby' => [
|
|
||||||
'required' => false,
|
|
||||||
'type' => 'string',
|
|
||||||
'sanitize_callback' => 'sanitize_key',
|
|
||||||
'validate_callback' => static fn($v) => in_array($v, ['default','latest','price_asc','price_desc','price','popularity','rating','date','menu_order'], true),
|
|
||||||
],
|
|
||||||
'order' => [
|
|
||||||
'required' => false,
|
|
||||||
'type' => 'string',
|
|
||||||
'sanitize_callback' => static fn($v) => strtoupper(sanitize_key((string)$v)) === 'DESC' ? 'DESC' : 'ASC',
|
|
||||||
],
|
|
||||||
'page' => [
|
|
||||||
'required' => false,
|
|
||||||
'type' => 'integer',
|
|
||||||
'default' => 1,
|
|
||||||
'sanitize_callback' => 'absint',
|
|
||||||
'validate_callback' => static fn($v) => (int)$v >= 1 && (int)$v <= 100,
|
|
||||||
],
|
|
||||||
'per_page' => [
|
|
||||||
'required' => false,
|
|
||||||
'type' => 'integer',
|
|
||||||
'default' => 12,
|
|
||||||
'sanitize_callback' => 'absint',
|
|
||||||
'validate_callback' => static fn($v) => (int)$v >= 1 && (int)$v <= 24,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitize_search($value): string {
|
|
||||||
return ProductQuery\sanitize_search_param($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitize_price($value): string {
|
|
||||||
if ($value === '' || $value === null) { return ''; }
|
|
||||||
$value = trim((string) $value);
|
|
||||||
// Allow numeric string, sanitize to float string
|
|
||||||
if (!is_numeric($value)) { return ''; }
|
|
||||||
$f = (float) $value;
|
|
||||||
if ($f < 0 || $f > 99999999) { return ''; }
|
|
||||||
return (string) $f;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validate_price($value, $request, $param): bool {
|
|
||||||
if ($value === '' || $value === null) { return true; }
|
|
||||||
if (!is_numeric($value)) { return false; }
|
|
||||||
$f = (float) $value;
|
|
||||||
return $f >= 0 && $f <= 99999999;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitize_bool_string($value): string {
|
|
||||||
if ($value === '1' || $value === 1 || $value === true || $value === 'true') { return '1'; }
|
|
||||||
return '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
function handle_products(\WP_REST_Request $request): \WP_REST_Response {
|
|
||||||
// Collect params, including dynamic attribute params pa_*
|
|
||||||
$search = sanitize_search((string) $request->get_param('search'));
|
|
||||||
// Search min length 0 for products endpoint (empty means no keyword filter). We allow empty search (unlike search endpoint min 2)
|
|
||||||
$category = sanitize_text_field((string) $request->get_param('category'));
|
|
||||||
$minPrice = sanitize_price($request->get_param('min_price'));
|
|
||||||
$maxPrice = sanitize_price($request->get_param('max_price'));
|
|
||||||
$stock = sanitize_key((string) ($request->get_param('stock') ?? 'all'));
|
|
||||||
$onSaleRaw = $request->get_param('on_sale');
|
|
||||||
if ($onSaleRaw === null) { $onSaleRaw = $request->get_param('sale'); }
|
|
||||||
$onSale = sanitize_bool_string($onSaleRaw) === '1';
|
|
||||||
$rating = absint($request->get_param('rating') ?? 0);
|
|
||||||
$orderby = sanitize_key((string) ($request->get_param('orderby') ?? 'default'));
|
|
||||||
$page = max(1, absint($request->get_param('page') ?? 1));
|
|
||||||
$perPage = max(1, min(24, absint($request->get_param('per_page') ?? 12)));
|
|
||||||
|
|
||||||
// Validate min <= max if both present
|
|
||||||
if ($minPrice !== '' && $maxPrice !== '' && (float)$minPrice > (float)$maxPrice) {
|
|
||||||
// Swap or return empty? Return empty result with validation error note (but keep 200 with empty to avoid UI break). We choose to swap silently? Better return empty with 200.
|
|
||||||
// We'll treat as invalid and return 400
|
|
||||||
return new \WP_REST_Response(['code' => 'invalid_price_range', 'message' => esc_html__('Min price cannot be greater than max price.', 'xshop'), 'data' => ['status' => 400]], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect attribute filters: any query param starting with pa_ or attribute_ or known attribute taxonomies
|
|
||||||
$attributes = [];
|
|
||||||
$allParams = $request->get_params();
|
|
||||||
foreach ($allParams as $key => $val) {
|
|
||||||
$k = sanitize_key((string) $key);
|
|
||||||
// Allow pa_* and attribute_* and custom like color/size if they correspond to taxonomies; we conservatively allow pa_* and attribute_*
|
|
||||||
if (str_starts_with($k, 'pa_') || str_starts_with($k, 'attribute_')) {
|
|
||||||
$tax = $k;
|
|
||||||
if (str_starts_with($tax, 'attribute_')) {
|
|
||||||
// attribute_pa_color -> pa_color
|
|
||||||
$tax = 'pa_' . substr($tax, 10);
|
|
||||||
}
|
|
||||||
// Normalize term slugs (comma separated or array)
|
|
||||||
$terms = [];
|
|
||||||
if (is_array($val)) {
|
|
||||||
foreach ($val as $t) { $t = sanitize_title((string)$t); if ($t !== '') { $terms[] = $t; } }
|
|
||||||
} else {
|
|
||||||
$valStr = (string) $val;
|
|
||||||
// Support comma-separated
|
|
||||||
foreach (explode(',', $valStr) as $t) { $t = sanitize_title(trim($t)); if ($t !== '') { $terms[] = $t; } }
|
|
||||||
}
|
|
||||||
if (!empty($terms)) {
|
|
||||||
// Only allow existing taxonomy
|
|
||||||
if (\taxonomy_exists($tax)) {
|
|
||||||
$attributes[$tax] = $terms;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also allow `filter_{tax}` style used by some themes? Support legacy `filter_color`
|
|
||||||
if (str_starts_with($k, 'filter_')) {
|
|
||||||
$tax = 'pa_' . substr($k, 7);
|
|
||||||
if (\taxonomy_exists($tax)) {
|
|
||||||
$valStr = (string) $val;
|
|
||||||
$terms = [];
|
|
||||||
foreach (explode(',', $valStr) as $t) { $t = sanitize_title(trim($t)); if ($t !== '') { $terms[] = $t; } }
|
|
||||||
if (!empty($terms)) { $attributes[$tax] = $terms; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build query args via shared layer
|
|
||||||
$queryParams = [
|
|
||||||
'search' => $search,
|
|
||||||
'category' => $category,
|
|
||||||
'attributes' => $attributes,
|
|
||||||
'min_price' => $minPrice !== '' ? (float)$minPrice : null,
|
|
||||||
'max_price' => $maxPrice !== '' ? (float)$maxPrice : null,
|
|
||||||
'stock' => $stock,
|
|
||||||
'on_sale' => $onSale,
|
|
||||||
'rating' => $rating,
|
|
||||||
'orderby' => $orderby,
|
|
||||||
'page' => $page,
|
|
||||||
'per_page' => $perPage,
|
|
||||||
];
|
|
||||||
|
|
||||||
// If Woo inactive, fallback to empty (no products)
|
|
||||||
if (!class_exists('WooCommerce')) {
|
|
||||||
return new \WP_REST_Response(['items' => [], 'pagination' => ['page' => $page, 'per_page' => $perPage, 'total' => 0, 'total_pages' => 1]], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
$wpArgs = ProductQuery\build_product_query_args($queryParams);
|
|
||||||
$result = ProductQuery\execute_query($wpArgs);
|
|
||||||
|
|
||||||
$items = [];
|
|
||||||
foreach ($result['ids'] as $pid) {
|
|
||||||
$product = wc_get_product((int) $pid);
|
|
||||||
if (!$product instanceof \WC_Product) { continue; }
|
|
||||||
$items[] = ProductQuery\format_product($product);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = new \WP_REST_Response([
|
|
||||||
'items' => $items,
|
|
||||||
'pagination' => [
|
|
||||||
'page' => $page,
|
|
||||||
'per_page' => $perPage,
|
|
||||||
'total' => $result['total'],
|
|
||||||
'total_pages' => $result['total_pages'],
|
|
||||||
],
|
|
||||||
], 200);
|
|
||||||
$response->header('Cache-Control', 'public, max-age=30');
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* XShop REST Search — /xshop/v1/search
|
|
||||||
*
|
|
||||||
* Public read-only product search. Returns minimal fields, no private data.
|
|
||||||
*
|
|
||||||
* @package XShop\Core
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace XShop\Core\REST;
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
use XShop\Core\Query as ProductQuery;
|
|
||||||
|
|
||||||
add_action('rest_api_init', __NAMESPACE__ . '\\register_search_route');
|
|
||||||
|
|
||||||
function register_search_route(): void {
|
|
||||||
register_rest_route('xshop/v1', '/search', [
|
|
||||||
'methods' => 'GET',
|
|
||||||
'callback' => __NAMESPACE__ . '\\handle_search',
|
|
||||||
'permission_callback' => '__return_true', // public read-only; no private data exposed
|
|
||||||
'args' => [
|
|
||||||
'search' => [
|
|
||||||
'required' => false,
|
|
||||||
'type' => 'string',
|
|
||||||
'sanitize_callback' => __NAMESPACE__ . '\\sanitize_search_param',
|
|
||||||
'validate_callback' => __NAMESPACE__ . '\\validate_search_param',
|
|
||||||
],
|
|
||||||
'limit' => [
|
|
||||||
'required' => false,
|
|
||||||
'type' => 'integer',
|
|
||||||
'default' => 6,
|
|
||||||
'sanitize_callback' => 'absint',
|
|
||||||
'validate_callback' => static function ($value): bool {
|
|
||||||
$v = (int) $value;
|
|
||||||
return $v >= 1 && $v <= 20;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitize_search_param($value): string {
|
|
||||||
return \XShop\Core\Query\sanitize_search_param($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validate_search_param($value, $request, $param): bool {
|
|
||||||
if (!is_string($value)) { return false; }
|
|
||||||
return \mb_strlen($value) <= 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle search.
|
|
||||||
*
|
|
||||||
* @param \WP_REST_Request $request
|
|
||||||
* @return \WP_REST_Response
|
|
||||||
*/
|
|
||||||
function handle_search(\WP_REST_Request $request): \WP_REST_Response {
|
|
||||||
$search = \XShop\Core\Query\sanitize_search_param((string) $request->get_param('search'));
|
|
||||||
$limit = (int) $request->get_param('limit');
|
|
||||||
if ($limit < 1 || $limit > 20) { $limit = 6; }
|
|
||||||
|
|
||||||
if (mb_strlen($search) < 2) {
|
|
||||||
return new \WP_REST_Response(['items' => [], 'total' => 0], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
$wcActive = class_exists('WooCommerce');
|
|
||||||
$items = [];
|
|
||||||
|
|
||||||
if ($wcActive && function_exists('wc_get_products')) {
|
|
||||||
$ids = [];
|
|
||||||
$wpQ = new \WP_Query([
|
|
||||||
'post_type' => 'product',
|
|
||||||
'post_status' => 'publish',
|
|
||||||
's' => $search,
|
|
||||||
'posts_per_page' => $limit * 2,
|
|
||||||
'no_found_rows' => true,
|
|
||||||
'fields' => 'ids',
|
|
||||||
]);
|
|
||||||
if (!empty($wpQ->posts)) { $ids = array_merge($ids, array_map('intval', $wpQ->posts)); }
|
|
||||||
wp_reset_postdata();
|
|
||||||
$skuQ = new \WP_Query([
|
|
||||||
'post_type' => 'product',
|
|
||||||
'post_status' => 'publish',
|
|
||||||
'posts_per_page' => $limit,
|
|
||||||
'no_found_rows' => true,
|
|
||||||
'fields' => 'ids',
|
|
||||||
'meta_query' => [['key' => '_sku', 'value' => $search, 'compare' => 'LIKE']],
|
|
||||||
]);
|
|
||||||
if (!empty($skuQ->posts)) { $ids = array_merge($ids, array_map('intval', $skuQ->posts)); }
|
|
||||||
wp_reset_postdata();
|
|
||||||
$ids = array_slice(array_values(array_unique($ids)), 0, $limit);
|
|
||||||
foreach ($ids as $pid) {
|
|
||||||
$product = wc_get_product((int) $pid);
|
|
||||||
if (!$product instanceof \WC_Product) { continue; }
|
|
||||||
$items[] = \XShop\Core\Query\format_product($product);
|
|
||||||
}
|
|
||||||
$total = count($items);
|
|
||||||
} else {
|
|
||||||
$q = new \WP_Query([
|
|
||||||
'post_type' => 'post',
|
|
||||||
'post_status' => 'publish',
|
|
||||||
's' => $search,
|
|
||||||
'posts_per_page' => $limit,
|
|
||||||
'no_found_rows' => true,
|
|
||||||
]);
|
|
||||||
foreach ($q->posts as $post) {
|
|
||||||
$items[] = [
|
|
||||||
'id' => (int) $post->ID,
|
|
||||||
'title' => html_entity_decode(get_the_title($post->ID), ENT_QUOTES, 'UTF-8'),
|
|
||||||
'url' => get_permalink($post->ID),
|
|
||||||
'image' => get_the_post_thumbnail_url($post->ID, 'thumbnail') ?: '',
|
|
||||||
'price_html' => '',
|
|
||||||
'type' => $post->post_type,
|
|
||||||
'in_stock' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$total = count($items);
|
|
||||||
wp_reset_postdata();
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = new \WP_REST_Response(['items' => $items, 'total' => $total], 200);
|
|
||||||
$response->header('Cache-Control', 'public, max-age=60');
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// format_product now lives in Query\ProductQuery — kept for BC if any caller used it, delegate
|
|
||||||
function format_product(\WC_Product $product): array {
|
|
||||||
return \XShop\Core\Query\format_product($product);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Plugin Name: XShop Core
|
|
||||||
* Plugin URI: https://example.com/xshop-core
|
|
||||||
* Description: Companion plugin for XShop theme – wishlist, compare, Q&A, AJAX search/filter, banners, demo import, and more. Requires XShop theme.
|
|
||||||
* Version: 1.0.0
|
|
||||||
* Author: XShop Team
|
|
||||||
* Author URI: https://example.com
|
|
||||||
* Text Domain: xshop
|
|
||||||
* Domain Path: /languages
|
|
||||||
* Requires at least: 6.4
|
|
||||||
* Requires PHP: 8.2
|
|
||||||
* License: Commercial
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
define('XSHOP_CORE_VERSION', '1.0.0');
|
|
||||||
define('XSHOP_CORE_FILE', __FILE__);
|
|
||||||
define('XSHOP_CORE_DIR', __DIR__);
|
|
||||||
define('XSHOP_CORE_URI', plugin_dir_url(__FILE__));
|
|
||||||
|
|
||||||
require_once XSHOP_CORE_DIR . '/includes/Core/Plugin.php';
|
|
||||||
|
|
||||||
register_activation_hook(__FILE__, ['XShop\Core\Plugin', 'activate']);
|
|
||||||
register_deactivation_hook(__FILE__, ['XShop\Core\Plugin', 'deactivate']);
|
|
||||||
|
|
||||||
add_action('plugins_loaded', function (): void {
|
|
||||||
load_plugin_textdomain('xshop', false, dirname(plugin_basename(__FILE__)) . '/languages');
|
|
||||||
XShop\Core\Plugin::instance()->init();
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* 404
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
get_header();
|
|
||||||
?>
|
|
||||||
<div class="xshop-container">
|
|
||||||
<div class="xshop-404">
|
|
||||||
<p class="xshop-404__code">404</p>
|
|
||||||
<h1 class="xshop-404__title"><?php esc_html_e('Page not found', 'xshop'); ?></h1>
|
|
||||||
<p class="xshop-text-muted" style="max-inline-size:48ch;margin-inline:auto"><?php esc_html_e('The page you are looking for does not exist. Try searching or return home.', 'xshop'); ?></p>
|
|
||||||
<div style="margin-block-start:var(--xshop-space-6);display:flex;gap:var(--xshop-space-3);justify-content:center;flex-wrap:wrap">
|
|
||||||
<a class="xshop-btn xshop-btn--primary" href="<?php echo esc_url(home_url('/')); ?>"><?php esc_html_e('Go home', 'xshop'); ?></a>
|
|
||||||
</div>
|
|
||||||
<div style="max-inline-size:520px;margin-inline:auto;margin-block-start:var(--xshop-space-8)">
|
|
||||||
<?php get_template_part('template-parts/components/search-form', null, ['placeholder'=>esc_html__('Search…','xshop')]); ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
$recent = new WP_Query(['post_type'=>'post','posts_per_page'=>3,'no_found_rows'=>true]);
|
|
||||||
if ($recent->have_posts()):
|
|
||||||
?>
|
|
||||||
<section class="xshop-section" aria-labelledby="xshop-404-recent">
|
|
||||||
<h2 id="xshop-404-recent" class="xshop-section__title" style="font-size:var(--xshop-text-xl)"><?php esc_html_e('Recent posts', 'xshop'); ?></h2>
|
|
||||||
<div class="xshop-grid xshop-grid--posts" style="margin-block-start:var(--xshop-space-4);text-align:start">
|
|
||||||
<?php while ($recent->have_posts()): $recent->the_post(); get_template_part('template-parts/components/post-card'); endwhile; wp_reset_postdata(); ?>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php get_footer(); ?>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Archive
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
get_header();
|
|
||||||
?>
|
|
||||||
<div class="xshop-container">
|
|
||||||
<div class="xshop-layout">
|
|
||||||
<div class="xshop-layout__content">
|
|
||||||
<header class="xshop-page-header">
|
|
||||||
<?php the_archive_title('<h1 class="xshop-page-header__title">', '</h1>'); ?>
|
|
||||||
<?php $desc = get_the_archive_description(); if ($desc !== ''): ?>
|
|
||||||
<div class="xshop-page-header__desc"><?php echo wp_kses_post($desc); ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<?php if (have_posts()): ?>
|
|
||||||
<div class="xshop-grid xshop-grid--posts">
|
|
||||||
<?php while (have_posts()): the_post(); get_template_part('template-parts/components/post-card'); endwhile; ?>
|
|
||||||
</div>
|
|
||||||
<?php get_template_part('template-parts/components/pagination'); ?>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php get_template_part('template-parts/components/empty-state', null, ['title'=>esc_html__('Nothing here','xshop'),'text'=>esc_html__('No posts found in this archive.','xshop'),'cta_label'=>esc_html__('Go home','xshop'),'cta_url'=>home_url('/')]); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php get_sidebar(); ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php get_footer(); ?>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/* XShop base — minimal reset + tokens wiring. Logical properties throughout. */
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
|
||||||
html { font-family: var(--xshop-font-sans); line-height: var(--xshop-leading); color: var(--xshop-text); background: var(--xshop-bg); -webkit-font-smoothing: antialiased; }
|
|
||||||
body { margin: 0; background: var(--xshop-bg); color: var(--xshop-text); }
|
|
||||||
a { color: var(--xshop-link); text-decoration: none; transition: color var(--xshop-transition); }
|
|
||||||
a:hover { color: var(--xshop-link-hover); }
|
|
||||||
a:focus-visible { outline: 2px solid var(--xshop-focus); outline-offset: 2px; }
|
|
||||||
img { max-width: 100%; height: auto; display: block; }
|
|
||||||
p { margin-block: 0 var(--xshop-space-4); }
|
|
||||||
h1, h2, h3, h4, h5, h6 { line-height: var(--xshop-leading-tight); margin-block: 0 var(--xshop-space-3); font-weight: var(--xshop-weight-bold); overflow-wrap: break-word; }
|
|
||||||
ul, ol { margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
/* Container */
|
|
||||||
.xshop-container { max-width: var(--xshop-container); margin-inline: auto; padding-inline: var(--xshop-space-4); }
|
|
||||||
.xshop-container--narrow { max-width: var(--xshop-container-narrow); }
|
|
||||||
|
|
||||||
/* Skip link */
|
|
||||||
.xshop-skip-link { position: absolute; inset-inline-start: -9999px; inset-block-start: auto; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
||||||
.xshop-skip-link:focus { inset-inline-start: var(--xshop-space-4); inset-block-start: var(--xshop-space-4); inline-size: auto; block-size: auto; overflow: visible; clip-path: none; white-space: normal; background: var(--xshop-card); color: var(--xshop-text); padding: var(--xshop-space-2) var(--xshop-space-4); border: 2px solid var(--xshop-focus); border-radius: var(--xshop-radius-sm); z-index: var(--xshop-z-modal); font-weight: var(--xshop-weight-medium); }
|
|
||||||
|
|
||||||
/* Focus */
|
|
||||||
:focus-visible { outline: 2px solid var(--xshop-focus); outline-offset: 2px; }
|
|
||||||
:focus:not(:focus-visible) { outline: none; }
|
|
||||||
|
|
||||||
/* Forms */
|
|
||||||
input, textarea, select, button { font: inherit; }
|
|
||||||
input[type="search"], input[type="text"], input[type="email"], input[type="password"], input[type="url"], input[type="tel"], textarea, select {
|
|
||||||
background: var(--xshop-input-bg); color: var(--xshop-input-text); border: 1px solid var(--xshop-input-border);
|
|
||||||
border-radius: var(--xshop-radius-sm); padding: var(--xshop-space-2) var(--xshop-space-3); inline-size: 100%;
|
|
||||||
}
|
|
||||||
input:focus, textarea:focus, select:focus { outline: 2px solid var(--xshop-focus); outline-offset: 0; border-color: var(--xshop-focus); }
|
|
||||||
|
|
||||||
/* Reduced motion */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/* XShop Components — buttons, cards, breadcrumbs, pagination, notices, etc. */
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.xshop-btn { display: inline-flex; align-items: center; justify-content: center; gap: var(--xshop-space-2); padding: var(--xshop-space-2) var(--xshop-space-4); border-radius: var(--xshop-radius-sm); border: 1px solid transparent; font-weight: var(--xshop-weight-medium); font-size: var(--xshop-text-sm); cursor: pointer; transition: background var(--xshop-transition), border-color var(--xshop-transition), color var(--xshop-transition); text-decoration: none; line-height: 1.4; }
|
|
||||||
.xshop-btn--primary { background: var(--xshop-primary); color: #fff; border-color: var(--xshop-primary); }
|
|
||||||
.xshop-btn--primary:hover { background: var(--xshop-primary-600); border-color: var(--xshop-primary-600); color: #fff; }
|
|
||||||
.xshop-btn--secondary { background: var(--xshop-card); color: var(--xshop-text); border-color: var(--xshop-border); }
|
|
||||||
.xshop-btn--secondary:hover { background: var(--xshop-surface); }
|
|
||||||
.xshop-btn--ghost { background: transparent; color: var(--xshop-text); border-color: transparent; }
|
|
||||||
.xshop-btn--ghost:hover { background: var(--xshop-surface); }
|
|
||||||
.xshop-btn:disabled { opacity: .55; cursor: not-allowed; }
|
|
||||||
|
|
||||||
/* Card — post-card, product placeholder, generic */
|
|
||||||
.xshop-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); }
|
|
||||||
.xshop-card:hover { box-shadow: var(--xshop-shadow-md); }
|
|
||||||
.xshop-card__media { aspect-ratio: 16/10; background: var(--xshop-surface); overflow: hidden; }
|
|
||||||
.xshop-card__media img { inline-size: 100%; block-size: 100%; object-fit: cover; }
|
|
||||||
.xshop-card__media--placeholder { display: flex; align-items: center; justify-content: center; color: var(--xshop-muted); font-size: var(--xshop-text-sm); }
|
|
||||||
.xshop-card__body { padding: var(--xshop-space-4); display: flex; flex-direction: column; gap: var(--xshop-space-2); flex: 1; }
|
|
||||||
.xshop-card__title { font-size: var(--xshop-text-base); margin: 0; line-height: var(--xshop-leading-tight); }
|
|
||||||
.xshop-card__title a { color: var(--xshop-text); }
|
|
||||||
.xshop-card__title a:hover { color: var(--xshop-link); }
|
|
||||||
.xshop-card__meta { font-size: var(--xshop-text-sm); color: var(--xshop-muted); display: flex; gap: var(--xshop-space-2); flex-wrap: wrap; }
|
|
||||||
.xshop-card__excerpt { font-size: var(--xshop-text-sm); color: var(--xshop-muted); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
||||||
|
|
||||||
/* Breadcrumbs */
|
|
||||||
.xshop-breadcrumbs { font-size: var(--xshop-text-sm); color: var(--xshop-muted); }
|
|
||||||
.xshop-breadcrumbs ol { display: flex; flex-wrap: wrap; gap: var(--xshop-space-2); list-style: none; margin: 0; padding: 0; align-items: center; }
|
|
||||||
.xshop-breadcrumbs li { display: inline-flex; align-items: center; gap: var(--xshop-space-2); }
|
|
||||||
.xshop-breadcrumbs li + li::before { content: "/"; opacity: .5; }
|
|
||||||
[dir="rtl"] .xshop-breadcrumbs li + li::before { content: "/"; }
|
|
||||||
.xshop-breadcrumbs a { color: var(--xshop-muted); }
|
|
||||||
.xshop-breadcrumbs a:hover { color: var(--xshop-text); }
|
|
||||||
.xshop-breadcrumbs [aria-current="page"] { color: var(--xshop-text); font-weight: var(--xshop-weight-medium); }
|
|
||||||
|
|
||||||
/* Pagination */
|
|
||||||
.xshop-pagination { display: flex; justify-content: center; margin-block-start: var(--xshop-space-8); }
|
|
||||||
.xshop-pagination .nav-links { display: flex; gap: var(--xshop-space-1); flex-wrap: wrap; align-items: center; }
|
|
||||||
.xshop-pagination .page-numbers { display: inline-flex; align-items: center; justify-content: center; min-inline-size: 40px; block-size: 40px; padding-inline: var(--xshop-space-2); border: 1px solid var(--xshop-border); border-radius: var(--xshop-radius-sm); background: var(--xshop-card); color: var(--xshop-text); font-size: var(--xshop-text-sm); text-decoration: none; }
|
|
||||||
.xshop-pagination .page-numbers:hover { background: var(--xshop-surface); }
|
|
||||||
.xshop-pagination .page-numbers.current { background: var(--xshop-primary); border-color: var(--xshop-primary); color: #fff; font-weight: var(--xshop-weight-bold); }
|
|
||||||
.xshop-pagination .page-numbers.dots { border-color: transparent; background: transparent; }
|
|
||||||
|
|
||||||
/* Search form */
|
|
||||||
.xshop-search-form { display: flex; gap: var(--xshop-space-2); max-inline-size: 520px; }
|
|
||||||
.xshop-search-form input[type="search"] { flex: 1; }
|
|
||||||
|
|
||||||
/* Empty state */
|
|
||||||
.xshop-empty { text-align: center; padding: var(--xshop-space-12) var(--xshop-space-6); background: var(--xshop-surface); border: 1px solid var(--xshop-border); border-radius: var(--xshop-radius-md); }
|
|
||||||
.xshop-empty__title { font-size: var(--xshop-text-xl); margin: 0 0 var(--xshop-space-2); }
|
|
||||||
.xshop-empty__text { color: var(--xshop-muted); margin: 0 0 var(--xshop-space-4); }
|
|
||||||
|
|
||||||
/* Loading */
|
|
||||||
.xshop-loading { display: inline-flex; align-items: center; gap: var(--xshop-space-2); color: var(--xshop-muted); font-size: var(--xshop-text-sm); }
|
|
||||||
.xshop-loading__spinner { inline-size: 16px; block-size: 16px; border: 2px solid var(--xshop-border); border-block-start-color: var(--xshop-primary); border-radius: 50%; animation: xshop-spin 600ms linear infinite; }
|
|
||||||
@keyframes xshop-spin { to { transform: rotate(360deg); } }
|
|
||||||
@media (prefers-reduced-motion: reduce) { .xshop-loading__spinner { animation: none; } }
|
|
||||||
|
|
||||||
/* Notices */
|
|
||||||
.xshop-notice { padding: var(--xshop-space-3) var(--xshop-space-4); border-radius: var(--xshop-radius-sm); border: 1px solid var(--xshop-border); background: var(--xshop-surface); font-size: var(--xshop-text-sm); }
|
|
||||||
.xshop-notice--success { background: var(--xshop-success-bg); border-color: #86efac; color: #14532d; }
|
|
||||||
[data-theme="dark"] .xshop-notice--success { color: #bbf7d0; }
|
|
||||||
.xshop-notice--warning { background: var(--xshop-warning-bg); border-color: #fcd34d; }
|
|
||||||
.xshop-notice--error { background: var(--xshop-danger-bg); border-color: #fca5a5; }
|
|
||||||
|
|
||||||
/* Comments */
|
|
||||||
.xshop-comments { margin-block-start: var(--xshop-space-8); border-block-start: 1px solid var(--xshop-border); padding-block-start: var(--xshop-space-8); }
|
|
||||||
.xshop-comments__title { font-size: var(--xshop-text-xl); }
|
|
||||||
.xshop-comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--xshop-space-4); }
|
|
||||||
.xshop-comment { background: var(--xshop-card); border: 1px solid var(--xshop-border); border-radius: var(--xshop-radius-md); padding: var(--xshop-space-4); }
|
|
||||||
.xshop-comment__meta { font-size: var(--xshop-text-sm); color: var(--xshop-muted); margin-block-end: var(--xshop-space-2); }
|
|
||||||
.xshop-comment__body { font-size: var(--xshop-text-base); }
|
|
||||||
.xshop-comment .children { list-style: none; margin-block-start: var(--xshop-space-4); padding-inline-start: var(--xshop-space-6); display: flex; flex-direction: column; gap: var(--xshop-space-4); border-inline-start: 2px solid var(--xshop-border); }
|
|
||||||
@media (max-width: 640px) { .xshop-comment .children { padding-inline-start: var(--xshop-space-3); } }
|
|
||||||
|
|
||||||
/* Page header */
|
|
||||||
.xshop-page-header { margin-block-end: var(--xshop-space-6); }
|
|
||||||
.xshop-page-header__title { font-size: var(--xshop-text-3xl); margin: 0; }
|
|
||||||
.xshop-page-header__desc { color: var(--xshop-muted); margin-block-start: var(--xshop-space-2); }
|
|
||||||
|
|
||||||
/* 404 */
|
|
||||||
.xshop-404 { text-align: center; padding: var(--xshop-space-16) var(--xshop-space-4); }
|
|
||||||
.xshop-404__code { font-size: 5rem; font-weight: var(--xshop-weight-bold); line-height: 1; color: var(--xshop-border); margin: 0; }
|
|
||||||
.xshop-404__title { font-size: var(--xshop-text-3xl); margin: var(--xshop-space-4) 0 var(--xshop-space-2); }
|
|
||||||
|
|
||||||
/* Promo banner foundation */
|
|
||||||
.xshop-promo { background: var(--xshop-surface); border: 1px solid var(--xshop-border); border-radius: var(--xshop-radius-md); padding: var(--xshop-space-6); display: flex; align-items: center; justify-content: space-between; gap: var(--xshop-space-4); flex-wrap: wrap; }
|
|
||||||
|
|
||||||
/* Woo notice compat */
|
|
||||||
.woocommerce-message, .woocommerce-info, .woocommerce-error { border-radius: var(--xshop-radius-sm); }
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
/* XShop Layout — header, footer, grid, sections. Logical properties only. */
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.xshop-header { position: sticky; inset-block-start: 0; z-index: var(--xshop-z-header); background: var(--xshop-card); border-block-end: 1px solid var(--xshop-border); }
|
|
||||||
.xshop-header__inner { display: flex; align-items: center; gap: var(--xshop-space-4); padding-block: var(--xshop-space-3); flex-wrap: wrap; }
|
|
||||||
.xshop-header__branding { display: flex; align-items: center; gap: var(--xshop-space-3); flex-shrink: 0; }
|
|
||||||
.xshop-header__logo img { max-block-size: 40px; inline-size: auto; }
|
|
||||||
.xshop-header__title { font-size: var(--xshop-text-xl); font-weight: var(--xshop-weight-bold); margin: 0; }
|
|
||||||
.xshop-header__title a { color: var(--xshop-text); }
|
|
||||||
.xshop-header__tagline { font-size: var(--xshop-text-sm); color: var(--xshop-muted); margin: 0; }
|
|
||||||
.xshop-header__nav { flex: 1 1 auto; display: flex; justify-content: center; }
|
|
||||||
.xshop-header__actions { display: flex; align-items: center; gap: var(--xshop-space-2); flex-shrink: 0; }
|
|
||||||
.xshop-header__action-btn { display: inline-flex; align-items: center; justify-content: center; min-inline-size: 40px; min-block-size: 40px; border: 1px solid var(--xshop-border); border-radius: var(--xshop-radius-sm); background: var(--xshop-card); color: var(--xshop-text); cursor: pointer; }
|
|
||||||
.xshop-header__action-btn:hover { background: var(--xshop-surface); }
|
|
||||||
.xshop-header__search { position: relative; }
|
|
||||||
.xshop-header__search-form { display: flex; gap: var(--xshop-space-2); }
|
|
||||||
|
|
||||||
/* Primary nav */
|
|
||||||
.xshop-nav { display: flex; }
|
|
||||||
.xshop-nav__list { display: flex; flex-wrap: wrap; gap: var(--xshop-space-1); list-style: none; margin: 0; padding: 0; align-items: center; }
|
|
||||||
.xshop-nav__list a { display: block; padding: var(--xshop-space-2) var(--xshop-space-3); color: var(--xshop-text); border-radius: var(--xshop-radius-sm); font-weight: var(--xshop-weight-medium); font-size: var(--xshop-text-sm); }
|
|
||||||
.xshop-nav__list a:hover, .xshop-nav__list a:focus-visible { background: var(--xshop-surface); color: var(--xshop-text); }
|
|
||||||
.xshop-nav__list .current-menu-item > a, .xshop-nav__list .current_page_item > a { background: var(--xshop-surface-2); }
|
|
||||||
|
|
||||||
/* Mobile nav */
|
|
||||||
.xshop-mobile-toggle { display: none; align-items: center; justify-content: center; inline-size: 40px; block-size: 40px; border: 1px solid var(--xshop-border); border-radius: var(--xshop-radius-sm); background: var(--xshop-card); cursor: pointer; color: var(--xshop-text); }
|
|
||||||
.xshop-mobile-nav { display: none; border-block-start: 1px solid var(--xshop-border); padding-block: var(--xshop-space-3); }
|
|
||||||
.xshop-mobile-nav.is-open { display: block; }
|
|
||||||
.xshop-mobile-nav__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.xshop-mobile-nav__list a { display: block; padding: var(--xshop-space-2) var(--xshop-space-3); color: var(--xshop-text); border-radius: var(--xshop-radius-sm); }
|
|
||||||
.xshop-mobile-nav__list a:hover { background: var(--xshop-surface); }
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.xshop-nav--desktop { display: none; }
|
|
||||||
.xshop-mobile-toggle { display: inline-flex; }
|
|
||||||
.xshop-header__search--desktop { display: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.xshop-footer { background: var(--xshop-surface); border-block-start: 1px solid var(--xshop-border); margin-block-start: var(--xshop-space-12); padding-block: var(--xshop-space-8) var(--xshop-space-6); }
|
|
||||||
.xshop-footer__widgets { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--xshop-space-6); margin-block-end: var(--xshop-space-8); }
|
|
||||||
.xshop-footer__bottom { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--xshop-space-4); border-block-start: 1px solid var(--xshop-border); padding-block-start: var(--xshop-space-4); font-size: var(--xshop-text-sm); color: var(--xshop-muted); }
|
|
||||||
.xshop-footer__nav-list { display: flex; flex-wrap: wrap; gap: var(--xshop-space-3); list-style: none; margin: 0; padding: 0; }
|
|
||||||
.xshop-footer__nav-list a { color: var(--xshop-muted); }
|
|
||||||
.xshop-footer__nav-list a:hover { color: var(--xshop-text); }
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.xshop-footer__widgets { grid-template-columns: 1fr 1fr; }
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.xshop-footer__widgets { grid-template-columns: 1fr; }
|
|
||||||
.xshop-footer__bottom { flex-direction: column; align-items: flex-start; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main layout */
|
|
||||||
.xshop-main { padding-block: var(--xshop-space-8); }
|
|
||||||
.xshop-layout { display: grid; grid-template-columns: 1fr 320px; gap: var(--xshop-space-8); align-items: start; }
|
|
||||||
.xshop-layout--no-sidebar { grid-template-columns: 1fr; }
|
|
||||||
.xshop-layout__content { min-inline-size: 0; }
|
|
||||||
.xshop-layout__sidebar { position: sticky; inset-block-start: calc(60px + var(--xshop-space-4)); }
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.xshop-layout { grid-template-columns: 1fr; }
|
|
||||||
.xshop-layout__sidebar { position: static; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grids */
|
|
||||||
.xshop-grid { display: grid; gap: var(--xshop-space-4); }
|
|
||||||
.xshop-grid--posts { grid-template-columns: repeat(3, 1fr); }
|
|
||||||
.xshop-grid--products { grid-template-columns: repeat(3, 1fr); }
|
|
||||||
@media (max-width: 1024px) { .xshop-grid--posts, .xshop-grid--products { grid-template-columns: repeat(2, 1fr); } }
|
|
||||||
@media (max-width: 640px) { .xshop-grid--posts, .xshop-grid--products { grid-template-columns: 1fr; } }
|
|
||||||
|
|
||||||
/* Section */
|
|
||||||
.xshop-section { padding-block: var(--xshop-space-8); }
|
|
||||||
.xshop-section__header { display: flex; align-items: baseline; justify-content: space-between; gap: var(--xshop-space-4); margin-block-end: var(--xshop-space-6); flex-wrap: wrap; }
|
|
||||||
.xshop-section__title { font-size: var(--xshop-text-2xl); margin: 0; }
|
|
||||||
.xshop-section__link { font-size: var(--xshop-text-sm); font-weight: var(--xshop-weight-medium); }
|
|
||||||
|
|
||||||
/* Hero foundation */
|
|
||||||
.xshop-hero { background: var(--xshop-surface); border: 1px solid var(--xshop-border); border-radius: var(--xshop-radius-lg); padding: var(--xshop-space-8); display: grid; gap: var(--xshop-space-4); }
|
|
||||||
.xshop-hero__title { font-size: var(--xshop-text-4xl); margin: 0; }
|
|
||||||
.xshop-hero__text { color: var(--xshop-muted); max-inline-size: 60ch; }
|
|
||||||
.xshop-hero__actions { display: flex; gap: var(--xshop-space-3); flex-wrap: wrap; }
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/* XShop Design Tokens — CSS custom properties. Logical, RTL-safe. */
|
|
||||||
:root {
|
|
||||||
/* Palette */
|
|
||||||
--xshop-primary: #0ea5e9;
|
|
||||||
--xshop-primary-600: #0284c7;
|
|
||||||
--xshop-primary-700: #0369a1;
|
|
||||||
--xshop-secondary: #111827;
|
|
||||||
--xshop-bg: #ffffff;
|
|
||||||
--xshop-surface: #f8fafc;
|
|
||||||
--xshop-surface-2: #f1f5f9;
|
|
||||||
--xshop-card: #ffffff;
|
|
||||||
--xshop-text: #0f172a;
|
|
||||||
--xshop-muted: #64748b;
|
|
||||||
--xshop-border: #e2e8f0;
|
|
||||||
--xshop-input-bg: #ffffff;
|
|
||||||
--xshop-input-border: #cbd5e1;
|
|
||||||
--xshop-input-text: #0f172a;
|
|
||||||
--xshop-focus: #0ea5e9;
|
|
||||||
--xshop-link: #0ea5e9;
|
|
||||||
--xshop-link-hover: #0284c7;
|
|
||||||
--xshop-success: #16a34a;
|
|
||||||
--xshop-success-bg: #f0fdf4;
|
|
||||||
--xshop-warning: #f59e0b;
|
|
||||||
--xshop-warning-bg: #fffbeb;
|
|
||||||
--xshop-danger: #dc2626;
|
|
||||||
--xshop-danger-bg: #fef2f2;
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
--xshop-font-sans: system-ui, -apple-system, "Vazirmatn", "IRANSans", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
--xshop-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
--xshop-text-xs: 0.75rem; /* 12px */
|
|
||||||
--xshop-text-sm: 0.875rem; /* 14px */
|
|
||||||
--xshop-text-base: 1rem; /* 16px */
|
|
||||||
--xshop-text-lg: 1.125rem; /* 18px */
|
|
||||||
--xshop-text-xl: 1.25rem; /* 20px */
|
|
||||||
--xshop-text-2xl: 1.5rem; /* 24px */
|
|
||||||
--xshop-text-3xl: 1.875rem;/* 30px */
|
|
||||||
--xshop-text-4xl: 2.25rem; /* 36px */
|
|
||||||
--xshop-weight-regular: 400;
|
|
||||||
--xshop-weight-medium: 500;
|
|
||||||
--xshop-weight-bold: 700;
|
|
||||||
--xshop-leading-tight: 1.25;
|
|
||||||
--xshop-leading: 1.6;
|
|
||||||
--xshop-leading-relaxed: 1.75;
|
|
||||||
|
|
||||||
/* Spacing / Layout */
|
|
||||||
--xshop-space-1: 4px; --xshop-space-2: 8px; --xshop-space-3: 12px; --xshop-space-4: 16px;
|
|
||||||
--xshop-space-5: 20px; --xshop-space-6: 24px; --xshop-space-8: 32px; --xshop-space-10: 40px;
|
|
||||||
--xshop-space-12: 48px; --xshop-space-16: 64px;
|
|
||||||
--xshop-container: 1280px;
|
|
||||||
--xshop-container-narrow: 720px;
|
|
||||||
--xshop-radius-sm: 8px; --xshop-radius-md: 14px; --xshop-radius-lg: 22px; --xshop-radius-full: 9999px;
|
|
||||||
--xshop-shadow-sm: 0 1px 2px rgba(0,0,0,.06);
|
|
||||||
--xshop-shadow-md: 0 8px 24px rgba(0,0,0,.08);
|
|
||||||
--xshop-shadow-lg: 0 16px 40px rgba(0,0,0,.12);
|
|
||||||
--xshop-z-header: 40; --xshop-z-overlay: 50; --xshop-z-modal: 60;
|
|
||||||
--xshop-transition-fast: 120ms ease;
|
|
||||||
--xshop-transition: 180ms ease;
|
|
||||||
--xshop-transition-slow: 280ms ease;
|
|
||||||
|
|
||||||
/* Breakpoints (for reference; media queries use literal values) */
|
|
||||||
--xshop-bp-sm: 640px;
|
|
||||||
--xshop-bp-md: 768px;
|
|
||||||
--xshop-bp-lg: 1024px;
|
|
||||||
--xshop-bp-xl: 1280px;
|
|
||||||
--xshop-bp-2xl: 1536px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode — semantic swap, not inversion */
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--xshop-bg: #0b1220;
|
|
||||||
--xshop-surface: #111a2e;
|
|
||||||
--xshop-surface-2: #1a2542;
|
|
||||||
--xshop-card: #151f38;
|
|
||||||
--xshop-text: #e2e8f0;
|
|
||||||
--xshop-muted: #94a3b8;
|
|
||||||
--xshop-border: #1e2d4a;
|
|
||||||
--xshop-input-bg: #0f1a33;
|
|
||||||
--xshop-input-border: #24365e;
|
|
||||||
--xshop-input-text: #e2e8f0;
|
|
||||||
--xshop-focus: #38bdf8;
|
|
||||||
--xshop-link: #38bdf8;
|
|
||||||
--xshop-link-hover: #7dd3fc;
|
|
||||||
--xshop-success-bg: #052e16;
|
|
||||||
--xshop-warning-bg: #451a03;
|
|
||||||
--xshop-danger-bg: #450a0a;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
:root { --xshop-transition: 0ms; --xshop-transition-fast: 0ms; --xshop-transition-slow: 0ms; }
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
/* XShop Utilities — visually hidden, containers, prose. */
|
|
||||||
.xshop-sr-only { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
||||||
.xshop-prose { max-inline-size: 68ch; }
|
|
||||||
.xshop-prose :where(p, ul, ol, blockquote) { margin-block-end: var(--xshop-space-4); }
|
|
||||||
.xshop-prose :where(blockquote) { border-inline-start: 3px solid var(--xshop-border); padding-inline-start: var(--xshop-space-4); color: var(--xshop-muted); }
|
|
||||||
.xshop-prose :where(a) { text-decoration: underline; text-underline-offset: 3px; }
|
|
||||||
.xshop-prose--rtl { direction: rtl; }
|
|
||||||
.xshop-text-muted { color: var(--xshop-muted); }
|
|
||||||
.xshop-text-center { text-align: center; }
|
|
||||||
.xshop-stack { display: flex; flex-direction: column; gap: var(--xshop-space-4); }
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
/* 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; }
|
|
||||||
|
|
||||||
/* ---- AJAX Search ---- */
|
|
||||||
.xshop-search{ position:relative; inline-size:100%; max-inline-size:520px; }
|
|
||||||
.xshop-search__form{ display:flex; gap:var(--xshop-space-2); }
|
|
||||||
.xshop-search__form input[type="search"]{ flex:1; }
|
|
||||||
.xshop-search__dropdown{ position:absolute; inset-block-start:calc(100% + 8px); inset-inline:0; background:var(--xshop-card); border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); box-shadow:var(--xshop-shadow-lg); z-index:var(--xshop-z-overlay); max-block-size:min(60vh, 420px); overflow:auto; padding:var(--xshop-space-2); display:flex; flex-direction:column; gap:var(--xshop-space-2); }
|
|
||||||
.xshop-search__dropdown[hidden]{ display:none !important; }
|
|
||||||
.xshop-search__loading{ display:flex; align-items:center; gap:var(--xshop-space-2); font-size:var(--xshop-text-sm); color:var(--xshop-muted); padding:var(--xshop-space-3); }
|
|
||||||
.xshop-search__loading[hidden]{ display:none !important; }
|
|
||||||
.xshop-search__results{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:2px; }
|
|
||||||
.xshop-search__item{ border-radius:var(--xshop-radius-sm); }
|
|
||||||
.xshop-search__item.is-active, .xshop-search__item:focus-within, .xshop-search__item:hover{ background:var(--xshop-surface); }
|
|
||||||
.xshop-search__item[aria-selected="true"]{ background:var(--xshop-surface-2); }
|
|
||||||
.xshop-search__link{ display:grid; grid-template-columns:48px 1fr; gap:var(--xshop-space-3); align-items:center; padding:var(--xshop-space-2); text-decoration:none; color:inherit; }
|
|
||||||
.xshop-search__thumb{ inline-size:48px; block-size:48px; object-fit:cover; border-radius:var(--xshop-radius-sm); background:var(--xshop-surface); }
|
|
||||||
.xshop-search__body{ display:flex; flex-direction:column; gap:2px; min-inline-size:0; }
|
|
||||||
.xshop-search__title{ font-size:var(--xshop-text-sm); font-weight:var(--xshop-weight-medium); color:var(--xshop-text); display:-webkit-box; -webkit-line-clamp:1; -webkit-box-orient:vertical; overflow:hidden; }
|
|
||||||
.xshop-search__price{ font-size:var(--xshop-text-xs); font-weight:var(--xshop-weight-bold); color:var(--xshop-text); }
|
|
||||||
.xshop-search__price del{ color:var(--xshop-muted); font-weight:400; margin-inline-end:6px; }
|
|
||||||
.xshop-search__price ins{ text-decoration:none; color:var(--xshop-danger); }
|
|
||||||
.xshop-search__stock{ font-size:var(--xshop-text-xs); color:var(--xshop-muted); }
|
|
||||||
.xshop-search__empty, .xshop-search__error{ font-size:var(--xshop-text-sm); color:var(--xshop-muted); padding:var(--xshop-space-3); text-align:center; }
|
|
||||||
.xshop-search__empty[hidden], .xshop-search__error[hidden]{ display:none !important; }
|
|
||||||
.xshop-search__viewall{ display:block; text-align:center; padding:var(--xshop-space-2); font-size:var(--xshop-text-sm); font-weight:var(--xshop-weight-medium); color:var(--xshop-primary); text-decoration:none; border-block-start:1px solid var(--xshop-border); margin-block-start:var(--xshop-space-1); }
|
|
||||||
.xshop-search__viewall[hidden]{ display:none !important; }
|
|
||||||
.xshop-search__viewall:hover{ background:var(--xshop-surface); }
|
|
||||||
@media (max-width:640px){
|
|
||||||
.xshop-search{ max-inline-size:100%; }
|
|
||||||
.xshop-search__dropdown{ position:fixed; inset-block-start:auto; inset-inline:var(--xshop-space-2); inset-block-end:var(--xshop-space-2); max-block-size:55vh; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Filters ---- */
|
|
||||||
.xshop-shop__layout{ display:grid; grid-template-columns:280px 1fr; gap:var(--xshop-space-6); align-items:start; }
|
|
||||||
@media (max-width:1024px){ .xshop-shop__layout{ grid-template-columns:1fr; } .xshop-shop__sidebar{ display:none; } }
|
|
||||||
.xshop-shop__filter-toggle{ display:none; margin-block-end:var(--xshop-space-4); }
|
|
||||||
@media (max-width:1024px){ .xshop-shop__filter-toggle{ display:inline-flex; } }
|
|
||||||
.xshop-filters{ display:flex; flex-direction:column; gap:var(--xshop-space-4); }
|
|
||||||
.xshop-filters__chips{ display:flex; flex-wrap:wrap; gap:var(--xshop-space-2); min-block-size:0; }
|
|
||||||
.xshop-filters__chips:empty{ display:none; }
|
|
||||||
.xshop-chip{ display:inline-flex; align-items:center; gap:6px; padding:4px 10px; border-radius:var(--xshop-radius-full); background:var(--xshop-surface); border:1px solid var(--xshop-border); font-size:var(--xshop-text-xs); cursor:pointer; }
|
|
||||||
.xshop-chip--clear{ background:var(--xshop-primary); color:#fff; border-color:var(--xshop-primary); }
|
|
||||||
.xshop-filter{ border:1px solid var(--xshop-border); border-radius:var(--xshop-radius-md); padding:var(--xshop-space-3); background:var(--xshop-card); }
|
|
||||||
.xshop-filter__title{ font-weight:var(--xshop-weight-bold); font-size:var(--xshop-text-sm); margin:0 0 var(--xshop-space-2); padding:0; }
|
|
||||||
.xshop-filter__options{ display:flex; flex-direction:column; gap:var(--xshop-space-2); }
|
|
||||||
.xshop-filter__option{ display:flex; align-items:center; gap:var(--xshop-space-2); font-size:var(--xshop-text-sm); cursor:pointer; }
|
|
||||||
.xshop-filter__option input{ accent-color:var(--xshop-primary); }
|
|
||||||
.xshop-filter__count{ margin-inline-start:auto; color:var(--xshop-muted); font-size:var(--xshop-text-xs); }
|
|
||||||
.xshop-filter__price{ display:flex; align-items:center; gap:var(--xshop-space-2); }
|
|
||||||
.xshop-filter__price input{ flex:1; }
|
|
||||||
.xshop-filters__actions{ display:flex; gap:var(--xshop-space-2); }
|
|
||||||
.xshop-drawer{ position:fixed; inset-block:0; inset-inline-start:0; inline-size:min(380px, 90vw); background:var(--xshop-card); border-inline-end:1px solid var(--xshop-border); z-index:var(--xshop-z-modal); overflow:auto; padding:var(--xshop-space-4); display:flex; flex-direction:column; gap:var(--xshop-space-4); }
|
|
||||||
.xshop-drawer[hidden]{ display:none !important; }
|
|
||||||
.xshop-drawer__header{ display:flex; align-items:center; justify-content:space-between; border-block-end:1px solid var(--xshop-border); padding-block-end:var(--xshop-space-3); }
|
|
||||||
.xshop-drawer__title{ font-size:var(--xshop-text-lg); margin:0; }
|
|
||||||
.xshop-drawer__overlay{ position:fixed; inset:0; background:rgba(0,0,0,0.4); z-index:calc(var(--xshop-z-modal) - 1); }
|
|
||||||
.xshop-drawer__overlay[hidden]{ display:none !important; }
|
|
||||||
|
|
||||||
/* Star rating fallback */
|
|
||||||
.star-rating{ color:#f59e0b; }
|
|
||||||
@@ -1,594 +0,0 @@
|
|||||||
/**
|
|
||||||
* XShop AJAX Filters — vanilla, URL sync, chips, debounce for price, stale protection, a11y.
|
|
||||||
* Works with template-parts/components/product-filters.php + shop toolbar + grid.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var DEBOUNCE_MS = 300;
|
|
||||||
|
|
||||||
function debounce(fn, ms) {
|
|
||||||
var t;
|
|
||||||
return function () {
|
|
||||||
var a = arguments, ctx = this;
|
|
||||||
clearTimeout(t);
|
|
||||||
t = setTimeout(function () { fn.apply(ctx, a); }, ms);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function qsParam(params, key) {
|
|
||||||
return params.get(key) || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStateFromURL() {
|
|
||||||
var params = new URLSearchParams(window.location.search);
|
|
||||||
var state = {
|
|
||||||
search: params.get('s') || '',
|
|
||||||
category: params.get('category') || '',
|
|
||||||
min_price: params.get('min_price') || '',
|
|
||||||
max_price: params.get('max_price') || '',
|
|
||||||
stock: params.get('stock') || 'all',
|
|
||||||
on_sale: params.get('on_sale') === '1' || params.get('sale') === '1' ? '1' : '0',
|
|
||||||
rating: params.get('rating') || '0',
|
|
||||||
orderby: params.get('orderby') || 'default',
|
|
||||||
page: parseInt(params.get('page') || '1', 10) || 1,
|
|
||||||
per_page: parseInt(params.get('per_page') || '12', 10) || 12,
|
|
||||||
attributes: {}
|
|
||||||
};
|
|
||||||
// Collect pa_* attributes
|
|
||||||
params.forEach(function (val, key) {
|
|
||||||
if (key.indexOf('pa_') === 0 || key.indexOf('attribute_') === 0 || key.indexOf('filter_') === 0) {
|
|
||||||
var tax = key;
|
|
||||||
if (tax.indexOf('attribute_') === 0) tax = 'pa_' + tax.slice(10);
|
|
||||||
if (tax.indexOf('filter_') === 0) tax = 'pa_' + tax.slice(7);
|
|
||||||
// normalize comma separated to array
|
|
||||||
var terms = [];
|
|
||||||
val.split(',').forEach(function (t) { t = t.trim(); if (t) terms.push(t); });
|
|
||||||
if (terms.length) {
|
|
||||||
// Also handle repeated keys via URLSearchParams.getAll would be better, but we use forEach which iterates each entry
|
|
||||||
if (!state.attributes[tax]) state.attributes[tax] = [];
|
|
||||||
terms.forEach(function (t) { if (state.attributes[tax].indexOf(t) === -1) state.attributes[tax].push(t); });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Handle array params like pa_color[]=black (PHP style)
|
|
||||||
params.forEach(function (val, key) {
|
|
||||||
if (key.indexOf('pa_') === 0 && key.indexOf('[]') !== -1) {
|
|
||||||
var base = key.replace('[]', '');
|
|
||||||
if (!state.attributes[base]) state.attributes[base] = [];
|
|
||||||
if (state.attributes[base].indexOf(val) === -1) state.attributes[base].push(val);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stateToParams(state) {
|
|
||||||
var p = new URLSearchParams();
|
|
||||||
if (state.search) p.set('s', state.search);
|
|
||||||
if (state.category) p.set('category', state.category);
|
|
||||||
Object.keys(state.attributes).forEach(function (tax) {
|
|
||||||
var terms = state.attributes[tax];
|
|
||||||
if (terms && terms.length) p.set(tax, terms.join(','));
|
|
||||||
});
|
|
||||||
if (state.min_price) p.set('min_price', state.min_price);
|
|
||||||
if (state.max_price) p.set('max_price', state.max_price);
|
|
||||||
if (state.stock && state.stock !== 'all') p.set('stock', state.stock);
|
|
||||||
if (state.on_sale === '1') p.set('on_sale', '1');
|
|
||||||
if (state.rating && state.rating !== '0' && state.rating !== 0) p.set('rating', String(state.rating));
|
|
||||||
if (state.orderby && state.orderby !== 'default') p.set('orderby', state.orderby);
|
|
||||||
if (state.page && state.page > 1) p.set('page', String(state.page));
|
|
||||||
// per_page only if not default
|
|
||||||
if (state.per_page && state.per_page !== 12) p.set('per_page', String(state.per_page));
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stateToEndpoint(state) {
|
|
||||||
var restUrl = (window.xshopData && window.xshopData.restUrl) ? window.xshopData.restUrl : '/wp-json/';
|
|
||||||
var base = restUrl;
|
|
||||||
// base is .../wp-json/xshop/v1/ or .../wp-json/
|
|
||||||
var endpoint;
|
|
||||||
if (base.indexOf('xshop/v1') !== -1) {
|
|
||||||
endpoint = base.replace(/\/$/, '') + '/products';
|
|
||||||
// if base is /wp-json/xshop/v1/ already, this is /wp-json/xshop/v1/products
|
|
||||||
// if base is /wp-json/ we need /wp-json/xshop/v1/products
|
|
||||||
if (base.indexOf('/wp-json/xshop/v1') === -1 && base.indexOf('xshop/v1') !== -1) {
|
|
||||||
endpoint = base.replace(/\/$/, '') + '/products';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// base is /wp-json/
|
|
||||||
endpoint = base.replace(/\/$/, '') + '/xshop/v1/products';
|
|
||||||
}
|
|
||||||
// Fix duplicate wrapper
|
|
||||||
endpoint = endpoint.replace('/xshop/v1/xshop/v1/', '/xshop/v1/');
|
|
||||||
var p = stateToParams(state);
|
|
||||||
// Also map search to endpoint param
|
|
||||||
if (state.search) p.set('search', state.search);
|
|
||||||
// Ensure page/per_page correct for endpoint (endpoint expects page/per_page)
|
|
||||||
var qs = p.toString();
|
|
||||||
return endpoint + (qs ? '?' + qs : '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderProductCard(item) {
|
|
||||||
// Reuse product-card markup via JS (avoid duplicating PHP). Minimal but token-consistent.
|
|
||||||
// We keep structure close to PHP product-card for CSS reuse.
|
|
||||||
var wrap = document.createElement('li');
|
|
||||||
wrap.className = 'xshop-products__item product';
|
|
||||||
var card = document.createElement('article');
|
|
||||||
card.className = 'xshop-product-card product';
|
|
||||||
var media = document.createElement('a');
|
|
||||||
media.href = item.url;
|
|
||||||
media.className = 'xshop-product-card__media';
|
|
||||||
media.setAttribute('aria-label', item.title);
|
|
||||||
if (item.image) {
|
|
||||||
var img = document.createElement('img');
|
|
||||||
img.src = item.image;
|
|
||||||
img.alt = item.title;
|
|
||||||
img.loading = 'lazy';
|
|
||||||
img.decoding = 'async';
|
|
||||||
media.appendChild(img);
|
|
||||||
} else {
|
|
||||||
var ph = document.createElement('span');
|
|
||||||
ph.className = 'xshop-card__media--placeholder';
|
|
||||||
ph.style.cssText = 'display:flex;inline-size:100%;block-size:100%;align-items:center;justify-content:center;';
|
|
||||||
ph.textContent = 'No image';
|
|
||||||
media.appendChild(ph);
|
|
||||||
}
|
|
||||||
// Badges: we only have in_stock, sale via price_html contains del? We can infer sale via price_html contains <del>
|
|
||||||
var badges = document.createElement('span');
|
|
||||||
badges.className = 'xshop-product-card__badges';
|
|
||||||
badges.setAttribute('aria-hidden', 'true');
|
|
||||||
if (item.price_html && item.price_html.indexOf('<del') !== -1) {
|
|
||||||
var b = document.createElement('span');
|
|
||||||
b.className = 'xshop-badge xshop-badge--sale';
|
|
||||||
b.textContent = 'Sale';
|
|
||||||
badges.appendChild(b);
|
|
||||||
}
|
|
||||||
if (!item.in_stock) {
|
|
||||||
var o = document.createElement('span');
|
|
||||||
o.className = 'xshop-badge xshop-badge--outofstock';
|
|
||||||
o.textContent = 'Out of stock';
|
|
||||||
badges.appendChild(o);
|
|
||||||
}
|
|
||||||
media.appendChild(badges);
|
|
||||||
card.appendChild(media);
|
|
||||||
var body = document.createElement('div');
|
|
||||||
body.className = 'xshop-product-card__body';
|
|
||||||
var h3 = document.createElement('h3');
|
|
||||||
h3.className = 'xshop-product-card__title';
|
|
||||||
var a = document.createElement('a');
|
|
||||||
a.href = item.url;
|
|
||||||
a.textContent = item.title;
|
|
||||||
h3.appendChild(a);
|
|
||||||
body.appendChild(h3);
|
|
||||||
if (item.price_html) {
|
|
||||||
var price = document.createElement('div');
|
|
||||||
price.className = 'xshop-product-card__price';
|
|
||||||
price.innerHTML = item.price_html; // trusted from Woo
|
|
||||||
body.appendChild(price);
|
|
||||||
}
|
|
||||||
var meta = document.createElement('div');
|
|
||||||
meta.className = 'xshop-product-card__meta';
|
|
||||||
if (item.type === 'variable') {
|
|
||||||
var v = document.createElement('span');
|
|
||||||
v.textContent = 'Variable';
|
|
||||||
meta.appendChild(v);
|
|
||||||
}
|
|
||||||
if (!item.in_stock) {
|
|
||||||
var s = document.createElement('span');
|
|
||||||
s.textContent = 'Out of stock';
|
|
||||||
meta.appendChild(s);
|
|
||||||
}
|
|
||||||
body.appendChild(meta);
|
|
||||||
var actions = document.createElement('div');
|
|
||||||
actions.className = 'xshop-product-card__actions';
|
|
||||||
var btn = document.createElement('a');
|
|
||||||
btn.href = item.url;
|
|
||||||
btn.className = 'button product_type_' + item.type;
|
|
||||||
btn.textContent = item.in_stock ? 'View' : 'Read more';
|
|
||||||
actions.appendChild(btn);
|
|
||||||
body.appendChild(actions);
|
|
||||||
card.appendChild(body);
|
|
||||||
wrap.appendChild(card);
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initFilters() {
|
|
||||||
var filtersRoots = document.querySelectorAll('[data-xshop-filters]');
|
|
||||||
var filtersRoot = filtersRoots[0];
|
|
||||||
var grid = document.querySelector('.xshop-products') || document.querySelector('[data-xshop-products]');
|
|
||||||
var resultsCountEl = document.querySelector('.woocommerce-result-count');
|
|
||||||
var paginationEl = document.querySelector('.woocommerce-pagination') || document.querySelector('[data-xshop-pagination]');
|
|
||||||
var orderingSelect = document.querySelector('.woocommerce-ordering select.orderby') || document.querySelector('[data-filter="orderby"]');
|
|
||||||
var chipsEl = document.querySelector('[data-xshop-chips]');
|
|
||||||
var forms = document.querySelectorAll('[data-xshop-filters-form]');
|
|
||||||
var form = forms[0];
|
|
||||||
var drawer = document.querySelector('[data-xshop-drawer]');
|
|
||||||
var drawerToggle = document.querySelector('[data-xshop-drawer-toggle]');
|
|
||||||
var drawerClose = document.querySelector('[data-xshop-drawer-close]');
|
|
||||||
var overlay = document.querySelector('[data-xshop-drawer-overlay]');
|
|
||||||
|
|
||||||
if (!filtersRoot) return;
|
|
||||||
// grid may be missing on empty category (has empty state) — create container if needed
|
|
||||||
var gridContainer = grid ? grid.parentElement : document.querySelector('.xshop-woo-layout') || document.body;
|
|
||||||
|
|
||||||
var state = parseStateFromURL();
|
|
||||||
var abortCtrl = null;
|
|
||||||
var seq = 0;
|
|
||||||
var isFirstLoad = true;
|
|
||||||
|
|
||||||
function syncFormFromState() {
|
|
||||||
forms.forEach(function (f) {
|
|
||||||
f.querySelectorAll('input[name="category"]').forEach(function (el) {
|
|
||||||
el.checked = el.value === state.category;
|
|
||||||
if (!state.category && el.value === '') el.checked = true;
|
|
||||||
});
|
|
||||||
f.querySelectorAll('input[data-filter="attribute"]').forEach(function (el) {
|
|
||||||
var tax = el.getAttribute('data-tax');
|
|
||||||
var val = el.value;
|
|
||||||
var active = state.attributes[tax] && state.attributes[tax].indexOf(val) !== -1;
|
|
||||||
el.checked = !!active;
|
|
||||||
});
|
|
||||||
var minEl = f.querySelector('input[name="min_price"]');
|
|
||||||
var maxEl = f.querySelector('input[name="max_price"]');
|
|
||||||
if (minEl) minEl.value = state.min_price || '';
|
|
||||||
if (maxEl) maxEl.value = state.max_price || '';
|
|
||||||
f.querySelectorAll('input[name="stock"]').forEach(function (el) { el.checked = el.value === (state.stock || 'all'); });
|
|
||||||
var saleEl = f.querySelector('input[name="on_sale"]');
|
|
||||||
if (saleEl) saleEl.checked = state.on_sale === '1';
|
|
||||||
f.querySelectorAll('input[name="rating"]').forEach(function (el) { el.checked = el.value === String(state.rating || '0'); });
|
|
||||||
var ob = f.querySelector('select[name="orderby"]');
|
|
||||||
if (ob) ob.value = state.orderby || 'default';
|
|
||||||
});
|
|
||||||
if (orderingSelect) orderingSelect.value = state.orderby || 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStateFromForm(srcForm) {
|
|
||||||
var f = srcForm || form;
|
|
||||||
if (!f) return state;
|
|
||||||
var fd = new FormData(f);
|
|
||||||
var newState = {
|
|
||||||
search: state.search,
|
|
||||||
category: (fd.get('category') || '').toString().trim(),
|
|
||||||
min_price: (fd.get('min_price') || '').toString().trim(),
|
|
||||||
max_price: (fd.get('max_price') || '').toString().trim(),
|
|
||||||
stock: (fd.get('stock') || 'all').toString(),
|
|
||||||
on_sale: fd.get('on_sale') === '1' ? '1' : '0',
|
|
||||||
rating: (fd.get('rating') || '0').toString(),
|
|
||||||
orderby: (fd.get('orderby') || 'default').toString(),
|
|
||||||
page: 1,
|
|
||||||
per_page: state.per_page,
|
|
||||||
attributes: {}
|
|
||||||
};
|
|
||||||
// Collect checked attributes from ALL forms (since sidebar+drawer duplicate)
|
|
||||||
document.querySelectorAll('input[data-filter="attribute"]:checked').forEach(function (el) {
|
|
||||||
var tax = el.getAttribute('data-tax');
|
|
||||||
if (!newState.attributes[tax]) newState.attributes[tax] = [];
|
|
||||||
if (newState.attributes[tax].indexOf(el.value) === -1) newState.attributes[tax].push(el.value);
|
|
||||||
});
|
|
||||||
return newState;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChips() {
|
|
||||||
if (!chipsEl) return;
|
|
||||||
chipsEl.innerHTML = '';
|
|
||||||
var hasActive = false;
|
|
||||||
function addChip(label, onRemove) {
|
|
||||||
hasActive = true;
|
|
||||||
var chip = document.createElement('button');
|
|
||||||
chip.type = 'button';
|
|
||||||
chip.className = 'xshop-chip';
|
|
||||||
chip.textContent = label + ' ×';
|
|
||||||
chip.setAttribute('aria-label', 'Remove filter ' + label);
|
|
||||||
chip.addEventListener('click', onRemove);
|
|
||||||
chipsEl.appendChild(chip);
|
|
||||||
}
|
|
||||||
if (state.category) {
|
|
||||||
addChip('Category: ' + state.category, function () {
|
|
||||||
state.category = '';
|
|
||||||
state.page = 1;
|
|
||||||
syncAndFetch(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Object.keys(state.attributes).forEach(function (tax) {
|
|
||||||
(state.attributes[tax] || []).forEach(function (term) {
|
|
||||||
addChip(tax.replace('pa_', '') + ': ' + term, function () {
|
|
||||||
var idx = state.attributes[tax].indexOf(term);
|
|
||||||
if (idx !== -1) state.attributes[tax].splice(idx, 1);
|
|
||||||
if (!state.attributes[tax].length) delete state.attributes[tax];
|
|
||||||
state.page = 1;
|
|
||||||
syncAndFetch(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (state.min_price) addChip('Min ' + state.min_price, function () { state.min_price = ''; state.page = 1; syncAndFetch(true); });
|
|
||||||
if (state.max_price) addChip('Max ' + state.max_price, function () { state.max_price = ''; state.page = 1; syncAndFetch(true); });
|
|
||||||
if (state.stock && state.stock !== 'all') addChip('Stock: ' + state.stock, function () { state.stock = 'all'; state.page = 1; syncAndFetch(true); });
|
|
||||||
if (state.on_sale === '1') addChip('On sale', function () { state.on_sale = '0'; state.page = 1; syncAndFetch(true); });
|
|
||||||
if (state.rating && state.rating !== '0') addChip('Rating ' + state.rating + '+', function () { state.rating = '0'; state.page = 1; syncAndFetch(true); });
|
|
||||||
if (state.search) addChip('Search: ' + state.search, function () { state.search = ''; state.page = 1; syncAndFetch(true); });
|
|
||||||
|
|
||||||
if (hasActive) {
|
|
||||||
var clearAll = document.createElement('button');
|
|
||||||
clearAll.type = 'button';
|
|
||||||
clearAll.className = 'xshop-chip xshop-chip--clear';
|
|
||||||
clearAll.textContent = 'Clear all';
|
|
||||||
clearAll.addEventListener('click', function () {
|
|
||||||
state = { search: '', category: '', min_price: '', max_price: '', stock: 'all', on_sale: '0', rating: '0', orderby: 'default', page: 1, per_page: state.per_page, attributes: {} };
|
|
||||||
// Preserve category context? If on category archive, clearing all should keep category context? We clear category context as well per "clear all" semantics (go to shop). If on category page, keep category? For now clear all goes to shop base (no category)
|
|
||||||
syncAndFetch(true);
|
|
||||||
});
|
|
||||||
chipsEl.appendChild(clearAll);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateURL(push) {
|
|
||||||
var p = stateToParams(state);
|
|
||||||
var url = window.location.pathname + (p.toString() ? '?' + p.toString() : '');
|
|
||||||
if (push) {
|
|
||||||
history.pushState(state, '', url);
|
|
||||||
} else {
|
|
||||||
history.replaceState(state, '', url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLoading(loading) {
|
|
||||||
if (!gridContainer) return;
|
|
||||||
gridContainer.setAttribute('aria-busy', loading ? 'true' : 'false');
|
|
||||||
var loader = document.querySelector('[data-xshop-loading]');
|
|
||||||
if (loader) loader.hidden = !loading;
|
|
||||||
// toolbar still usable
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderResults(data) {
|
|
||||||
var items = data.items || [];
|
|
||||||
var pagination = data.pagination || { page: 1, per_page: 12, total: 0, total_pages: 1 };
|
|
||||||
|
|
||||||
// Ensure grid exists
|
|
||||||
var targetGrid = document.querySelector('.xshop-products');
|
|
||||||
if (!targetGrid) {
|
|
||||||
// Create grid if was empty state
|
|
||||||
var emptyState = document.querySelector('.xshop-empty');
|
|
||||||
if (emptyState) emptyState.remove();
|
|
||||||
targetGrid = document.createElement('ul');
|
|
||||||
targetGrid.className = 'xshop-products products columns-4';
|
|
||||||
if (gridContainer) gridContainer.appendChild(targetGrid);
|
|
||||||
}
|
|
||||||
if (targetGrid) {
|
|
||||||
targetGrid.innerHTML = '';
|
|
||||||
if (!items.length) {
|
|
||||||
// Empty state
|
|
||||||
targetGrid.remove();
|
|
||||||
var empty = document.createElement('div');
|
|
||||||
empty.className = 'xshop-empty';
|
|
||||||
empty.innerHTML = '<h3 class="xshop-empty__title">No products found</h3><p class="xshop-empty__text">Try adjusting your filters.</p>';
|
|
||||||
if (gridContainer) gridContainer.appendChild(empty);
|
|
||||||
} else {
|
|
||||||
items.forEach(function (it) {
|
|
||||||
targetGrid.appendChild(renderProductCard(it));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result count
|
|
||||||
if (resultsCountEl) {
|
|
||||||
resultsCountEl.textContent = pagination.total + ' products';
|
|
||||||
} else {
|
|
||||||
var rc = document.querySelector('.woocommerce-result-count');
|
|
||||||
if (rc) rc.textContent = pagination.total + ' products';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
renderPagination(pagination);
|
|
||||||
renderChips();
|
|
||||||
// Focus management: move focus to grid for screen readers
|
|
||||||
if (targetGrid && !isFirstLoad) {
|
|
||||||
targetGrid.setAttribute('tabindex', '-1');
|
|
||||||
// Don't steal focus aggressively; just ensure aria-live
|
|
||||||
}
|
|
||||||
isFirstLoad = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPagination(pag) {
|
|
||||||
var container = paginationEl || document.querySelector('.woocommerce-pagination');
|
|
||||||
if (!container) return;
|
|
||||||
container.innerHTML = '';
|
|
||||||
if (pag.total_pages <= 1) return;
|
|
||||||
var nav = document.createElement('nav');
|
|
||||||
nav.setAttribute('aria-label', 'Pagination');
|
|
||||||
nav.className = 'xshop-pagination';
|
|
||||||
var inner = document.createElement('div');
|
|
||||||
inner.className = 'nav-links';
|
|
||||||
for (var i = 1; i <= pag.total_pages; i++) {
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'page-numbers' + (i === pag.page ? ' current' : '');
|
|
||||||
btn.textContent = String(i);
|
|
||||||
btn.setAttribute('aria-label', 'Page ' + i);
|
|
||||||
if (i === pag.page) btn.setAttribute('aria-current', 'page');
|
|
||||||
(function (pageNum) {
|
|
||||||
btn.addEventListener('click', function () {
|
|
||||||
state.page = pageNum;
|
|
||||||
syncAndFetch(true);
|
|
||||||
});
|
|
||||||
})(i);
|
|
||||||
inner.appendChild(btn);
|
|
||||||
}
|
|
||||||
nav.appendChild(inner);
|
|
||||||
container.appendChild(nav);
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncAndFetch(pushURL) {
|
|
||||||
syncFormFromState(); // ensure form reflects state (for back/forward)
|
|
||||||
renderChips();
|
|
||||||
updateURL(pushURL);
|
|
||||||
fetchAndRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
var debouncedPriceFetch = debounce(function () {
|
|
||||||
state.page = 1;
|
|
||||||
syncAndFetch(true);
|
|
||||||
}, DEBOUNCE_MS);
|
|
||||||
|
|
||||||
function fetchAndRender() {
|
|
||||||
var mySeq = ++seq;
|
|
||||||
if (abortCtrl) abortCtrl.abort();
|
|
||||||
abortCtrl = new AbortController();
|
|
||||||
setLoading(true);
|
|
||||||
var url = stateToEndpoint(state);
|
|
||||||
// Update URL already done
|
|
||||||
fetch(url, { signal: abortCtrl.signal, headers: { 'Accept': 'application/json' } })
|
|
||||||
.then(function (res) {
|
|
||||||
if (!res.ok) throw new Error('http ' + res.status);
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then(function (data) {
|
|
||||||
if (mySeq !== seq) return; // stale
|
|
||||||
setLoading(false);
|
|
||||||
renderResults(data);
|
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
if (err && err.name === 'AbortError') return;
|
|
||||||
if (mySeq !== seq) return;
|
|
||||||
setLoading(false);
|
|
||||||
// show error, keep existing grid
|
|
||||||
var errEl = document.querySelector('[data-xshop-error]');
|
|
||||||
if (errEl) {
|
|
||||||
errEl.hidden = false;
|
|
||||||
errEl.textContent = 'Could not load products. Please try again.';
|
|
||||||
setTimeout(function () { errEl.hidden = true; }, 3000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach change listeners to all forms
|
|
||||||
forms.forEach(function (f) {
|
|
||||||
f.addEventListener('change', function (e) {
|
|
||||||
var target = e.target;
|
|
||||||
if (!target) return;
|
|
||||||
if (target.getAttribute('data-filter') === 'price') return;
|
|
||||||
var newState = buildStateFromForm(f);
|
|
||||||
newState.search = state.search;
|
|
||||||
newState.per_page = state.per_page;
|
|
||||||
state = newState;
|
|
||||||
syncAndFetch(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
var priceInputs = document.querySelectorAll('input[name="min_price"], input[name="max_price"]');
|
|
||||||
priceInputs.forEach(function (el) {
|
|
||||||
el.addEventListener('input', function () {
|
|
||||||
// Read from closest form
|
|
||||||
var f = el.closest('form');
|
|
||||||
var fd = f ? new FormData(f) : new FormData(form);
|
|
||||||
state.min_price = (fd.get('min_price') || '').toString().trim();
|
|
||||||
state.max_price = (fd.get('max_price') || '').toString().trim();
|
|
||||||
state.page = 1;
|
|
||||||
renderChips();
|
|
||||||
updateURL(true);
|
|
||||||
debouncedPriceFetch();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ordering select (toolbar and form)
|
|
||||||
if (orderingSelect) {
|
|
||||||
orderingSelect.addEventListener('change', function () {
|
|
||||||
state.orderby = orderingSelect.value || 'default';
|
|
||||||
state.page = 1;
|
|
||||||
// sync form select too
|
|
||||||
var formOb = form.querySelector('select[name="orderby"]');
|
|
||||||
if (formOb) formOb.value = state.orderby;
|
|
||||||
syncAndFetch(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
var formOrderby = form.querySelector('select[name="orderby"]');
|
|
||||||
if (formOrderby && formOrderby !== orderingSelect) {
|
|
||||||
formOrderby.addEventListener('change', function () {
|
|
||||||
state.orderby = formOrderby.value || 'default';
|
|
||||||
if (orderingSelect) orderingSelect.value = state.orderby;
|
|
||||||
state.page = 1;
|
|
||||||
syncAndFetch(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
forms.forEach(function (f) {
|
|
||||||
f.addEventListener('submit', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var newState = buildStateFromForm(f);
|
|
||||||
newState.search = state.search;
|
|
||||||
newState.per_page = state.per_page;
|
|
||||||
state = newState;
|
|
||||||
syncAndFetch(true);
|
|
||||||
closeDrawer();
|
|
||||||
});
|
|
||||||
var clearAllLink = f.querySelector('[data-xshop-clear-all]');
|
|
||||||
if (clearAllLink) {
|
|
||||||
clearAllLink.addEventListener('click', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
state = { search: state.search, category: '', min_price: '', max_price: '', stock: 'all', on_sale: '0', rating: '0', orderby: 'default', page: 1, per_page: state.per_page, attributes: {} };
|
|
||||||
syncAndFetch(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drawer
|
|
||||||
function openDrawer() {
|
|
||||||
if (!drawer) return;
|
|
||||||
drawer.hidden = false;
|
|
||||||
drawer.classList.add('is-open');
|
|
||||||
if (overlay) overlay.hidden = false;
|
|
||||||
var first = drawer.querySelector('input, select, button');
|
|
||||||
if (first) first.focus();
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
if (drawerToggle) drawerToggle.setAttribute('aria-expanded', 'true');
|
|
||||||
}
|
|
||||||
function closeDrawer() {
|
|
||||||
if (!drawer) return;
|
|
||||||
drawer.hidden = true;
|
|
||||||
drawer.classList.remove('is-open');
|
|
||||||
if (overlay) overlay.hidden = true;
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
if (drawerToggle) {
|
|
||||||
drawerToggle.setAttribute('aria-expanded', 'false');
|
|
||||||
drawerToggle.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (drawerToggle) {
|
|
||||||
drawerToggle.addEventListener('click', function () {
|
|
||||||
var isOpen = drawer && !drawer.hidden;
|
|
||||||
if (isOpen) closeDrawer(); else openDrawer();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (drawerClose) drawerClose.addEventListener('click', closeDrawer);
|
|
||||||
if (overlay) overlay.addEventListener('click', closeDrawer);
|
|
||||||
document.addEventListener('keydown', function (e) {
|
|
||||||
if (e.key === 'Escape' && drawer && !drawer.hidden) closeDrawer();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Browser back/forward
|
|
||||||
window.addEventListener('popstate', function (e) {
|
|
||||||
var newState = e.state ? e.state : parseStateFromURL();
|
|
||||||
// e.state may be null on initial load; parse from URL
|
|
||||||
if (!e.state) newState = parseStateFromURL();
|
|
||||||
state = newState;
|
|
||||||
syncFormFromState();
|
|
||||||
renderChips();
|
|
||||||
fetchAndRender();
|
|
||||||
// Don't push URL again
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial: parse URL, sync form, render chips, but don't fetch if server-rendered matches? We will fetch to ensure AJAX grid matches server (in case of direct URL with filters, server already rendered correctly, but we want to ensure pagination etc sync). To avoid double fetch on initial load with no filters, we skip fetch if no filters and not paginated.
|
|
||||||
syncFormFromState();
|
|
||||||
renderChips();
|
|
||||||
// If URL has any filter param (category/attributes/price etc) or page>1 or search, we should ensure grid reflects it (server already did if no-JS). But to keep AJAX in sync, we could fetch once if filters present? For now, we only fetch on user interaction; initial server render is sufficient. But to support back/forward after JS navigation, we need fetch on popstate. So initial load does not auto-fetch unless needed.
|
|
||||||
// However, if user lands on /shop/?category=... directly, server already rendered filtered products (no-JS fallback). That's correct. No need to double fetch.
|
|
||||||
|
|
||||||
// Expose for testing
|
|
||||||
window.xshopFilters = { state: state, fetch: fetchAndRender, parse: parseStateFromURL };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initFilters);
|
|
||||||
} else {
|
|
||||||
initFilters();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
/**
|
|
||||||
* XShop AJAX Search — vanilla, debounce 250ms, AbortController, stale protection, a11y keyboard.
|
|
||||||
* Expects markup from template-parts/components/ajax-search.php
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var DEBOUNCE_MS = 250;
|
|
||||||
var MIN_LENGTH = 2;
|
|
||||||
|
|
||||||
function debounce(fn, ms) {
|
|
||||||
var t;
|
|
||||||
return function () {
|
|
||||||
var args = arguments;
|
|
||||||
var ctx = this;
|
|
||||||
clearTimeout(t);
|
|
||||||
t = setTimeout(function () { fn.apply(ctx, args); }, ms);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(s) {
|
|
||||||
var d = document.createElement('div');
|
|
||||||
d.textContent = s;
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initSearch(root) {
|
|
||||||
if (!root || root.dataset.xshopSearchInit) return;
|
|
||||||
root.dataset.xshopSearchInit = '1';
|
|
||||||
|
|
||||||
var input = root.querySelector('[data-xshop-search-input]');
|
|
||||||
var form = root.querySelector('[data-xshop-search-form]');
|
|
||||||
var dropdown = root.querySelector('[data-xshop-search-dropdown]');
|
|
||||||
var resultsEl = root.querySelector('[data-xshop-search-results]');
|
|
||||||
var loadingEl = root.querySelector('[data-xshop-search-loading]');
|
|
||||||
var emptyEl = root.querySelector('[data-xshop-search-empty]');
|
|
||||||
var errorEl = root.querySelector('[data-xshop-search-error]');
|
|
||||||
var viewAllEl = root.querySelector('[data-xshop-search-viewall]');
|
|
||||||
var statusEl = root.querySelector('[data-xshop-search-status]');
|
|
||||||
|
|
||||||
if (!input || !form || !dropdown || !resultsEl) return;
|
|
||||||
|
|
||||||
var limit = parseInt(input.getAttribute('data-limit') || '6', 10);
|
|
||||||
var minLen = parseInt(input.getAttribute('data-min-length') || String(MIN_LENGTH), 10);
|
|
||||||
var abortCtrl = null;
|
|
||||||
var lastQuery = '';
|
|
||||||
var seq = 0;
|
|
||||||
var activeIndex = -1;
|
|
||||||
var items = [];
|
|
||||||
|
|
||||||
var restUrl = (window.xshopData && window.xshopData.restUrl) ? window.xshopData.restUrl : '/wp-json/';
|
|
||||||
// Ensure trailing slash handling: restUrl is like http://.../wp-json/xshop/v1/ or /wp-json/
|
|
||||||
// We need xshop/v1/search
|
|
||||||
var endpoint = restUrl.replace(/\/$/, '') + '/search';
|
|
||||||
// If restUrl already contains xshop/v1/, don't duplicate
|
|
||||||
if (restUrl.indexOf('xshop/v1') !== -1) {
|
|
||||||
endpoint = restUrl.replace(/\/$/, '') + '/search';
|
|
||||||
// restUrl is .../xshop/v1/ -> endpoint is .../xshop/v1/search
|
|
||||||
// If restUrl is .../wp-json/ -> endpoint becomes .../wp-json/search (wrong), fix
|
|
||||||
if (restUrl.indexOf('xshop/v1') === -1) {
|
|
||||||
endpoint = restUrl.replace(/\/$/, '') + '/xshop/v1/search';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Safer: build from origin
|
|
||||||
if (endpoint.indexOf('xshop') === -1) {
|
|
||||||
endpoint = (window.location.origin || '') + '/wp-json/xshop/v1/search';
|
|
||||||
if (window.xshopData && window.xshopData.restUrl) {
|
|
||||||
var base = window.xshopData.restUrl;
|
|
||||||
// base like http://localhost/.../wp-json/ -> append xshop/v1/search
|
|
||||||
if (base.indexOf('/wp-json/') !== -1) {
|
|
||||||
endpoint = base.split('/wp-json/')[0] + '/wp-json/xshop/v1/search';
|
|
||||||
// Preserve subpath like /xshop-test/wordpress/wp-json/
|
|
||||||
// base is http://localhost/xshop-test/wordpress/wp-json/ or .../xshop/v1/
|
|
||||||
if (base.indexOf('xshop/v1') !== -1) {
|
|
||||||
endpoint = base.replace(/\/$/, '') + '/search';
|
|
||||||
} else {
|
|
||||||
endpoint = base.replace(/\/$/, '') + '/xshop/v1/search';
|
|
||||||
// if base already is wp-json/, this works; if base is wp-json/xshop/v1/ this duplicates
|
|
||||||
if (endpoint.indexOf('/xshop/v1/xshop/v1/') !== -1) {
|
|
||||||
endpoint = endpoint.replace('/xshop/v1/xshop/v1/', '/xshop/v1/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setExpanded(expanded) {
|
|
||||||
input.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
||||||
dropdown.hidden = !expanded;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLoading(loading) {
|
|
||||||
if (loadingEl) loadingEl.hidden = !loading;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showEmpty(show) {
|
|
||||||
if (emptyEl) emptyEl.hidden = !show;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(show) {
|
|
||||||
if (errorEl) errorEl.hidden = !show;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateViewAll(query) {
|
|
||||||
if (!viewAllEl) return;
|
|
||||||
if (!query || query.length < minLen) {
|
|
||||||
viewAllEl.hidden = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var url = form.getAttribute('action') || '/';
|
|
||||||
var q = encodeURIComponent(query);
|
|
||||||
viewAllEl.href = url + (url.indexOf('?') === -1 ? '?' : '&') + 's=' + q + '&post_type=product';
|
|
||||||
viewAllEl.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearResults() {
|
|
||||||
resultsEl.innerHTML = '';
|
|
||||||
items = [];
|
|
||||||
activeIndex = -1;
|
|
||||||
input.removeAttribute('aria-activedescendant');
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(itemsData, query) {
|
|
||||||
clearResults();
|
|
||||||
showEmpty(false);
|
|
||||||
showError(false);
|
|
||||||
if (!itemsData.length) {
|
|
||||||
showEmpty(true);
|
|
||||||
if (statusEl) statusEl.textContent = (window.xshopData && window.xshopData.i18n && window.xshopData.i18n.noResults) ? window.xshopData.i18n.noResults : 'No results';
|
|
||||||
updateViewAll(query);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (statusEl) statusEl.textContent = itemsData.length + ' results';
|
|
||||||
items = itemsData;
|
|
||||||
itemsData.forEach(function (it, idx) {
|
|
||||||
var li = document.createElement('li');
|
|
||||||
li.setAttribute('role', 'option');
|
|
||||||
li.id = input.id + '-option-' + idx;
|
|
||||||
li.className = 'xshop-search__item';
|
|
||||||
if (idx === activeIndex) li.setAttribute('aria-selected', 'true');
|
|
||||||
// Build via DOM to avoid innerHTML XSS for price_html (Woo price_html is trusted but we still sanitize via allowed)
|
|
||||||
var a = document.createElement('a');
|
|
||||||
a.href = it.url;
|
|
||||||
a.className = 'xshop-search__link';
|
|
||||||
// image
|
|
||||||
if (it.image) {
|
|
||||||
var img = document.createElement('img');
|
|
||||||
img.src = it.image;
|
|
||||||
img.alt = '';
|
|
||||||
img.loading = 'lazy';
|
|
||||||
img.decoding = 'async';
|
|
||||||
img.className = 'xshop-search__thumb';
|
|
||||||
a.appendChild(img);
|
|
||||||
}
|
|
||||||
var body = document.createElement('span');
|
|
||||||
body.className = 'xshop-search__body';
|
|
||||||
var title = document.createElement('span');
|
|
||||||
title.className = 'xshop-search__title';
|
|
||||||
title.textContent = it.title;
|
|
||||||
body.appendChild(title);
|
|
||||||
if (it.price_html) {
|
|
||||||
var price = document.createElement('span');
|
|
||||||
price.className = 'xshop-search__price';
|
|
||||||
// price_html from Woo is HTML; we allow it but strip scripts via text? Use innerHTML with sanitization: only allow Woo price spans
|
|
||||||
// Since price_html is from trusted server (Woo), we can use innerHTML after ensuring no script
|
|
||||||
price.innerHTML = it.price_html; // trusted
|
|
||||||
body.appendChild(price);
|
|
||||||
}
|
|
||||||
if (!it.in_stock) {
|
|
||||||
var stock = document.createElement('span');
|
|
||||||
stock.className = 'xshop-search__stock';
|
|
||||||
stock.textContent = 'Out of stock';
|
|
||||||
body.appendChild(stock);
|
|
||||||
}
|
|
||||||
a.appendChild(body);
|
|
||||||
li.appendChild(a);
|
|
||||||
// Click closes
|
|
||||||
li.addEventListener('mousedown', function (e) {
|
|
||||||
// prevent input blur before click
|
|
||||||
e.preventDefault();
|
|
||||||
});
|
|
||||||
resultsEl.appendChild(li);
|
|
||||||
});
|
|
||||||
updateViewAll(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setActive(idx) {
|
|
||||||
var lis = resultsEl.querySelectorAll('[role="option"]');
|
|
||||||
lis.forEach(function (li, i) {
|
|
||||||
if (i === idx) {
|
|
||||||
li.classList.add('is-active');
|
|
||||||
li.setAttribute('aria-selected', 'true');
|
|
||||||
input.setAttribute('aria-activedescendant', li.id);
|
|
||||||
} else {
|
|
||||||
li.classList.remove('is-active');
|
|
||||||
li.setAttribute('aria-selected', 'false');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
activeIndex = idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDropdown() {
|
|
||||||
setExpanded(false);
|
|
||||||
setLoading(false);
|
|
||||||
showError(false);
|
|
||||||
// keep results for next open? Clear on next input
|
|
||||||
input.removeAttribute('aria-activedescendant');
|
|
||||||
activeIndex = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDropdown() {
|
|
||||||
setExpanded(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
var doSearch = debounce(function () {
|
|
||||||
var q = input.value.trim();
|
|
||||||
if (q.length < minLen) {
|
|
||||||
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
|
|
||||||
clearResults();
|
|
||||||
showEmpty(false);
|
|
||||||
showError(false);
|
|
||||||
setLoading(false);
|
|
||||||
setExpanded(false);
|
|
||||||
updateViewAll('');
|
|
||||||
if (statusEl) statusEl.textContent = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// dedupe identical query
|
|
||||||
if (q === lastQuery && items.length) {
|
|
||||||
openDropdown();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastQuery = q;
|
|
||||||
var mySeq = ++seq;
|
|
||||||
if (abortCtrl) abortCtrl.abort();
|
|
||||||
abortCtrl = new AbortController();
|
|
||||||
setLoading(true);
|
|
||||||
showError(false);
|
|
||||||
showEmpty(false);
|
|
||||||
openDropdown();
|
|
||||||
if (statusEl) statusEl.textContent = 'Searching';
|
|
||||||
|
|
||||||
var url = endpoint + '?search=' + encodeURIComponent(q) + '&limit=' + encodeURIComponent(String(limit));
|
|
||||||
fetch(url, { signal: abortCtrl.signal, headers: { 'Accept': 'application/json' } })
|
|
||||||
.then(function (res) {
|
|
||||||
if (!res.ok) throw new Error('http ' + res.status);
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then(function (data) {
|
|
||||||
if (mySeq !== seq) return; // stale
|
|
||||||
setLoading(false);
|
|
||||||
var list = (data && data.items) ? data.items : [];
|
|
||||||
render(list, q);
|
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
if (err && err.name === 'AbortError') return;
|
|
||||||
if (mySeq !== seq) return;
|
|
||||||
setLoading(false);
|
|
||||||
showError(true);
|
|
||||||
if (statusEl) statusEl.textContent = 'Error';
|
|
||||||
});
|
|
||||||
}, DEBOUNCE_MS);
|
|
||||||
|
|
||||||
input.addEventListener('input', doSearch);
|
|
||||||
input.addEventListener('focus', function () {
|
|
||||||
if (input.value.trim().length >= minLen && resultsEl.children.length) openDropdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
input.addEventListener('keydown', function (e) {
|
|
||||||
var lis = resultsEl.querySelectorAll('[role="option"]');
|
|
||||||
if (!lis.length) {
|
|
||||||
if (e.key === 'Escape') closeDropdown();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
var next = activeIndex + 1;
|
|
||||||
if (next >= lis.length) next = 0;
|
|
||||||
setActive(next);
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
var prev = activeIndex - 1;
|
|
||||||
if (prev < 0) prev = lis.length - 1;
|
|
||||||
setActive(prev);
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
if (activeIndex >= 0 && lis[activeIndex]) {
|
|
||||||
e.preventDefault();
|
|
||||||
var link = lis[activeIndex].querySelector('a');
|
|
||||||
if (link) window.location.href = link.href;
|
|
||||||
} else {
|
|
||||||
// let form submit to canonical search page
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
closeDropdown();
|
|
||||||
input.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click outside to close
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
if (!root.contains(e.target)) closeDropdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form submit: let it go to ?s=...&post_type=product (canonical). But ensure we close dropdown.
|
|
||||||
form.addEventListener('submit', function () {
|
|
||||||
closeDropdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
// View all link
|
|
||||||
if (viewAllEl) {
|
|
||||||
viewAllEl.addEventListener('click', function () {
|
|
||||||
closeDropdown();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initAll() {
|
|
||||||
document.querySelectorAll('[data-xshop-search]').forEach(initSearch);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initAll);
|
|
||||||
} else {
|
|
||||||
initAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose for testing
|
|
||||||
window.xshopSearch = { init: initSearch, debounceMs: DEBOUNCE_MS };
|
|
||||||
})();
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
/**
|
|
||||||
* XShop theme bootstrap – vanilla, no jQuery.
|
|
||||||
* Feature modules are dynamically imported when DOM markers are present.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Dark mode toggle via [data-theme] if enabled.
|
|
||||||
function initThemeMode() {
|
|
||||||
var data = window.xshopData;
|
|
||||||
if (!data) return;
|
|
||||||
// Theme mode is server-rendered via data-theme on <html>; this handles toggle clicks.
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
var btn = e.target.closest('[data-xshop-theme-toggle]');
|
|
||||||
if (!btn) return;
|
|
||||||
var html = document.documentElement;
|
|
||||||
var next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
|
||||||
html.setAttribute('data-theme', next);
|
|
||||||
try { localStorage.setItem('xshop_theme', next); } catch (_) {}
|
|
||||||
// Cookie for server-side rendering on next request.
|
|
||||||
document.cookie = 'xshop_theme=' + next + '; path=/; max-age=2592000; SameSite=Lax';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal focus trap helper for drawers/modals (used by later modules).
|
|
||||||
window.xshopTrapFocus = function (container) {
|
|
||||||
var sel = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
||||||
var nodes = container.querySelectorAll(sel);
|
|
||||||
if (!nodes.length) return function () {};
|
|
||||||
var first = nodes[0], last = nodes[nodes.length - 1];
|
|
||||||
function onKey(e) {
|
|
||||||
if (e.key !== 'Tab') return;
|
|
||||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
||||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
||||||
}
|
|
||||||
container.addEventListener('keydown', onKey);
|
|
||||||
return function () { container.removeEventListener('keydown', onKey); };
|
|
||||||
};
|
|
||||||
|
|
||||||
function initMobileNav() {
|
|
||||||
var toggle = document.querySelector('[data-xshop-mobile-toggle]');
|
|
||||||
var nav = document.getElementById('xshop-mobile-nav');
|
|
||||||
if (!toggle || !nav) return;
|
|
||||||
var releaseTrap = null;
|
|
||||||
function setOpen(open) {
|
|
||||||
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
||||||
nav.classList.toggle('is-open', open);
|
|
||||||
nav.hidden = !open;
|
|
||||||
if (open) {
|
|
||||||
// Move focus into nav.
|
|
||||||
var firstLink = nav.querySelector('a, button');
|
|
||||||
if (firstLink) firstLink.focus();
|
|
||||||
if (window.xshopTrapFocus) releaseTrap = window.xshopTrapFocus(nav);
|
|
||||||
document.addEventListener('keydown', onDocKey);
|
|
||||||
} else {
|
|
||||||
if (releaseTrap) { releaseTrap(); releaseTrap = null; }
|
|
||||||
document.removeEventListener('keydown', onDocKey);
|
|
||||||
toggle.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function onDocKey(e) {
|
|
||||||
if (e.key === 'Escape') setOpen(false);
|
|
||||||
}
|
|
||||||
toggle.addEventListener('click', function () {
|
|
||||||
var open = toggle.getAttribute('aria-expanded') === 'true';
|
|
||||||
setOpen(!open);
|
|
||||||
});
|
|
||||||
// Close when clicking outside.
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
if (nav.hidden) return;
|
|
||||||
if (nav.contains(e.target) || toggle.contains(e.target)) return;
|
|
||||||
setOpen(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
initThemeMode();
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initMobileNav);
|
|
||||||
} else {
|
|
||||||
initMobileNav();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Comments — WP native, threaded, paginated, accessible.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
if (post_password_required()) {
|
|
||||||
echo '<p class="xshop-notice">' . esc_html__('This post is password protected. Enter the password to view comments.', 'xshop') . '</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hasComments = have_comments();
|
|
||||||
$commentsOpen = comments_open();
|
|
||||||
$commentsNumber = get_comments_number();
|
|
||||||
?>
|
|
||||||
<section id="comments" class="xshop-comments" aria-label="<?php esc_attr_e('Comments', 'xshop'); ?>">
|
|
||||||
<?php if ($hasComments): ?>
|
|
||||||
<h2 class="xshop-comments__title">
|
|
||||||
<?php
|
|
||||||
printf(
|
|
||||||
esc_html(_n('%s Comment', '%s Comments', (int) $commentsNumber, 'xshop')),
|
|
||||||
esc_html(number_format_i18n((int) $commentsNumber))
|
|
||||||
);
|
|
||||||
?>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<ol class="xshop-comment-list">
|
|
||||||
<?php
|
|
||||||
wp_list_comments([
|
|
||||||
'style' => 'ol',
|
|
||||||
'short_ping' => true,
|
|
||||||
'avatar_size' => 40,
|
|
||||||
'max_depth' => (int) get_option('thread_comments_depth', 5),
|
|
||||||
]);
|
|
||||||
?>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
the_comments_pagination([
|
|
||||||
'prev_text' => is_rtl() ? esc_html__('Next', 'xshop') : esc_html__('Previous', 'xshop'),
|
|
||||||
'next_text' => is_rtl() ? esc_html__('Previous', 'xshop') : esc_html__('Next', 'xshop'),
|
|
||||||
'class' => 'xshop-pagination',
|
|
||||||
]);
|
|
||||||
?>
|
|
||||||
|
|
||||||
<?php if (!$commentsOpen): ?>
|
|
||||||
<p class="xshop-notice"><?php esc_html_e('Comments are closed.', 'xshop'); ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php else: ?>
|
|
||||||
<?php if ($commentsOpen): ?>
|
|
||||||
<h2 class="xshop-comments__title"><?php esc_html_e('Leave a comment', 'xshop'); ?></h2>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php if (!is_page()): ?>
|
|
||||||
<p class="xshop-notice"><?php esc_html_e('Comments are closed.', 'xshop'); ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
comment_form([
|
|
||||||
'title_reply_before' => '<h3 id="reply-title" class="xshop-comments__title">',
|
|
||||||
'title_reply_after' => '</h3>',
|
|
||||||
'class_submit' => 'xshop-btn xshop-btn--primary',
|
|
||||||
'submit_button' => '<button name="%1$s" type="submit" id="%2$s" class="%3$s">%4$s</button>',
|
|
||||||
]);
|
|
||||||
?>
|
|
||||||
</section>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Footer — widgets, nav, copyright.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
?>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="xshop-footer" role="contentinfo">
|
|
||||||
<?php get_template_part('template-parts/components/site-footer'); ?>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<?php wp_footer(); ?>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Front Page — extensible section architecture.
|
|
||||||
* Static front page: hero + latest posts + promo foundations.
|
|
||||||
* Blog front page: delegates to index behavior.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
if (is_home()) {
|
|
||||||
// When front page shows blog posts, behave like index.
|
|
||||||
get_template_part('index');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
get_header();
|
|
||||||
?>
|
|
||||||
<div class="xshop-container xshop-stack">
|
|
||||||
|
|
||||||
<?php
|
|
||||||
// Hero — filterable so child theme / builder can replace.
|
|
||||||
$hero = apply_filters('xshop_front_hero_args', [
|
|
||||||
'title' => get_bloginfo('name') ? get_bloginfo('name') : esc_html__('XShop — Foundation', 'xshop'),
|
|
||||||
'text' => get_bloginfo('description') ? get_bloginfo('description') : esc_html__('A polished, RTL-ready WooCommerce foundation. Your shop content will appear here.', 'xshop'),
|
|
||||||
'cta_label' => esc_html__('Browse Shop', 'xshop'),
|
|
||||||
'cta_url' => function_exists('wc_get_page_permalink') ? wc_get_page_permalink('shop') : home_url('/'),
|
|
||||||
]);
|
|
||||||
get_template_part('template-parts/components/hero', null, $hero);
|
|
||||||
?>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
// Category / content grid foundation — shows top categories when available, else latest content placeholder.
|
|
||||||
$cats = get_terms(['taxonomy' => 'category', 'hide_empty' => true, 'number' => 6]);
|
|
||||||
if (!is_wp_error($cats) && !empty($cats)):
|
|
||||||
?>
|
|
||||||
<section class="xshop-section" aria-labelledby="xshop-cat-heading">
|
|
||||||
<?php get_template_part('template-parts/components/section-heading', null, ['title' => esc_html__('Categories', 'xshop'), 'tag' => 'h2']); ?>
|
|
||||||
<div class="xshop-grid" style="grid-template-columns:repeat(auto-fill,minmax(160px,1fr))">
|
|
||||||
<?php foreach ($cats as $cat): ?>
|
|
||||||
<a class="xshop-card" href="<?php echo esc_url(get_term_link($cat)); ?>" style="padding:var(--xshop-space-4);text-align:center">
|
|
||||||
<strong><?php echo esc_html($cat->name); ?></strong>
|
|
||||||
<span class="xshop-text-muted" style="font-size:var(--xshop-text-sm)"><?php echo esc_html(sprintf(esc_html__('%s posts', 'xshop'), number_format_i18n((int) $cat->count))); ?></span>
|
|
||||||
</a>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
// Product/content grid foundation — latest products if WC active, else latest posts.
|
|
||||||
if (xshop_has_woocommerce() && function_exists('wc_get_products')):
|
|
||||||
$products = wc_get_products(['limit' => 6, 'status' => 'publish', 'orderby' => 'date', 'order' => 'DESC', 'return' => 'ids']);
|
|
||||||
if (!empty($products)):
|
|
||||||
?>
|
|
||||||
<section class="xshop-section" aria-labelledby="xshop-products-heading">
|
|
||||||
<?php get_template_part('template-parts/components/section-heading', null, ['title' => esc_html__('Latest Products', 'xshop'), 'link_label' => esc_html__('View shop', 'xshop'), 'link_url' => function_exists('wc_get_page_permalink') ? wc_get_page_permalink('shop') : home_url('/')]); ?>
|
|
||||||
<div class="xshop-grid xshop-grid--products">
|
|
||||||
<?php foreach ($products as $pid):
|
|
||||||
$p = wc_get_product((int) $pid);
|
|
||||||
if (!$p) { continue; }
|
|
||||||
?>
|
|
||||||
<article class="xshop-card">
|
|
||||||
<a class="xshop-card__media" href="<?php echo esc_url(get_permalink((int) $pid)); ?>" tabindex="-1">
|
|
||||||
<?php if (has_post_thumbnail((int) $pid)): echo get_the_post_thumbnail((int) $pid, 'xshop-card', ['loading'=>'lazy','decoding'=>'async']); else: ?><span class="xshop-card__media--placeholder xshop-card__media" style="aspect-ratio:16/10;display:flex;align-items:center;justify-content:center"><?php esc_html_e('No image', 'xshop'); ?></span><?php endif; ?>
|
|
||||||
</a>
|
|
||||||
<div class="xshop-card__body">
|
|
||||||
<h3 class="xshop-card__title"><a href="<?php echo esc_url(get_permalink((int) $pid)); ?>"><?php echo esc_html($p->get_name()); ?></a></h3>
|
|
||||||
<div class="xshop-card__meta"><?php echo wp_kses_post($p->get_price_html()); ?></div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<?php endif; endif; ?>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
get_template_part('template-parts/components/promo-banner', null, [
|
|
||||||
'title' => esc_html__('Promotional Banner Foundation', 'xshop'),
|
|
||||||
'text' => esc_html__('Replace with xshop-core Banners (position: home_hero) in later milestones.', 'xshop'),
|
|
||||||
'cta_label' => esc_html__('Learn more', 'xshop'),
|
|
||||||
'cta_url' => home_url('/'),
|
|
||||||
]);
|
|
||||||
?>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
// Latest posts foundation — always rendered as content grid fallback.
|
|
||||||
$latest = new WP_Query(['post_type'=>'post','posts_per_page'=>6,'no_found_rows'=>true,'ignore_sticky_posts'=>true]);
|
|
||||||
if ($latest->have_posts()):
|
|
||||||
?>
|
|
||||||
<section class="xshop-section" aria-labelledby="xshop-latest-heading">
|
|
||||||
<?php get_template_part('template-parts/components/section-heading', null, ['title' => esc_html__('Latest Posts', 'xshop'), 'link_label' => esc_html__('View blog', 'xshop'), 'link_url' => get_permalink(get_option('page_for_posts')) ?: home_url('/')]); ?>
|
|
||||||
<div class="xshop-grid xshop-grid--posts">
|
|
||||||
<?php while ($latest->have_posts()): $latest->the_post(); get_template_part('template-parts/components/post-card'); endwhile; wp_reset_postdata(); ?>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php get_template_part('template-parts/components/empty-state', null, ['title'=>esc_html__('No posts yet','xshop'),'text'=>esc_html__('Publish posts to see them here.','xshop')]); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<?php get_footer(); ?>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* XShop Theme Bootstrap
|
|
||||||
*
|
|
||||||
* Thin loader – no business logic here. Each module exposes `xshop_*` setup functions.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
define('XSHOP_VERSION', '1.0.0');
|
|
||||||
define('XSHOP_THEME_DIR', get_template_directory());
|
|
||||||
define('XSHOP_THEME_URI', get_template_directory_uri());
|
|
||||||
|
|
||||||
// Load order matters: helpers first.
|
|
||||||
require_once XSHOP_THEME_DIR . '/inc/helpers/sanitize.php';
|
|
||||||
require_once XSHOP_THEME_DIR . '/inc/helpers/template.php';
|
|
||||||
require_once XSHOP_THEME_DIR . '/inc/setup/theme-support.php';
|
|
||||||
require_once XSHOP_THEME_DIR . '/inc/setup/menus.php';
|
|
||||||
require_once XSHOP_THEME_DIR . '/inc/setup/sidebars.php';
|
|
||||||
require_once XSHOP_THEME_DIR . '/inc/setup/body-classes.php';
|
|
||||||
require_once XSHOP_THEME_DIR . '/inc/setup/assets.php';
|
|
||||||
require_once XSHOP_THEME_DIR . '/inc/woocommerce/setup.php';
|
|
||||||
require_once XSHOP_THEME_DIR . '/inc/compatibility/gutenberg.php';
|
|
||||||
require_once XSHOP_THEME_DIR . '/inc/compatibility/elementor.php';
|
|
||||||
require_once XSHOP_THEME_DIR . '/inc/customization/options.php';
|
|
||||||
require_once XSHOP_THEME_DIR . '/inc/performance/loader.php';
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Header — semantic, accessible, RTL/LTR safe.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
$dark = xshop_is_dark_mode();
|
|
||||||
?><!doctype html>
|
|
||||||
<html <?php language_attributes(); ?> data-theme="<?php echo $dark ? 'dark' : 'light'; ?>">
|
|
||||||
<head>
|
|
||||||
<meta charset="<?php bloginfo('charset'); ?>">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<?php wp_head(); ?>
|
|
||||||
</head>
|
|
||||||
<body <?php body_class(); ?>>
|
|
||||||
<?php wp_body_open(); ?>
|
|
||||||
<a class="xshop-skip-link" href="#xshop-main"><?php esc_html_e('Skip to content', 'xshop'); ?></a>
|
|
||||||
|
|
||||||
<header class="xshop-header" role="banner">
|
|
||||||
<?php get_template_part('template-parts/components/site-header'); ?>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main id="xshop-main" class="xshop-main" tabindex="-1">
|
|
||||||
<?php
|
|
||||||
// Breadcrumbs on all but front page and 404; Woo pages get Woo-aware breadcrumbs.
|
|
||||||
if (!is_front_page() && !is_404()) {
|
|
||||||
echo '<div class="xshop-container" style="margin-block-end:var(--xshop-space-4)">';
|
|
||||||
get_template_part('template-parts/components/breadcrumbs');
|
|
||||||
echo '</div>';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Elementor compatibility – no hard dependency.
|
|
||||||
*/
|
|
||||||
|
|
||||||
add_action('init', 'xshop_elementor_compat');
|
|
||||||
|
|
||||||
function xshop_elementor_compat(): void {
|
|
||||||
// Declare Elementor locations if needed later (header/footer).
|
|
||||||
// Guarded so missing Elementor is harmless.
|
|
||||||
if (!defined('ELEMENTOR_VERSION')) { return; }
|
|
||||||
// No-op for v1; widgets live in xshop-core when Elementor is present.
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
add_action('init', 'xshop_gutenberg_setup');
|
|
||||||
|
|
||||||
function xshop_gutenberg_setup(): void {
|
|
||||||
// Allow wide/full alignment where theme supports it later via theme.json.
|
|
||||||
add_theme_support('align-wide');
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Theme options store: single option `xshop_settings`.
|
|
||||||
* Full options UI ships in M6; this file only registers the setting with sanitization.
|
|
||||||
*/
|
|
||||||
|
|
||||||
add_action('init', 'xshop_register_settings');
|
|
||||||
|
|
||||||
function xshop_register_settings(): void {
|
|
||||||
register_setting('xshop_settings', 'xshop_settings', [
|
|
||||||
'type' => 'array',
|
|
||||||
'sanitize_callback' => 'xshop_sanitize_settings',
|
|
||||||
'default' => xshop_default_settings(),
|
|
||||||
'show_in_rest' => false,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function xshop_default_settings(): array {
|
|
||||||
return [
|
|
||||||
'color_mode' => 'light', // light|dark|auto
|
|
||||||
'header_layout' => 'classic', // classic|centered|minimal
|
|
||||||
'footer_layout' => 'four-col',
|
|
||||||
'shop_columns' => 3,
|
|
||||||
'product_card' => 'default',
|
|
||||||
'enable_dark_mode' => '0',
|
|
||||||
'custom_css' => '',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function xshop_sanitize_settings($value): array {
|
|
||||||
if (!is_array($value)) { $value = []; }
|
|
||||||
$defaults = xshop_default_settings();
|
|
||||||
$out = $defaults;
|
|
||||||
if (isset($value['color_mode'])) {
|
|
||||||
$out['color_mode'] = in_array($value['color_mode'], ['light','dark','auto'], true) ? $value['color_mode'] : $defaults['color_mode'];
|
|
||||||
}
|
|
||||||
if (isset($value['header_layout'])) {
|
|
||||||
$out['header_layout'] = in_array($value['header_layout'], ['classic','centered','minimal'], true) ? $value['header_layout'] : $defaults['header_layout'];
|
|
||||||
}
|
|
||||||
if (isset($value['footer_layout'])) {
|
|
||||||
$out['footer_layout'] = sanitize_key((string) $value['footer_layout']);
|
|
||||||
}
|
|
||||||
if (isset($value['shop_columns'])) {
|
|
||||||
$out['shop_columns'] = min(6, max(1, absint($value['shop_columns'])));
|
|
||||||
}
|
|
||||||
if (isset($value['product_card'])) {
|
|
||||||
$out['product_card'] = in_array($value['product_card'], ['compact','default','detailed'], true) ? $value['product_card'] : $defaults['product_card'];
|
|
||||||
}
|
|
||||||
if (isset($value['enable_dark_mode'])) {
|
|
||||||
$out['enable_dark_mode'] = $value['enable_dark_mode'] ? '1' : '0';
|
|
||||||
}
|
|
||||||
if (isset($value['custom_css'])) {
|
|
||||||
// Allow CSS – wp_strip_all_tags + wp_kses with empty allow-list would strip everything;
|
|
||||||
// use sanitization that preserves CSS: wp_strip_all_tags is too aggressive, so allow raw but trim.
|
|
||||||
$out['custom_css'] = trim(wp_strip_all_tags((string) $value['custom_css']));
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared sanitization helpers. All options/banners/QA go through these.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function xshop_sanitize_url(?string $value): string {
|
|
||||||
$value = trim((string) $value);
|
|
||||||
return $value === '' ? '' : esc_url_raw($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function xshop_sanitize_banner_position(string $value): string {
|
|
||||||
$allowed = ['home_hero','home_strip','shop_top','product_sidebar','footer_top'];
|
|
||||||
return in_array($value, $allowed, true) ? $value : 'home_hero';
|
|
||||||
}
|
|
||||||
|
|
||||||
function xshop_sanitize_checkbox($value): string {
|
|
||||||
return $value ? '1' : '0';
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Template helpers.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function xshop_get_setting(string $key, $default = null) {
|
|
||||||
static $cache = null;
|
|
||||||
if ($cache === null) {
|
|
||||||
$cache = get_option('xshop_settings', []);
|
|
||||||
if (!is_array($cache)) { $cache = []; }
|
|
||||||
}
|
|
||||||
return $cache[$key] ?? $default;
|
|
||||||
}
|
|
||||||
|
|
||||||
function xshop_svg(string $name, array $attrs = []): string {
|
|
||||||
$attrsStr = '';
|
|
||||||
foreach ($attrs as $k => $v) {
|
|
||||||
$attrsStr .= ' ' . esc_attr((string) $k) . '="' . esc_attr((string) $v) . '"';
|
|
||||||
}
|
|
||||||
// Sprite reference: /assets/images/sprite.svg#icon-<name>
|
|
||||||
$href = esc_url(XSHOP_THEME_URI . '/assets/images/sprite.svg#icon-' . sanitize_key($name));
|
|
||||||
return '<svg' . $attrsStr . ' aria-hidden="true"><use href="' . $href . '"></use></svg>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function xshop_is_dark_mode(): bool {
|
|
||||||
$mode = xshop_get_setting('color_mode', 'light');
|
|
||||||
if ($mode === 'dark') { return true; }
|
|
||||||
if ($mode === 'auto' && isset($_COOKIE['xshop_theme'])) {
|
|
||||||
return $_COOKIE['xshop_theme'] === 'dark';
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function xshop_has_woocommerce(): bool {
|
|
||||||
return class_exists('WooCommerce');
|
|
||||||
}
|
|
||||||
|
|
||||||
function xshop_is_woocommerce_page(): bool {
|
|
||||||
if (!xshop_has_woocommerce()) { return false; }
|
|
||||||
return function_exists('is_woocommerce') && (is_woocommerce() || is_cart() || is_checkout() || is_account_page());
|
|
||||||
}
|
|
||||||
|
|
||||||
function xshop_get_branding_fallback(): array {
|
|
||||||
$name = get_bloginfo('name');
|
|
||||||
$desc = get_bloginfo('description');
|
|
||||||
return [
|
|
||||||
'name' => $name ? $name : esc_html__('XShop', 'xshop'),
|
|
||||||
'desc' => $desc ? $desc : '',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performance helpers – conditional asset loading, defer hints.
|
|
||||||
*/
|
|
||||||
|
|
||||||
add_filter('script_loader_tag', 'xshop_defer_scripts', 10, 3);
|
|
||||||
|
|
||||||
function xshop_defer_scripts(string $tag, string $handle, string $src): string {
|
|
||||||
// Defer theme script (not admin, not customizer preview).
|
|
||||||
if ($handle === 'xshop' && !is_admin() && !is_customize_preview()) {
|
|
||||||
if (str_contains($tag, ' defer')) { return $tag; }
|
|
||||||
$tag = str_replace('<script ', '<script defer ', $tag);
|
|
||||||
}
|
|
||||||
return $tag;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
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 → 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-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');
|
|
||||||
|
|
||||||
// Conditionally avoid WooCommerce styles on non-WC pages – WC styles already dequeued in inc/woocommerce/setup.php.
|
|
||||||
// Theme JS is tiny; feature modules conditionally enqueued elsewhere.
|
|
||||||
wp_enqueue_script('xshop', XSHOP_THEME_URI . '/assets/js/theme.js', [], $ver, true);
|
|
||||||
|
|
||||||
if (!is_admin() && !wp_is_json_request()) {
|
|
||||||
wp_enqueue_script('xshop-search', XSHOP_THEME_URI . '/assets/js/search.js', [], $ver, true);
|
|
||||||
// Filter JS — header search exists globally, filters exist on shop archives. Enqueue front-end globally (8KB) with DOM guard (init only if [data-xshop-filters] present).
|
|
||||||
wp_enqueue_script('xshop-filter', XSHOP_THEME_URI . '/assets/js/filter.js', [], $ver, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Localized data for REST/AJAX (nonces, i18n hints).
|
|
||||||
wp_localize_script('xshop', 'xshopData', [
|
|
||||||
'restUrl' => esc_url_raw(rest_url('xshop/v1/')),
|
|
||||||
'nonce' => wp_create_nonce('wp_rest'),
|
|
||||||
'isRtl' => is_rtl(),
|
|
||||||
'i18n' => [
|
|
||||||
'searchPlaceholder' => esc_html__('Search products…', 'xshop'),
|
|
||||||
'noResults' => esc_html__('No results found.', 'xshop'),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (is_singular() && comments_open() && get_option('thread_comments')) {
|
|
||||||
wp_enqueue_script('comment-reply');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
add_filter('body_class', 'xshop_body_classes');
|
|
||||||
|
|
||||||
function xshop_body_classes(array $classes): array {
|
|
||||||
$classes[] = 'xshop';
|
|
||||||
$classes[] = xshop_is_dark_mode() ? 'xshop--dark' : 'xshop--light';
|
|
||||||
if (xshop_has_woocommerce()) {
|
|
||||||
$classes[] = 'xshop--has-woo';
|
|
||||||
if (xshop_is_woocommerce_page()) {
|
|
||||||
$classes[] = 'xshop--woo-page';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$classes[] = 'xshop--no-woo';
|
|
||||||
}
|
|
||||||
if (is_rtl()) {
|
|
||||||
$classes[] = 'xshop--rtl';
|
|
||||||
}
|
|
||||||
return $classes;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
add_action('after_setup_theme', 'xshop_register_menus');
|
|
||||||
|
|
||||||
function xshop_register_menus(): void {
|
|
||||||
register_nav_menus([
|
|
||||||
'xshop-primary' => esc_html__('Primary Menu', 'xshop'),
|
|
||||||
'xshop-mobile' => esc_html__('Mobile Menu', 'xshop'),
|
|
||||||
'xshop-footer' => esc_html__('Footer Menu', 'xshop'),
|
|
||||||
'xshop-topbar' => esc_html__('Top Bar Menu', 'xshop'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
add_action('widgets_init', 'xshop_register_sidebars');
|
|
||||||
|
|
||||||
function xshop_register_sidebars(): void {
|
|
||||||
$sidebars = [
|
|
||||||
['id'=>'sidebar-main','name'=>__('Main Sidebar','xshop'),'desc'=>__('Appears on blog and pages.','xshop')],
|
|
||||||
['id'=>'sidebar-shop','name'=>__('Shop Sidebar','xshop'),'desc'=>__('Appears on shop archives.','xshop')],
|
|
||||||
['id'=>'footer-1','name'=>__('Footer Column 1','xshop'),'desc'=>''],
|
|
||||||
['id'=>'footer-2','name'=>__('Footer Column 2','xshop'),'desc'=>''],
|
|
||||||
['id'=>'footer-3','name'=>__('Footer Column 3','xshop'),'desc'=>''],
|
|
||||||
['id'=>'footer-4','name'=>__('Footer Column 4','xshop'),'desc'=>''],
|
|
||||||
];
|
|
||||||
foreach ($sidebars as $s) {
|
|
||||||
register_sidebar([
|
|
||||||
'name' => $s['name'],
|
|
||||||
'id' => $s['id'],
|
|
||||||
'description' => $s['desc'],
|
|
||||||
'before_widget' => '<section id="%1$s" class="xshop-widget %2$s">',
|
|
||||||
'after_widget' => '</section>',
|
|
||||||
'before_title' => '<h3 class="xshop-widget__title">',
|
|
||||||
'after_title' => '</h3>',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
add_action('after_setup_theme', 'xshop_setup_theme_support');
|
|
||||||
|
|
||||||
function xshop_setup_theme_support(): void {
|
|
||||||
add_theme_support('title-tag');
|
|
||||||
add_theme_support('post-thumbnails');
|
|
||||||
add_theme_support('html5', ['search-form','comment-form','comment-list','gallery','caption','style','script']);
|
|
||||||
add_theme_support('custom-logo', ['height'=>56,'width'=>200,'flex-height'=>true,'flex-width'=>true]);
|
|
||||||
add_theme_support('automatic-feed-links');
|
|
||||||
add_theme_support('responsive-embeds');
|
|
||||||
add_theme_support('editor-styles');
|
|
||||||
add_theme_support('wp-block-styles');
|
|
||||||
add_theme_support('woocommerce', [
|
|
||||||
'thumbnail_image_width' => 400,
|
|
||||||
'single_image_width' => 800,
|
|
||||||
]);
|
|
||||||
add_theme_support('wc-product-gallery-zoom');
|
|
||||||
add_theme_support('wc-product-gallery-lightbox');
|
|
||||||
add_theme_support('wc-product-gallery-slider');
|
|
||||||
|
|
||||||
load_theme_textdomain('xshop', XSHOP_THEME_DIR . '/languages');
|
|
||||||
|
|
||||||
// Image sizes.
|
|
||||||
add_image_size('xshop-card', 400, 400, false);
|
|
||||||
add_image_size('xshop-card-2x', 800, 800, false);
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WooCommerce integration: wrappers, hooks. Keep template overrides minimal.
|
|
||||||
*/
|
|
||||||
|
|
||||||
add_action('after_setup_theme', 'xshop_woo_setup');
|
|
||||||
|
|
||||||
function xshop_woo_setup(): void {
|
|
||||||
if (!class_exists('WooCommerce')) { return; }
|
|
||||||
|
|
||||||
add_action('woocommerce_before_main_content', 'xshop_woo_wrapper_start', 10);
|
|
||||||
add_action('woocommerce_after_main_content', 'xshop_woo_wrapper_end', 10);
|
|
||||||
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');
|
|
||||||
|
|
||||||
// M4: No-JS fallback — apply filters to main query on shop/category archive
|
|
||||||
add_action('pre_get_posts', 'xshop_wc_filter_main_query', 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
function xshop_wc_filter_main_query(\WP_Query $q): void {
|
|
||||||
if (\is_admin() || !$q->is_main_query()) { return; }
|
|
||||||
if (!\class_exists('WooCommerce')) { return; }
|
|
||||||
// Only on product archive (shop or product_taxonomy)
|
|
||||||
if (!(\is_shop() || \is_product_taxonomy())) { return; }
|
|
||||||
// Don't interfere with REST
|
|
||||||
if (\defined('REST_REQUEST') && \REST_REQUEST) { return; }
|
|
||||||
|
|
||||||
// If no filter params, leave default Woo query
|
|
||||||
$hasFilter = isset($_GET['min_price']) || isset($_GET['max_price']) || isset($_GET['stock']) || isset($_GET['on_sale']) || isset($_GET['sale']) || isset($_GET['rating']) || isset($_GET['category']) || isset($_GET['orderby']) || isset($_GET['s']);
|
|
||||||
// Also check pa_* attributes
|
|
||||||
foreach ($_GET as $k => $v) {
|
|
||||||
if (\str_starts_with((string) $k, 'pa_') || \str_starts_with((string) $k, 'filter_') || \str_starts_with((string) $k, 'attribute_')) { $hasFilter = true; break; }
|
|
||||||
}
|
|
||||||
if (!$hasFilter) { return; }
|
|
||||||
|
|
||||||
// Build params from $_GET (same canonical as REST)
|
|
||||||
$params = [
|
|
||||||
'search' => isset($_GET['s']) ? \sanitize_text_field(\wp_unslash($_GET['s'])) : '',
|
|
||||||
'category' => isset($_GET['category']) ? \sanitize_text_field(\wp_unslash($_GET['category'])) : '',
|
|
||||||
'attributes' => [],
|
|
||||||
'min_price' => isset($_GET['min_price']) ? \sanitize_text_field(\wp_unslash($_GET['min_price'])) : null,
|
|
||||||
'max_price' => isset($_GET['max_price']) ? \sanitize_text_field(\wp_unslash($_GET['max_price'])) : null,
|
|
||||||
'stock' => isset($_GET['stock']) ? \sanitize_key(\wp_unslash($_GET['stock'])) : 'all',
|
|
||||||
'on_sale' => (isset($_GET['on_sale']) && $_GET['on_sale'] === '1') || (isset($_GET['sale']) && $_GET['sale'] === '1'),
|
|
||||||
'rating' => isset($_GET['rating']) ? \absint($_GET['rating']) : 0,
|
|
||||||
'orderby' => isset($_GET['orderby']) ? \sanitize_key(\wp_unslash($_GET['orderby'])) : 'default',
|
|
||||||
'page' => isset($_GET['page']) ? \absint($_GET['page']) : (isset($_GET['paged']) ? \absint($_GET['paged']) : 1),
|
|
||||||
'per_page' => 12,
|
|
||||||
];
|
|
||||||
// Collect attributes from pa_* etc.
|
|
||||||
foreach ($_GET as $k => $v) {
|
|
||||||
$kClean = \sanitize_key((string) $k);
|
|
||||||
if (\str_starts_with($kClean, 'pa_') || \str_starts_with($kClean, 'attribute_')) {
|
|
||||||
$tax = $kClean;
|
|
||||||
if (\str_starts_with($tax, 'attribute_')) { $tax = 'pa_' . \substr($tax, 10); }
|
|
||||||
$terms = [];
|
|
||||||
if (\is_array($v)) { foreach ($v as $t) { $t = \sanitize_title((string) $t); if ($t !== '') { $terms[] = $t; } } }
|
|
||||||
else { foreach (\explode(',', (string) $v) as $t) { $t = \sanitize_title(\trim($t)); if ($t !== '') { $terms[] = $t; } } }
|
|
||||||
if (!empty($terms) && \taxonomy_exists($tax)) { $params['attributes'][$tax] = $terms; }
|
|
||||||
}
|
|
||||||
if (\str_starts_with($kClean, 'filter_')) {
|
|
||||||
$tax = 'pa_' . \substr($kClean, 7);
|
|
||||||
if (\taxonomy_exists($tax)) {
|
|
||||||
$terms = [];
|
|
||||||
foreach (\explode(',', (string) $v) as $t) { $t = \sanitize_title(\trim($t)); if ($t !== '') { $terms[] = $t; } }
|
|
||||||
if (!empty($terms)) { $params['attributes'][$tax] = $terms; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If on category archive and category param empty, preserve queried category
|
|
||||||
if (\is_product_category() && empty($params['category'])) {
|
|
||||||
$term = \get_queried_object();
|
|
||||||
if ($term instanceof \WP_Term) { $params['category'] = $term->slug; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use shared ProductQuery to build args, then merge into main query
|
|
||||||
if (!\class_exists('XShop\Core\Query\ProductQuery')) {
|
|
||||||
// Fallback if plugin not loaded yet (should be loaded via xshop-core)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// ProductQuery class is functions-based, not class; use function directly if available
|
|
||||||
if (\function_exists('XShop\Core\Query\build_product_query_args')) {
|
|
||||||
$wpArgs = \XShop\Core\Query\build_product_query_args($params);
|
|
||||||
// Merge into main query (only tax_query/meta_query/orderby etc, not post_type/status which already correct)
|
|
||||||
if (!empty($wpArgs['tax_query'])) { $q->set('tax_query', $wpArgs['tax_query']); }
|
|
||||||
if (!empty($wpArgs['meta_query'])) { $q->set('meta_query', $wpArgs['meta_query']); }
|
|
||||||
if (isset($wpArgs['meta_key'])) { $q->set('meta_key', $wpArgs['meta_key']); }
|
|
||||||
if (isset($wpArgs['orderby'])) { $q->set('orderby', $wpArgs['orderby']); }
|
|
||||||
if (isset($wpArgs['order'])) { $q->set('order', $wpArgs['order']); }
|
|
||||||
if (isset($wpArgs['s'])) { $q->set('s', $wpArgs['s']); }
|
|
||||||
// Ensure per_page/page respects
|
|
||||||
$q->set('posts_per_page', $wpArgs['posts_per_page']);
|
|
||||||
$q->set('paged', $wpArgs['paged']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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">';
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
add_action('wp', 'xshop_woo_notice_compat');
|
|
||||||
|
|
||||||
function xshop_woo_notice_compat(): void {
|
|
||||||
if (!class_exists('WooCommerce')) { return; }
|
|
||||||
// No-op for M1; placeholder for future notice positioning hooks.
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Index — blog fallback.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
get_header();
|
|
||||||
?>
|
|
||||||
<div class="xshop-container">
|
|
||||||
<div class="xshop-layout">
|
|
||||||
<div class="xshop-layout__content">
|
|
||||||
<header class="xshop-page-header">
|
|
||||||
<h1 class="xshop-page-header__title"><?php esc_html_e('Blog', 'xshop'); ?></h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<?php if (have_posts()): ?>
|
|
||||||
<div class="xshop-grid xshop-grid--posts">
|
|
||||||
<?php while (have_posts()): the_post(); ?>
|
|
||||||
<?php get_template_part('template-parts/components/post-card'); ?>
|
|
||||||
<?php endwhile; ?>
|
|
||||||
</div>
|
|
||||||
<?php get_template_part('template-parts/components/pagination'); ?>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php get_template_part('template-parts/components/empty-state', null, ['title' => esc_html__('No posts yet', 'xshop'), 'text' => esc_html__('When you publish posts they will appear here.', 'xshop')]); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php get_sidebar(); ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php get_footer(); ?>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Page
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
get_header();
|
|
||||||
?>
|
|
||||||
<div class="xshop-container">
|
|
||||||
<div class="xshop-layout">
|
|
||||||
<div class="xshop-layout__content">
|
|
||||||
<?php while (have_posts()): the_post(); ?>
|
|
||||||
<article <?php post_class('xshop-prose'); ?>>
|
|
||||||
<header class="xshop-page-header">
|
|
||||||
<h1 class="xshop-page-header__title"><?php the_title(); ?></h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<?php if (has_post_thumbnail()): ?>
|
|
||||||
<div class="xshop-card__media" style="border-radius:var(--xshop-radius-md);margin-block-end:var(--xshop-space-6)">
|
|
||||||
<?php the_post_thumbnail('large', ['loading'=>'eager','decoding'=>'async']); ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="entry-content">
|
|
||||||
<?php
|
|
||||||
the_content();
|
|
||||||
wp_link_pages([
|
|
||||||
'before' => '<nav class="xshop-pagination" aria-label="' . esc_attr__('Page links', 'xshop') . '"><span class="xshop-text-muted">' . esc_html__('Pages:', 'xshop') . '</span> ',
|
|
||||||
'after' => '</nav>',
|
|
||||||
]);
|
|
||||||
?>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
if (comments_open() || (int) get_comments_number() > 0) {
|
|
||||||
comments_template();
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<?php endwhile; ?>
|
|
||||||
</div>
|
|
||||||
<?php get_sidebar(); ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php get_footer(); ?>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
/* RTL fallback — tokens use logical properties; this file exists for WordPress RTL detection. */
|
|
||||||
body.rtl { direction: rtl; }
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
PNG placeholder – replace with 1200x900 screenshot before release. See tools/build-release.ps1 notes.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Search Results
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
get_header();
|
|
||||||
|
|
||||||
$query = get_search_query();
|
|
||||||
?>
|
|
||||||
<div class="xshop-container">
|
|
||||||
<div class="xshop-layout">
|
|
||||||
<div class="xshop-layout__content">
|
|
||||||
<header class="xshop-page-header">
|
|
||||||
<h1 class="xshop-page-header__title">
|
|
||||||
<?php if ($query !== ''): printf(esc_html__('Search results for: %s', 'xshop'), '<span>' . esc_html($query) . '</span>'); else: esc_html_e('Search', 'xshop'); endif; ?>
|
|
||||||
</h1>
|
|
||||||
<div style="margin-block-start:var(--xshop-space-4)">
|
|
||||||
<?php get_template_part('template-parts/components/search-form', null, ['placeholder'=>esc_html__('Search…','xshop')]); ?>
|
|
||||||
</div>
|
|
||||||
<?php if (have_posts()): ?>
|
|
||||||
<p class="xshop-text-muted" style="margin-block-start:var(--xshop-space-3)"><?php echo esc_html(sprintf(esc_html__('%s results found', 'xshop'), number_format_i18n((int) $GLOBALS['wp_query']->found_posts))); ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<?php if (have_posts()): ?>
|
|
||||||
<div class="xshop-grid xshop-grid--posts">
|
|
||||||
<?php while (have_posts()): the_post(); get_template_part('template-parts/components/post-card'); endwhile; ?>
|
|
||||||
</div>
|
|
||||||
<?php get_template_part('template-parts/components/pagination'); ?>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php get_template_part('template-parts/components/empty-state', null, ['title'=>esc_html__('No results','xshop'),'text'=>esc_html__('Try different keywords or browse categories.','xshop'),'cta_label'=>esc_html__('Go home','xshop'),'cta_url'=>home_url('/')]); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php get_sidebar(); ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php get_footer(); ?>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Sidebar — dynamic_sidebar with graceful empty state.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
$sidebarId = $args['id'] ?? 'sidebar-main';
|
|
||||||
if (!is_active_sidebar($sidebarId)) {
|
|
||||||
// Allow filtering to suppress empty state.
|
|
||||||
if (apply_filters('xshop_sidebar_show_empty', false, $sidebarId)) {
|
|
||||||
echo '<aside class="xshop-layout__sidebar" aria-label="' . esc_attr__('Sidebar', 'xshop') . '"><p class="xshop-text-muted">' . esc_html__('No widgets added.', 'xshop') . '</p></aside>';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<aside class="xshop-layout__sidebar" aria-label="<?php esc_attr_e('Sidebar', 'xshop'); ?>">
|
|
||||||
<?php dynamic_sidebar($sidebarId); ?>
|
|
||||||
</aside>
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Single Post
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
get_header();
|
|
||||||
?>
|
|
||||||
<div class="xshop-container">
|
|
||||||
<div class="xshop-layout">
|
|
||||||
<div class="xshop-layout__content">
|
|
||||||
<?php while (have_posts()): the_post(); ?>
|
|
||||||
<article <?php post_class('xshop-prose'); ?>>
|
|
||||||
<header class="xshop-page-header">
|
|
||||||
<h1 class="xshop-page-header__title"><?php the_title(); ?></h1>
|
|
||||||
<div class="xshop-card__meta">
|
|
||||||
<time datetime="<?php echo esc_attr(get_the_date('c')); ?>"><?php echo esc_html(get_the_date()); ?></time>
|
|
||||||
<span aria-hidden="true">·</span>
|
|
||||||
<span><?php echo esc_html(get_the_author()); ?></span>
|
|
||||||
<?php
|
|
||||||
$cats = get_the_category();
|
|
||||||
if (!empty($cats[0])) {
|
|
||||||
echo '<span aria-hidden="true">·</span><a href="' . esc_url(get_category_link($cats[0]->term_id)) . '">' . esc_html($cats[0]->name) . '</a>';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<?php if (has_post_thumbnail()): ?>
|
|
||||||
<div class="xshop-card__media" style="border-radius:var(--xshop-radius-md);margin-block-end:var(--xshop-space-6)">
|
|
||||||
<?php the_post_thumbnail('large', ['loading'=>'eager','decoding'=>'async']); ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="entry-content">
|
|
||||||
<?php
|
|
||||||
the_content();
|
|
||||||
wp_link_pages([
|
|
||||||
'before' => '<nav class="xshop-pagination" aria-label="' . esc_attr__('Page links', 'xshop') . '"><span class="xshop-text-muted">' . esc_html__('Pages:', 'xshop') . '</span> ',
|
|
||||||
'after' => '</nav>',
|
|
||||||
]);
|
|
||||||
?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
$tags = get_the_tags();
|
|
||||||
if ($tags):
|
|
||||||
?>
|
|
||||||
<footer style="margin-block-start:var(--xshop-space-6)">
|
|
||||||
<p class="xshop-card__meta"><?php the_tags(esc_html__('Tags: ', 'xshop'), ', '); ?></p>
|
|
||||||
</footer>
|
|
||||||
<?php endif; ?>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
// Next/previous post navigation (accessible).
|
|
||||||
the_post_navigation([
|
|
||||||
'prev_text' => '<span class="xshop-text-muted">' . esc_html__('Previous', 'xshop') . '</span><br>%title',
|
|
||||||
'next_text' => '<span class="xshop-text-muted">' . esc_html__('Next', 'xshop') . '</span><br>%title',
|
|
||||||
]);
|
|
||||||
?>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
if (comments_open() || (int) get_comments_number() > 0) {
|
|
||||||
comments_template();
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<?php endwhile; ?>
|
|
||||||
</div>
|
|
||||||
<?php get_sidebar(); ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php get_footer(); ?>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/*
|
|
||||||
Theme Name: XShop
|
|
||||||
Theme URI: https://example.com/xshop
|
|
||||||
Author: XShop Team
|
|
||||||
Author URI: https://example.com
|
|
||||||
Description: Persian-first, RTL/LTR, responsive WooCommerce theme. Fast, accessible, and commercial-grade. Requires xshop-core companion plugin for wishlist/compare/Q&A/search/filter/banners.
|
|
||||||
Version: 1.0.0
|
|
||||||
License: Commercial
|
|
||||||
License URI: https://example.com/license
|
|
||||||
Text Domain: xshop
|
|
||||||
Domain Path: /languages
|
|
||||||
Requires at least: 6.4
|
|
||||||
Tested up to: 6.6
|
|
||||||
Requires PHP: 8.2
|
|
||||||
Tags: woocommerce, rtl, e-commerce, persian, responsive, dark-mode, accessibility-ready
|
|
||||||
*/
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* AJAX Search — reusable, accessible, combobox-like (without over-engineered ARIA).
|
|
||||||
*
|
|
||||||
* Reusable by: desktop header, mobile header, future overlay.
|
|
||||||
* JS: assets/js/search.js handles fetch, debounce, stale protection, keyboard.
|
|
||||||
*
|
|
||||||
* Args: ['placeholder'=>string, 'action'=>string, 'id'=>string|null]
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
$uid = $args['id'] ?? ('xshop-ajax-search-' . wp_unique_id());
|
|
||||||
$placeholder = $args['placeholder'] ?? esc_html__('Search products…', 'xshop');
|
|
||||||
$action = $args['action'] ?? home_url('/');
|
|
||||||
$minLength = 2;
|
|
||||||
$limit = 6;
|
|
||||||
?>
|
|
||||||
<div class="xshop-search" data-xshop-search>
|
|
||||||
<form role="search" method="get" action="<?php echo esc_url($action); ?>" class="xshop-search__form" data-xshop-search-form>
|
|
||||||
<label class="xshop-sr-only" for="<?php echo esc_attr($uid); ?>"><?php esc_html_e('Search products', 'xshop'); ?></label>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
id="<?php echo esc_attr($uid); ?>"
|
|
||||||
name="s"
|
|
||||||
value="<?php echo esc_attr(get_search_query()); ?>"
|
|
||||||
placeholder="<?php echo esc_attr($placeholder); ?>"
|
|
||||||
autocomplete="off"
|
|
||||||
aria-label="<?php esc_attr_e('Search products', 'xshop'); ?>"
|
|
||||||
aria-expanded="false"
|
|
||||||
aria-controls="<?php echo esc_attr($uid); ?>-listbox"
|
|
||||||
aria-autocomplete="list"
|
|
||||||
role="combobox"
|
|
||||||
data-xshop-search-input
|
|
||||||
data-min-length="<?php echo esc_attr((string) $minLength); ?>"
|
|
||||||
data-limit="<?php echo esc_attr((string) $limit); ?>"
|
|
||||||
/>
|
|
||||||
<?php if (xshop_has_woocommerce()): ?>
|
|
||||||
<input type="hidden" name="post_type" value="product" />
|
|
||||||
<?php endif; ?>
|
|
||||||
<button type="submit" class="xshop-btn xshop-btn--primary" aria-label="<?php esc_attr_e('Search', 'xshop'); ?>"><?php esc_html_e('Search', 'xshop'); ?></button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="<?php echo esc_attr($uid); ?>-listbox"
|
|
||||||
class="xshop-search__dropdown"
|
|
||||||
role="listbox"
|
|
||||||
hidden
|
|
||||||
data-xshop-search-dropdown
|
|
||||||
>
|
|
||||||
<div class="xshop-search__status xshop-sr-only" aria-live="polite" aria-atomic="true" data-xshop-search-status></div>
|
|
||||||
|
|
||||||
<div class="xshop-search__loading" hidden data-xshop-search-loading>
|
|
||||||
<span class="xshop-loading__spinner" aria-hidden="true"></span>
|
|
||||||
<span><?php esc_html_e('Searching…', 'xshop'); ?></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="xshop-search__results" role="presentation" data-xshop-search-results></ul>
|
|
||||||
|
|
||||||
<div class="xshop-search__empty" hidden data-xshop-search-empty>
|
|
||||||
<p><?php esc_html_e('No products found.', 'xshop'); ?></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xshop-search__error" hidden data-xshop-search-error>
|
|
||||||
<p><?php esc_html_e('Search is temporarily unavailable. Press Enter to search.', 'xshop'); ?></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="#" class="xshop-search__viewall" hidden data-xshop-search-viewall>
|
|
||||||
<?php esc_html_e('View all results', 'xshop'); ?> →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Breadcrumbs — semantic, RTL/LTR safe, Woo-aware, SEO-friendly.
|
|
||||||
*
|
|
||||||
* Data: array of ['label'=>string,'url'=>string|null,'current'=>bool]
|
|
||||||
* If $args['items'] not provided, auto-builds from WP/WC context.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
$args = wp_parse_args($args ?? [], ['items' => null]);
|
|
||||||
$items = $args['items'];
|
|
||||||
|
|
||||||
if ($items === null) {
|
|
||||||
$items = [];
|
|
||||||
// Home.
|
|
||||||
$items[] = ['label' => esc_html__('Home', 'xshop'), 'url' => home_url('/'), 'current' => false];
|
|
||||||
|
|
||||||
if (is_front_page()) {
|
|
||||||
// Front page is current; keep only home as current.
|
|
||||||
$items[0]['current'] = true;
|
|
||||||
$items[0]['url'] = null;
|
|
||||||
} elseif (function_exists('is_woocommerce') && xshop_has_woocommerce() && (is_woocommerce() || is_cart() || is_checkout() || is_account_page())) {
|
|
||||||
// Delegate to Woo breadcrumb if available, but provide fallback.
|
|
||||||
if (function_exists('is_shop') && is_shop()) {
|
|
||||||
$items[] = ['label' => esc_html__('Shop', 'xshop'), 'url' => null, 'current' => true];
|
|
||||||
} elseif (function_exists('is_product_category') && is_product_category()) {
|
|
||||||
$shopUrl = function_exists('wc_get_page_permalink') ? wc_get_page_permalink('shop') : home_url('/');
|
|
||||||
$items[] = ['label' => esc_html__('Shop', 'xshop'), 'url' => $shopUrl, 'current' => false];
|
|
||||||
$term = get_queried_object();
|
|
||||||
if ($term instanceof WP_Term) {
|
|
||||||
$items[] = ['label' => $term->name, 'url' => null, 'current' => true];
|
|
||||||
}
|
|
||||||
} elseif (function_exists('is_product') && is_product()) {
|
|
||||||
$shopUrl = function_exists('wc_get_page_permalink') ? wc_get_page_permalink('shop') : home_url('/');
|
|
||||||
$items[] = ['label' => esc_html__('Shop', 'xshop'), 'url' => $shopUrl, 'current' => false];
|
|
||||||
$items[] = ['label' => get_the_title(), 'url' => null, 'current' => true];
|
|
||||||
} elseif (function_exists('is_cart') && is_cart()) {
|
|
||||||
$items[] = ['label' => esc_html__('Cart', 'xshop'), 'url' => null, 'current' => true];
|
|
||||||
} elseif (function_exists('is_checkout') && is_checkout()) {
|
|
||||||
$items[] = ['label' => esc_html__('Checkout', 'xshop'), 'url' => null, 'current' => true];
|
|
||||||
} elseif (function_exists('is_account_page') && is_account_page()) {
|
|
||||||
$items[] = ['label' => esc_html__('My Account', 'xshop'), 'url' => null, 'current' => true];
|
|
||||||
}
|
|
||||||
} elseif (is_home()) {
|
|
||||||
$postsPage = get_option('page_for_posts');
|
|
||||||
$label = $postsPage ? get_the_title((int) $postsPage) : esc_html__('Blog', 'xshop');
|
|
||||||
$items[] = ['label' => $label, 'url' => null, 'current' => true];
|
|
||||||
} elseif (is_category() || is_tag() || is_tax()) {
|
|
||||||
$term = get_queried_object();
|
|
||||||
if ($term instanceof WP_Term) {
|
|
||||||
$items[] = ['label' => $term->name, 'url' => null, 'current' => true];
|
|
||||||
}
|
|
||||||
} elseif (is_search()) {
|
|
||||||
$items[] = ['label' => sprintf(esc_html__('Search: %s', 'xshop'), get_search_query()), 'url' => null, 'current' => true];
|
|
||||||
} elseif (is_404()) {
|
|
||||||
$items[] = ['label' => esc_html__('Not Found', 'xshop'), 'url' => null, 'current' => true];
|
|
||||||
} elseif (is_singular()) {
|
|
||||||
$postType = get_post_type();
|
|
||||||
if ($postType && $postType !== 'post' && $postType !== 'page') {
|
|
||||||
$obj = get_post_type_object($postType);
|
|
||||||
if ($obj) {
|
|
||||||
$items[] = ['label' => $obj->labels->name, 'url' => null, 'current' => false];
|
|
||||||
}
|
|
||||||
} elseif ($postType === 'post') {
|
|
||||||
$cat = get_the_category();
|
|
||||||
if (!empty($cat[0])) {
|
|
||||||
$items[] = ['label' => $cat[0]->name, 'url' => get_category_link($cat[0]->term_id), 'current' => false];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$items[] = ['label' => get_the_title(), 'url' => null, 'current' => true];
|
|
||||||
} elseif (is_archive()) {
|
|
||||||
$items[] = ['label' => get_the_archive_title(), 'url' => null, 'current' => true];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark last as current if none marked.
|
|
||||||
$hasCurrent = false;
|
|
||||||
foreach ($items as $it) { if (!empty($it['current'])) { $hasCurrent = true; break; } }
|
|
||||||
if (!$hasCurrent && count($items) > 0) {
|
|
||||||
$items[count($items) - 1]['current'] = true;
|
|
||||||
$items[count($items) - 1]['url'] = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Never break if empty.
|
|
||||||
if (empty($items)) { return; }
|
|
||||||
|
|
||||||
// Allow SEO plugins / child themes to filter.
|
|
||||||
$items = apply_filters('xshop_breadcrumbs_items', $items);
|
|
||||||
if (empty($items) || !is_array($items)) { return; }
|
|
||||||
?>
|
|
||||||
<nav class="xshop-breadcrumbs" aria-label="<?php esc_attr_e('Breadcrumb', 'xshop'); ?>">
|
|
||||||
<ol>
|
|
||||||
<?php foreach ($items as $item):
|
|
||||||
$label = isset($item['label']) ? (string) $item['label'] : '';
|
|
||||||
if ($label === '') { continue; }
|
|
||||||
$isCurrent = !empty($item['current']);
|
|
||||||
$url = isset($item['url']) ? (string) $item['url'] : '';
|
|
||||||
?>
|
|
||||||
<li>
|
|
||||||
<?php if ($isCurrent || $url === ''): ?>
|
|
||||||
<span aria-current="page"><?php echo esc_html($label); ?></span>
|
|
||||||
<?php else: ?>
|
|
||||||
<a href="<?php echo esc_url($url); ?>"><?php echo esc_html($label); ?></a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Empty state — no posts / no results.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
$title = $args['title'] ?? esc_html__('Nothing found', 'xshop');
|
|
||||||
$text = $args['text'] ?? esc_html__('Try adjusting your search or browse categories.', 'xshop');
|
|
||||||
$ctaLabel = $args['cta_label'] ?? '';
|
|
||||||
$ctaUrl = $args['cta_url'] ?? '';
|
|
||||||
?>
|
|
||||||
<div class="xshop-empty" role="status">
|
|
||||||
<h2 class="xshop-empty__title"><?php echo esc_html($title); ?></h2>
|
|
||||||
<p class="xshop-empty__text"><?php echo esc_html($text); ?></p>
|
|
||||||
<?php if ($ctaLabel !== '' && $ctaUrl !== ''): ?>
|
|
||||||
<a class="xshop-btn xshop-btn--primary" href="<?php echo esc_url($ctaUrl); ?>"><?php echo esc_html($ctaLabel); ?></a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Hero foundation — extensible, no hardcoded business copy beyond fallbacks.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
$title = $args['title'] ?? esc_html__('Welcome to XShop', 'xshop');
|
|
||||||
$text = $args['text'] ?? esc_html__('A fast, RTL-ready store foundation. Customize hero content via front-page.php or a child theme.', 'xshop');
|
|
||||||
$ctaLabel = $args['cta_label'] ?? '';
|
|
||||||
$ctaUrl = $args['cta_url'] ?? '';
|
|
||||||
?>
|
|
||||||
<section class="xshop-hero" aria-label="<?php esc_attr_e('Hero', 'xshop'); ?>">
|
|
||||||
<h1 class="xshop-hero__title"><?php echo esc_html($title); ?></h1>
|
|
||||||
<?php if ($text !== ''): ?>
|
|
||||||
<p class="xshop-hero__text"><?php echo esc_html($text); ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($ctaLabel !== '' && $ctaUrl !== ''): ?>
|
|
||||||
<div class="xshop-hero__actions">
|
|
||||||
<a class="xshop-btn xshop-btn--primary" href="<?php echo esc_url($ctaUrl); ?>"><?php echo esc_html($ctaLabel); ?></a>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</section>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Loading indicator — for future AJAX use; static markup safe for M1.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
$label = $args['label'] ?? esc_html__('Loading…', 'xshop');
|
|
||||||
?>
|
|
||||||
<div class="xshop-loading" role="status" aria-live="polite" aria-busy="true">
|
|
||||||
<span class="xshop-loading__spinner" aria-hidden="true"></span>
|
|
||||||
<span><?php echo esc_html($label); ?></span>
|
|
||||||
</div>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Pagination — archive/search, accessible, RTL-safe.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
global $wp_query;
|
|
||||||
|
|
||||||
$query = $args['query'] ?? $wp_query;
|
|
||||||
if (!$query instanceof WP_Query) { return; }
|
|
||||||
if ((int) $query->max_num_pages <= 1) { return; }
|
|
||||||
|
|
||||||
$big = 999999999;
|
|
||||||
$links = paginate_links([
|
|
||||||
'base' => str_replace((string) $big, '%#%', esc_url(get_pagenum_link((string) $big))),
|
|
||||||
'format' => '?paged=%#%',
|
|
||||||
'current' => max(1, (int) get_query_var('paged', 1)),
|
|
||||||
'total' => (int) $query->max_num_pages,
|
|
||||||
'prev_text' => is_rtl() ? esc_html__('Next', 'xshop') : esc_html__('Previous', 'xshop'),
|
|
||||||
'next_text' => is_rtl() ? esc_html__('Previous', 'xshop') : esc_html__('Next', 'xshop'),
|
|
||||||
'type' => 'array',
|
|
||||||
'mid_size' => 1,
|
|
||||||
'end_size' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (empty($links) || !is_array($links)) { return; }
|
|
||||||
?>
|
|
||||||
<nav class="xshop-pagination" aria-label="<?php esc_attr_e('Pagination', 'xshop'); ?>">
|
|
||||||
<div class="nav-links">
|
|
||||||
<?php foreach ($links as $link):
|
|
||||||
// paginate_links returns escaped HTML; we allow it via wp_kses with minimal allow-list
|
|
||||||
// but current span vs anchor is already safe. Use wp_kses_post to preserve classes.
|
|
||||||
echo wp_kses_post($link);
|
|
||||||
endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Post Card — reusable, handles missing thumb/excerpt/long title.
|
|
||||||
*
|
|
||||||
* Expects $args['post_id'] or uses global $post.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
$postId = isset($args['post_id']) ? (int) $args['post_id'] : get_the_ID();
|
|
||||||
if (!$postId) { return; }
|
|
||||||
$title = get_the_title($postId);
|
|
||||||
if ($title === '') { $title = esc_html__('Untitled', 'xshop'); }
|
|
||||||
$permalink = get_permalink($postId);
|
|
||||||
$excerpt = has_excerpt($postId) ? get_the_excerpt($postId) : wp_trim_words(wp_strip_all_tags(get_post_field('post_content', $postId)), 22);
|
|
||||||
$hasThumb = has_post_thumbnail($postId);
|
|
||||||
?>
|
|
||||||
<article <?php post_class('xshop-card'); ?>>
|
|
||||||
<a href="<?php echo esc_url($permalink); ?>" class="xshop-card__media <?php echo $hasThumb ? '' : 'xshop-card__media--placeholder'; ?>" aria-hidden="<?php echo $hasThumb ? 'false' : 'true'; ?>" tabindex="-1">
|
|
||||||
<?php if ($hasThumb): ?>
|
|
||||||
<?php echo get_the_post_thumbnail($postId, 'xshop-card', ['loading' => 'lazy', 'decoding' => 'async', 'alt' => esc_attr($title)]); ?>
|
|
||||||
<?php else: ?>
|
|
||||||
<span><?php esc_html_e('No image', 'xshop'); ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</a>
|
|
||||||
<div class="xshop-card__body">
|
|
||||||
<h3 class="xshop-card__title"><a href="<?php echo esc_url($permalink); ?>"><?php echo esc_html($title); ?></a></h3>
|
|
||||||
<div class="xshop-card__meta">
|
|
||||||
<time datetime="<?php echo esc_attr(get_the_date('c', $postId)); ?>"><?php echo esc_html(get_the_date('', $postId)); ?></time>
|
|
||||||
<?php
|
|
||||||
$cats = get_the_category($postId);
|
|
||||||
if (!empty($cats[0])) {
|
|
||||||
echo '<span aria-hidden="true">·</span><a href="' . esc_url(get_category_link($cats[0]->term_id)) . '">' . esc_html($cats[0]->name) . '</a>';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
</div>
|
|
||||||
<?php if ($excerpt !== ''): ?>
|
|
||||||
<p class="xshop-card__excerpt"><?php echo esc_html($excerpt); ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Product Filters — reusable sidebar (desktop) + drawer (mobile) + no-JS fallback.
|
|
||||||
*
|
|
||||||
* Expects context via $args or current queried object (shop/category).
|
|
||||||
* Uses same canonical query params as REST /xshop/v1/products and URL sync.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
if (!xshop_has_woocommerce()) { return; }
|
|
||||||
|
|
||||||
// Current state from URL (canonical)
|
|
||||||
$currentCategory = isset($_GET['category']) ? sanitize_text_field(wp_unslash($_GET['category'])) : '';
|
|
||||||
if (is_product_category() && $currentCategory === '') {
|
|
||||||
$term = get_queried_object();
|
|
||||||
if ($term instanceof WP_Term) { $currentCategory = $term->slug; }
|
|
||||||
}
|
|
||||||
$currentMinPrice = isset($_GET['min_price']) ? sanitize_text_field(wp_unslash($_GET['min_price'])) : '';
|
|
||||||
$currentMaxPrice = isset($_GET['max_price']) ? sanitize_text_field(wp_unslash($_GET['max_price'])) : '';
|
|
||||||
$currentStock = isset($_GET['stock']) ? sanitize_key(wp_unslash($_GET['stock'])) : 'all';
|
|
||||||
$currentOnSale = isset($_GET['on_sale']) ? '1' : (isset($_GET['sale']) ? '1' : '0');
|
|
||||||
if (isset($_GET['on_sale'])) { $currentOnSale = $_GET['on_sale'] === '1' ? '1' : '0'; }
|
|
||||||
if (isset($_GET['sale']) && $_GET['sale'] === '1') { $currentOnSale = '1'; }
|
|
||||||
$currentRating = isset($_GET['rating']) ? absint($_GET['rating']) : 0;
|
|
||||||
$currentOrderby = isset($_GET['orderby']) ? sanitize_key(wp_unslash($_GET['orderby'])) : 'default';
|
|
||||||
|
|
||||||
// Categories
|
|
||||||
$categories = get_terms(['taxonomy'=>'product_cat','hide_empty'=>true,'number'=>20]);
|
|
||||||
if (is_wp_error($categories)) { $categories = []; }
|
|
||||||
|
|
||||||
// Attributes — discover public Woo attribute taxonomies pa_*
|
|
||||||
$attributeTaxonomies = [];
|
|
||||||
if (function_exists('wc_get_attribute_taxonomies')) {
|
|
||||||
$taxes = wc_get_attribute_taxonomies();
|
|
||||||
foreach ($taxes as $tax) {
|
|
||||||
$name = 'pa_' . $tax->attribute_name;
|
|
||||||
if (taxonomy_exists($name)) { $attributeTaxonomies[] = $name; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also allow fallback to any pa_* that exists and has terms
|
|
||||||
if (empty($attributeTaxonomies)) {
|
|
||||||
$allTax = get_taxonomies(['public'=>false]);
|
|
||||||
foreach ($allTax as $t) { if (str_starts_with($t,'pa_') && taxonomy_exists($t)) { $attributeTaxonomies[] = $t; } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// For no-JS fallback, we render a <form method="get" action="shop/category URL"> with same params
|
|
||||||
$shopUrl = wc_get_page_permalink('shop');
|
|
||||||
if (is_product_category()) {
|
|
||||||
$term = get_queried_object();
|
|
||||||
if ($term instanceof WP_Term) { $shopUrl = get_term_link($term); }
|
|
||||||
}
|
|
||||||
if (is_wp_error($shopUrl) || !$shopUrl) { $shopUrl = home_url('/shop/'); }
|
|
||||||
|
|
||||||
// Active chips data for JS
|
|
||||||
?>
|
|
||||||
<div class="xshop-filters" data-xshop-filters data-shop-url="<?php echo esc_url($shopUrl); ?>" data-category-context="<?php echo esc_attr($currentCategory); ?>">
|
|
||||||
<!-- Active chips -->
|
|
||||||
<div class="xshop-filters__chips" data-xshop-chips aria-live="polite"></div>
|
|
||||||
|
|
||||||
<form method="get" action="<?php echo esc_url($shopUrl); ?>" class="xshop-filters__form" data-xshop-filters-form>
|
|
||||||
<?php
|
|
||||||
// Preserve search keyword if present (from M3)
|
|
||||||
if (isset($_GET['s'])) {
|
|
||||||
echo '<input type="hidden" name="s" value="' . esc_attr(sanitize_text_field(wp_unslash($_GET['s']))) . '" />';
|
|
||||||
}
|
|
||||||
// Preserve category context as hidden if on category archive (so clearing filters doesn't drop category unless user clears chip)
|
|
||||||
if (is_product_category() && $currentCategory !== '') {
|
|
||||||
echo '<input type="hidden" name="category" value="' . esc_attr($currentCategory) . '" data-xshop-category-context />';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
|
|
||||||
<!-- Categories (if not already on category context? Show anyway with active state) -->
|
|
||||||
<?php if (!empty($categories)): ?>
|
|
||||||
<fieldset class="xshop-filter">
|
|
||||||
<legend class="xshop-filter__title"><?php esc_html_e('Categories', 'xshop'); ?></legend>
|
|
||||||
<div class="xshop-filter__options">
|
|
||||||
<?php foreach ($categories as $cat):
|
|
||||||
$isActive = $currentCategory === $cat->slug;
|
|
||||||
?>
|
|
||||||
<label class="xshop-filter__option">
|
|
||||||
<input type="radio" name="category" value="<?php echo esc_attr($cat->slug); ?>" <?php checked($isActive); ?> data-filter="category" />
|
|
||||||
<span><?php echo esc_html($cat->name); ?></span>
|
|
||||||
<span class="xshop-filter__count"><?php echo esc_html(number_format_i18n((int)$cat->count)); ?></span>
|
|
||||||
</label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<label class="xshop-filter__option">
|
|
||||||
<input type="radio" name="category" value="" <?php checked($currentCategory === ''); ?> data-filter="category" />
|
|
||||||
<span><?php esc_html_e('All categories', 'xshop'); ?></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<!-- Attributes -->
|
|
||||||
<?php foreach ($attributeTaxonomies as $tax):
|
|
||||||
$terms = get_terms(['taxonomy'=>$tax,'hide_empty'=>true,'number'=>30]);
|
|
||||||
if (is_wp_error($terms) || empty($terms)) { continue; }
|
|
||||||
$taxLabel = wc_attribute_label($tax);
|
|
||||||
$activeTerms = [];
|
|
||||||
if (isset($_GET[$tax])) {
|
|
||||||
$raw = wp_unslash($_GET[$tax]);
|
|
||||||
if (is_string($raw)) {
|
|
||||||
foreach (explode(',', $raw) as $t) { $t = sanitize_title($t); if ($t !== '') { $activeTerms[] = $t; } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also support filter_ prefix legacy
|
|
||||||
$filterKey = 'filter_' . substr($tax, 3);
|
|
||||||
if (isset($_GET[$filterKey])) {
|
|
||||||
$raw = wp_unslash($_GET[$filterKey]);
|
|
||||||
foreach (explode(',', (string)$raw) as $t) { $t = sanitize_title($t); if ($t !== '') { $activeTerms[] = $t; } }
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<fieldset class="xshop-filter">
|
|
||||||
<legend class="xshop-filter__title"><?php echo esc_html($taxLabel); ?></legend>
|
|
||||||
<div class="xshop-filter__options">
|
|
||||||
<?php foreach ($terms as $term): $isActive = in_array($term->slug, $activeTerms, true); ?>
|
|
||||||
<label class="xshop-filter__option">
|
|
||||||
<input type="checkbox" name="<?php echo esc_attr($tax); ?>[]" value="<?php echo esc_attr($term->slug); ?>" <?php checked($isActive); ?> data-filter="attribute" data-tax="<?php echo esc_attr($tax); ?>" />
|
|
||||||
<span><?php echo esc_html($term->name); ?></span>
|
|
||||||
<span class="xshop-filter__count"><?php echo esc_html(number_format_i18n((int)$term->count)); ?></span>
|
|
||||||
</label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
|
|
||||||
<!-- Price -->
|
|
||||||
<fieldset class="xshop-filter">
|
|
||||||
<legend class="xshop-filter__title"><?php esc_html_e('Price', 'xshop'); ?></legend>
|
|
||||||
<div class="xshop-filter__price">
|
|
||||||
<label>
|
|
||||||
<span class="xshop-sr-only"><?php esc_html_e('Min price', 'xshop'); ?></span>
|
|
||||||
<input type="number" name="min_price" placeholder="<?php esc_attr_e('Min', 'xshop'); ?>" value="<?php echo esc_attr($currentMinPrice); ?>" min="0" step="1" data-filter="price" />
|
|
||||||
</label>
|
|
||||||
<span aria-hidden="true">—</span>
|
|
||||||
<label>
|
|
||||||
<span class="xshop-sr-only"><?php esc_html_e('Max price', 'xshop'); ?></span>
|
|
||||||
<input type="number" name="max_price" placeholder="<?php esc_attr_e('Max', 'xshop'); ?>" value="<?php echo esc_attr($currentMaxPrice); ?>" min="0" step="1" data-filter="price" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<!-- Stock -->
|
|
||||||
<fieldset class="xshop-filter">
|
|
||||||
<legend class="xshop-filter__title"><?php esc_html_e('Stock', 'xshop'); ?></legend>
|
|
||||||
<div class="xshop-filter__options">
|
|
||||||
<label class="xshop-filter__option"><input type="radio" name="stock" value="all" <?php checked($currentStock, 'all'); ?> data-filter="stock" /> <span><?php esc_html_e('All', 'xshop'); ?></span></label>
|
|
||||||
<label class="xshop-filter__option"><input type="radio" name="stock" value="instock" <?php checked($currentStock, 'instock'); ?> data-filter="stock" /> <span><?php esc_html_e('In stock', 'xshop'); ?></span></label>
|
|
||||||
<label class="xshop-filter__option"><input type="radio" name="stock" value="outofstock" <?php checked($currentStock, 'outofstock'); ?> data-filter="stock" /> <span><?php esc_html_e('Out of stock', 'xshop'); ?></span></label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<!-- Sale -->
|
|
||||||
<fieldset class="xshop-filter">
|
|
||||||
<legend class="xshop-filter__title"><?php esc_html_e('Sale', 'xshop'); ?></legend>
|
|
||||||
<label class="xshop-filter__option"><input type="checkbox" name="on_sale" value="1" <?php checked($currentOnSale, '1'); ?> data-filter="sale" /> <span><?php esc_html_e('On sale', 'xshop'); ?></span></label>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<!-- Rating -->
|
|
||||||
<fieldset class="xshop-filter">
|
|
||||||
<legend class="xshop-filter__title"><?php esc_html_e('Rating', 'xshop'); ?></legend>
|
|
||||||
<div class="xshop-filter__options">
|
|
||||||
<?php for ($r=5;$r>=1;$r--): $isActive = $currentRating === $r; ?>
|
|
||||||
<label class="xshop-filter__option"><input type="radio" name="rating" value="<?php echo esc_attr((string)$r); ?>" <?php checked($isActive); ?> data-filter="rating" /> <span><?php echo esc_html(sprintf(esc_html__('%d+ stars', 'xshop'), $r)); ?></span></label>
|
|
||||||
<?php endfor; ?>
|
|
||||||
<label class="xshop-filter__option"><input type="radio" name="rating" value="0" <?php checked($currentRating, 0); ?> data-filter="rating" /> <span><?php esc_html_e('Any rating', 'xshop'); ?></span></label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<!-- Sorting (also in toolbar, but keep here for no-JS) -->
|
|
||||||
<fieldset class="xshop-filter">
|
|
||||||
<legend class="xshop-filter__title"><?php esc_html_e('Sort', 'xshop'); ?></legend>
|
|
||||||
<select name="orderby" data-filter="orderby" aria-label="<?php esc_attr_e('Sort', 'xshop'); ?>">
|
|
||||||
<option value="default" <?php selected($currentOrderby, 'default'); ?>><?php esc_html_e('Default', 'xshop'); ?></option>
|
|
||||||
<option value="latest" <?php selected($currentOrderby, 'latest'); ?>><?php esc_html_e('Latest', 'xshop'); ?></option>
|
|
||||||
<option value="price_asc" <?php selected($currentOrderby, 'price_asc'); ?>><?php esc_html_e('Price: low to high', 'xshop'); ?></option>
|
|
||||||
<option value="price_desc" <?php selected($currentOrderby, 'price_desc'); ?>><?php esc_html_e('Price: high to low', 'xshop'); ?></option>
|
|
||||||
<option value="popularity" <?php selected($currentOrderby, 'popularity'); ?>><?php esc_html_e('Popular', 'xshop'); ?></option>
|
|
||||||
<option value="rating" <?php selected($currentOrderby, 'rating'); ?>><?php esc_html_e('Rated', 'xshop'); ?></option>
|
|
||||||
</select>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div class="xshop-filters__actions">
|
|
||||||
<button type="submit" class="xshop-btn xshop-btn--primary"><?php esc_html_e('Apply', 'xshop'); ?></button>
|
|
||||||
<a href="<?php echo esc_url($shopUrl); ?>" class="xshop-btn xshop-btn--ghost" data-xshop-clear-all><?php esc_html_e('Clear all', 'xshop'); ?></a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Promotional banner foundation — rendered via banner CPT later; static variant for M1.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
$title = $args['title'] ?? '';
|
|
||||||
$text = $args['text'] ?? '';
|
|
||||||
$ctaLabel = $args['cta_label'] ?? '';
|
|
||||||
$ctaUrl = $args['cta_url'] ?? '';
|
|
||||||
if ($title === '' && $text === '') { return; }
|
|
||||||
?>
|
|
||||||
<section class="xshop-promo" aria-label="<?php esc_attr_e('Promotion', 'xshop'); ?>">
|
|
||||||
<div>
|
|
||||||
<?php if ($title !== ''): ?><h2 class="xshop-section__title" style="font-size:var(--xshop-text-xl);margin:0 0 var(--xshop-space-1)"><?php echo esc_html($title); ?></h2><?php endif; ?>
|
|
||||||
<?php if ($text !== ''): ?><p style="margin:0;color:var(--xshop-muted)"><?php echo esc_html($text); ?></p><?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php if ($ctaLabel !== '' && $ctaUrl !== ''): ?>
|
|
||||||
<a class="xshop-btn xshop-btn--primary" href="<?php echo esc_url($ctaUrl); ?>"><?php echo esc_html($ctaLabel); ?></a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</section>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Search form — accessible, RTL-safe.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
$uid = 'xshop-search-' . wp_unique_id();
|
|
||||||
$placeholder = $args['placeholder'] ?? esc_html__('Search…', 'xshop');
|
|
||||||
$action = $args['action'] ?? home_url('/');
|
|
||||||
?>
|
|
||||||
<form role="search" method="get" action="<?php echo esc_url($action); ?>" class="xshop-search-form">
|
|
||||||
<label class="xshop-sr-only" for="<?php echo esc_attr($uid); ?>"><?php esc_html_e('Search', 'xshop'); ?></label>
|
|
||||||
<input type="search" id="<?php echo esc_attr($uid); ?>" name="s" value="<?php echo esc_attr(get_search_query()); ?>" placeholder="<?php echo esc_attr($placeholder); ?>" required />
|
|
||||||
<button type="submit" class="xshop-btn xshop-btn--primary"><?php esc_html_e('Search', 'xshop'); ?></button>
|
|
||||||
</form>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Section Heading — reusable for home sections.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
$title = $args['title'] ?? '';
|
|
||||||
if ($title === '') { return; }
|
|
||||||
$linkLabel = $args['link_label'] ?? '';
|
|
||||||
$linkUrl = $args['link_url'] ?? '';
|
|
||||||
$tag = $args['tag'] ?? 'h2';
|
|
||||||
$allowed = ['h2','h3','div'];
|
|
||||||
if (!in_array($tag, $allowed, true)) { $tag = 'h2'; }
|
|
||||||
?>
|
|
||||||
<div class="xshop-section__header">
|
|
||||||
<<?php echo esc_attr($tag); ?> class="xshop-section__title"><?php echo esc_html($title); ?></<?php echo esc_attr($tag); ?>>
|
|
||||||
<?php if ($linkLabel !== '' && $linkUrl !== ''): ?>
|
|
||||||
<a class="xshop-section__link" href="<?php echo esc_url($linkUrl); ?>"><?php echo esc_html($linkLabel); ?></a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Site Footer — widgets, nav, branding fallback, copyright.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
$branding = xshop_get_branding_fallback();
|
|
||||||
$year = date_i18n('Y');
|
|
||||||
?>
|
|
||||||
<div class="xshop-container">
|
|
||||||
<div class="xshop-footer__widgets">
|
|
||||||
<?php for ($i = 1; $i <= 4; $i++):
|
|
||||||
$id = 'footer-' . $i;
|
|
||||||
$has = is_active_sidebar($id);
|
|
||||||
?>
|
|
||||||
<div class="xshop-footer__col">
|
|
||||||
<?php if ($has): ?>
|
|
||||||
<?php dynamic_sidebar($id); ?>
|
|
||||||
<?php else: ?>
|
|
||||||
<section class="xshop-widget">
|
|
||||||
<h3 class="xshop-widget__title"><?php echo esc_html(sprintf(esc_html__('Footer %d', 'xshop'), $i)); ?></h3>
|
|
||||||
<p class="xshop-text-muted" style="font-size:var(--xshop-text-sm)"><?php esc_html_e('Add widgets in Appearance → Widgets.', 'xshop'); ?></p>
|
|
||||||
</section>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endfor; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xshop-footer__bottom">
|
|
||||||
<p style="margin:0">
|
|
||||||
<?php
|
|
||||||
// Translators: %1$s year, %2$s site name.
|
|
||||||
printf(esc_html__('© %1$s %2$s. All rights reserved.', 'xshop'), esc_html($year), esc_html($branding['name']));
|
|
||||||
?>
|
|
||||||
</p>
|
|
||||||
<nav aria-label="<?php esc_attr_e('Footer', 'xshop'); ?>">
|
|
||||||
<?php
|
|
||||||
wp_nav_menu([
|
|
||||||
'theme_location' => 'xshop-footer',
|
|
||||||
'container' => false,
|
|
||||||
'menu_class' => 'xshop-footer__nav-list',
|
|
||||||
'depth' => 1,
|
|
||||||
'fallback_cb' => false,
|
|
||||||
]);
|
|
||||||
?>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Site Header — branding, primary nav, mobile nav, search, WC hooks.
|
|
||||||
* Used by header.php. Future Header Builder will extend, not replace.
|
|
||||||
*
|
|
||||||
* @package XShop
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
$branding = xshop_get_branding_fallback();
|
|
||||||
$hasLogo = has_custom_logo();
|
|
||||||
?>
|
|
||||||
<div class="xshop-header__inner xshop-container">
|
|
||||||
<div class="xshop-header__branding">
|
|
||||||
<?php if ($hasLogo): ?>
|
|
||||||
<div class="xshop-header__logo"><?php the_custom_logo(); ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<div>
|
|
||||||
<p class="xshop-header__title"><a href="<?php echo esc_url(home_url('/')); ?>" rel="home"><?php echo esc_html($branding['name']); ?></a></p>
|
|
||||||
<?php if ($branding['desc'] !== ''): ?>
|
|
||||||
<p class="xshop-header__tagline"><?php echo esc_html($branding['desc']); ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="xshop-header__nav xshop-nav xshop-nav--desktop" aria-label="<?php esc_attr_e('Primary', 'xshop'); ?>">
|
|
||||||
<?php
|
|
||||||
wp_nav_menu([
|
|
||||||
'theme_location' => 'xshop-primary',
|
|
||||||
'container' => false,
|
|
||||||
'menu_class' => 'xshop-nav__list',
|
|
||||||
'fallback_cb' => static function (): void {
|
|
||||||
echo '<ul class="xshop-nav__list"><li><a href="' . esc_url(home_url('/')) . '">' . esc_html__('Home', 'xshop') . '</a></li></ul>';
|
|
||||||
},
|
|
||||||
'depth' => 2,
|
|
||||||
]);
|
|
||||||
?>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="xshop-header__actions">
|
|
||||||
<div class="xshop-header__search xshop-header__search--desktop">
|
|
||||||
<?php get_template_part('template-parts/components/ajax-search', null, ['placeholder' => esc_html__('Search products…', 'xshop')]); ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if (xshop_has_woocommerce()): ?>
|
|
||||||
<a class="xshop-header__action-btn" href="<?php echo esc_url(function_exists('wc_get_page_permalink') ? wc_get_page_permalink('myaccount') : wp_login_url()); ?>" aria-label="<?php esc_attr_e('My Account', 'xshop'); ?>">
|
|
||||||
<span aria-hidden="true">◉</span><span class="xshop-sr-only"><?php esc_html_e('My Account', 'xshop'); ?></span>
|
|
||||||
</a>
|
|
||||||
<?php if (function_exists('wc_get_cart_url')): ?>
|
|
||||||
<a class="xshop-header__action-btn" href="<?php echo esc_url(wc_get_cart_url()); ?>" aria-label="<?php esc_attr_e('Cart', 'xshop'); ?>">
|
|
||||||
<span aria-hidden="true">🛒</span><span class="xshop-sr-only"><?php esc_html_e('Cart', 'xshop'); ?></span>
|
|
||||||
</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<button type="button" class="xshop-mobile-toggle" data-xshop-mobile-toggle aria-expanded="false" aria-controls="xshop-mobile-nav" aria-label="<?php esc_attr_e('Toggle menu', 'xshop'); ?>">
|
|
||||||
<span aria-hidden="true">☰</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav id="xshop-mobile-nav" class="xshop-mobile-nav xshop-container" aria-label="<?php esc_attr_e('Mobile', 'xshop'); ?>" hidden>
|
|
||||||
<?php
|
|
||||||
wp_nav_menu([
|
|
||||||
'theme_location' => 'xshop-primary',
|
|
||||||
'container' => false,
|
|
||||||
'menu_class' => 'xshop-mobile-nav__list',
|
|
||||||
'fallback_cb' => static function (): void {
|
|
||||||
echo '<ul class="xshop-mobile-nav__list"><li><a href="' . esc_url(home_url('/')) . '">' . esc_html__('Home', 'xshop') . '</a></li></ul>';
|
|
||||||
},
|
|
||||||
'depth' => 2,
|
|
||||||
]);
|
|
||||||
?>
|
|
||||||
<div style="margin-block-start:var(--xshop-space-4)">
|
|
||||||
<?php get_template_part('template-parts/components/ajax-search', null, ['placeholder' => esc_html__('Search products…', 'xshop'), 'id' => 'xshop-ajax-search-mobile-' . wp_unique_id()]); ?>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Shop / category archive — XShop filters + AJAX grid + no-JS fallback.
|
|
||||||
*
|
|
||||||
* Integrates product-filters sidebar (desktop) + drawer (mobile) + toolbar.
|
|
||||||
* Server render uses Woo hooks + ProductQuery via pre_get_posts (inc/woocommerce/setup.php) for no-JS.
|
|
||||||
* JS enhances via REST /xshop/v1/products.
|
|
||||||
*
|
|
||||||
* 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>';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
|
|
||||||
<div class="xshop-shop" data-xshop-shop>
|
|
||||||
<!-- Mobile filter toggle -->
|
|
||||||
<button type="button" class="xshop-btn xshop-btn--secondary xshop-shop__filter-toggle" data-xshop-drawer-toggle aria-expanded="false" aria-controls="xshop-filter-drawer">
|
|
||||||
<?php esc_html_e('Filters', 'xshop'); ?>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="xshop-shop__layout">
|
|
||||||
<!-- Desktop sidebar -->
|
|
||||||
<aside class="xshop-shop__sidebar" aria-label="<?php esc_attr_e('Filters', 'xshop'); ?>">
|
|
||||||
<?php get_template_part('template-parts/components/product-filters'); ?>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Mobile drawer -->
|
|
||||||
<div class="xshop-drawer" id="xshop-filter-drawer" hidden data-xshop-drawer role="dialog" aria-modal="true" aria-label="<?php esc_attr_e('Filters', 'xshop'); ?>">
|
|
||||||
<div class="xshop-drawer__header">
|
|
||||||
<h2 class="xshop-drawer__title"><?php esc_html_e('Filters', 'xshop'); ?></h2>
|
|
||||||
<button type="button" class="xshop-btn xshop-btn--ghost" data-xshop-drawer-close aria-label="<?php esc_attr_e('Close filters', 'xshop'); ?>">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="xshop-drawer__body">
|
|
||||||
<?php get_template_part('template-parts/components/product-filters'); ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="xshop-drawer__overlay" hidden data-xshop-drawer-overlay></div>
|
|
||||||
|
|
||||||
<!-- Main -->
|
|
||||||
<div class="xshop-shop__main" data-xshop-shop-main>
|
|
||||||
<div class="xshop-shop__toolbar" data-xshop-toolbar>
|
|
||||||
<?php do_action('woocommerce_before_shop_loop'); ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xshop-search__loading" hidden data-xshop-loading style="display:none">
|
|
||||||
<span class="xshop-loading__spinner" aria-hidden="true"></span> <?php esc_html_e('Loading…', 'xshop'); ?>
|
|
||||||
</div>
|
|
||||||
<div class="xshop-search__error" hidden data-xshop-error role="alert"></div>
|
|
||||||
|
|
||||||
<div data-xshop-products-container>
|
|
||||||
<?php
|
|
||||||
if (woocommerce_product_loop()) {
|
|
||||||
woocommerce_product_loop_start();
|
|
||||||
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');
|
|
||||||
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'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div data-xshop-pagination>
|
|
||||||
<?php
|
|
||||||
// Woo pagination rendered via woocommerce_after_shop_loop already inside loop; for AJAX we render custom via JS
|
|
||||||
// Ensure result count is accessible
|
|
||||||
?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
do_action('woocommerce_after_main_content');
|
|
||||||
get_footer('shop');
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<?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'); ?>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?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');
|
|
||||||
Reference in New Issue
Block a user