21 Commits
v0.0.1 ... dev

Author SHA1 Message Date
5e4af247fa Make PHP version configurable in Dockerfile
Add BuildKit syntax directive and PHP_VERSION build arg (default 8.4)
to allow building with different PHP versions without editing the file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:55:52 +01:00
4031a1c8aa Add PHPUnit test suite with Brain\Monkey (v0.1.6)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m2s
Create Release Package / PHPUnit Tests (push) Successful in 46s
Create Release Package / Build Release (push) Successful in 50s
Add test infrastructure for isolated unit testing without WordPress/WooCommerce:
- 27 tests (54 assertions) covering TemplateOverride and WooCommerceExtension
- Brain\Monkey for WordPress function mocking, class stubs for TwigService and WC_Product
- PHPUnit test job added to Gitea CI pipeline between lint and build-release
- Test artifacts excluded from release packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:48:19 +01:00
784b400c46 Fix 10 known bugs: catalog, single product, and account pages (v0.1.5)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 44s
Create Release Package / Build Release (push) Successful in 52s
Catalog: page title via woocommerce_page_title(), breadcrumbs, category
template rename (underscore), 3-column grid, single chevron on sort.

Single product: variable form data attributes + disabled CSS class fix
(WC JS only toggles CSS classes, not HTML disabled attribute), dark mode
select specificity (0,5,1) to beat WC's (0,4,3) background shorthand,
gallery main image in thumbnail strip with empty URL guard, related/
upsells setup_postdata for correct global $product, grouped product
loop logic rewrite.

Account: downloads via wc_get_customer_available_downloads().

New: product-gallery.js, sanitize_title filter, wc_setup_product_data()
and wp_reset_postdata() Twig functions, product-thumbnails.html.twig
suppressor. Removed obsolete PLAN.md and SETUP.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:33:31 +01:00
98359d4cfb Security audit fixes: fn() whitelist, escaping, and performance (v0.1.4)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m41s
Create Release Package / Build Release (push) Successful in 1m47s
- WooCommerceExtension: ALLOWED_FUNCTIONS whitelist for fn() Twig function
- Notice templates: data attributes use wp_kses_post instead of raw
- Search form: esc_attr on search query value attribute
- Per-request ContextBuilder caching via static variable
- Shared wc_bootstrap_render_in_page_shell() helper (DRY)
- Removed unused WC_BOOTSTRAP_VERSION and WC_BOOTSTRAP_URL constants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:02:43 +01:00
e72b4ba3c1 updated the theme screenshot 2026-03-01 00:42:11 +01:00
bf24b121f5 Add theme screenshot and bump to v0.1.3
All checks were successful
Create Release Package / PHP Lint (push) Successful in 59s
Create Release Package / Build Release (push) Successful in 1m13s
Add dark mode product archive screenshot for WordPress theme preview.
Update version, changelog, and documentation for release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:35:34 +01:00
cfe089d1fe Fix dark mode rendering for tables, form inputs, and notices
All checks were successful
Create Release Package / PHP Lint (push) Successful in 51s
Create Release Package / Build Release (push) Successful in 1m9s
- Override WooCommerce's .input-text background (specificity 0,3,1) with
  [data-bs-theme="dark"] selector for text inputs and textareas
- Remove table-light from <thead> in cart, review-order, orders, and
  payment-methods templates (forces white in dark mode)
- Add .alert.woocommerce-* compound selectors for notice overrides
  outside .woocommerce wrapper ancestry
- Suppress focus ring on programmatically focused notices
  (woocommerce.js focus_populate_live_region adds tabindex + focus)
- Wrap order-details product table in card matching thank-you page style
- Fix thank-you alert icon/text line wrap with d-flex align-items-center
- Bump version to 0.1.2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:13:31 +01:00
cb2d064441 Prepare v0.1.1 bugfix release
All checks were successful
Create Release Package / PHP Lint (push) Successful in 59s
Create Release Package / Build Release (push) Successful in 1m11s
Update CHANGELOG.md with v0.1.1 entry (dark mode selects, notice
styling, product card overflow). Bump version in style.css. Document
CSS specificity learnings in CLAUDE.md. Update README release example.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:32:34 +01:00
e234ba6449 Fix product card images overlapping top border-radius
Add overflow-hidden to the card <article> so the card's own
border-radius clips the product image at the top corners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:25:49 +01:00
f4877833cf Fix dark mode selects, notice borders, and double notice icons
- Override WooCommerce's hardcoded #fff on native <select> and Select2
  widgets for Bootstrap 5 dark mode (background, color, border)
- Suppress WooCommerce icon font ::before on notices — templates
  already render Bootstrap Icons (bi-check-circle, etc.)
- Bump notice selector specificity to .woocommerce .woocommerce-message
  (0,2,0) to beat woocommerce.css border-top: 3px solid override
- Explicitly reset border-top shorthand to prevent WooCommerce's
  colored top-border from bleeding through

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:24:55 +01:00
01404c87ba Update CLAUDE.md session history for v0.1.0 re-release
All checks were successful
Create Release Package / PHP Lint (push) Successful in 57s
Create Release Package / Build Release (push) Successful in 1m9s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:02:34 +01:00
f3e7ede60c Add v0.1.0 changelog entry
Document all features and fixes between v0.0.1 and v0.1.0:
Docker stack, My Account redesign, product archive card grid,
single product layout, and 14 template quirk fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:01:26 +01:00
6ee95f4a2f Fix template quirks and bump version to 0.1.0
All checks were successful
Create Release Package / PHP Lint (push) Successful in 57s
Create Release Package / Build Release (push) Successful in 1m11s
Audit and fix 14 Twig templates for escaping bugs, CSS conflicts,
and missing Bootstrap styling:
- Fix nl2br/esc_html filter order in order details
- Add WC gallery modifier classes for zoom/photoswipe JS init
- Fix HTML entity double-encoding in headings (up-sells, cross-sells, related)
- Remove wrong 'is defined' guards on function calls
- Remove duplicate deprecated hooks in dashboard
- Add |raw to brand description HTML filter chain
- Add role="alert" for accessibility, |esc_attr on notification types
- Style mini-cart remove button as Bootstrap btn
- Make shipping form-check class conditional
- Add shop_table CSS reset and gallery opacity fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:50:19 +01:00
c5f8e88ee4 Fix single product layout conflicts with WooCommerce CSS
WooCommerce's layout CSS fought Bootstrap's grid in three ways:

1. Float-based two-column layout (width: 48% + float) on div.images
   and div.summary squeezed content inside our col-lg-6 columns.
   Reset with float: none; width: 100%.

2. Nested content wrapper (.container + #primary + <main>) from
   woocommerce_output_content_wrapper doubled up on the parent
   theme's existing .container. Remove the hooks entirely.

3. Sale badge (position: absolute; top: -.5em; z-index: 9) escaped
   the image column and blocked breadcrumb clicks. Override to
   top: 0.5em; z-index: 1 and use gx-* (horizontal-only gutters)
   to avoid negative margin-top on the .row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:25:01 +01:00
7fda8e1962 Add Bootstrap 5 single product page layout
Add two-column responsive grid (image gallery + product summary) for
single product pages, following the same bridge pattern used for
product archives.

Key changes:
- Create content-single-product.php bridge and Twig layout template
- Add single product renderer at template_redirect priority 11
- Disable WooCommerce block compatibility layer that strips classic
  hooks when parent theme has theme.json
- Move PHP templates to woocommerce/ subfolder for cleaner structure
- Fix Twig templates to self-compute context data not passed by
  wc_get_template() (tabs, short-description, meta, rating)
- Fix Underscore.js triple-brace syntax conflict in variation template
  by wrapping in {% verbatim %}

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:55:39 +01:00
00872a6568 Add Bootstrap 5 product archive with card grid and sidebar
Replace WooCommerce's default shop/category page rendering with a
Bootstrap 5 card grid layout featuring responsive columns, sale badges,
star ratings, and an offcanvas sidebar for filters on mobile.

Key implementation details:

- Bypass parent theme's TemplateController for product archives via
  wp_bootstrap_should_render_template filter, render at template_redirect
  priority 11 using the same page shell injection pattern as plugin pages

- Add archive-product.php (Bootstrap layout with optional sidebar) and
  content-product.php (PHP bridge for wc_get_template_part interception)

- Inject global $product into Twig context in TemplateOverride to fix
  empty price/add-to-cart/rating/sale-flash in loop sub-templates — Twig
  has isolated variable scopes and cannot access PHP globals directly

- Fix pagination URLs: use get_pagenum_link() instead of ?page= query
  param (WordPress uses 'paged' for archive pagination, not 'page')

- Fix double-escaped &ndash; in result count by adding |raw filter

- Reset WooCommerce float-based layout CSS (woocommerce-layout.css) for
  shop pages to prevent conflicts with Bootstrap flex grid

- Register shop-sidebar widget area with Bootstrap-styled markup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:06:33 +01:00
7034134678 Polish My Account templates with Bootstrap 5 patterns
Redesign navigation with endpoint icons, offcanvas-lg responsive
pattern, and sticky sidebar. Replace flat dashboard with card-based
welcome greeting (avatar) and quick-action grid. Wrap all forms
(edit-account, edit-address, lost/reset-password) in card sections
with icon headers. Restructure view-order with summary card and
status badge component. Override WooCommerce's float-based layout
and max-width constraint to let Bootstrap flex grid handle sizing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:28:15 +01:00
b8001a5ab0 Fix CSS dependency handle and update documentation
Correct wc-bootstrap-overrides stylesheet dependency from unregistered
'woocommerce' handle to 'woocommerce-general'. Update README directory
structure to reflect wc-base.html.twig rename and .env-dist location.
Add Fixed section to CHANGELOG.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:58:43 +01:00
c3b16b68c5 Fix unstyled pages: rename base.html.twig to avoid parent collision
The child's templates/base.html.twig was shadowing the parent's
views/base.html.twig (full HTML page shell) because prependPath()
made Twig find the child's minimal wrapper first. Rename to
wc-base.html.twig so the parent's page shell renders correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:29:20 +01:00
c0d1dc85c4 Refine Docker stack configuration
- Use mariadb:latest and wordpress:php8.4 floating tags
- Add private registry image name (hub.bundespruefstelle.ch/woocommerce)
- Add restart policies (always for prod, unless-stopped for dev)
- Move .env-dist to project root

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:15:46 +01:00
624de0cae6 Add Docker development environment
Multistage Dockerfile (WooCommerce download, wp-bootstrap npm build,
Composer deps, WordPress runtime), Compose stack with MariaDB, and
auto-setup entrypoint that installs WordPress and activates the theme
on first boot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:59:20 +01:00
90 changed files with 4920 additions and 1334 deletions

15
.claude/settings.json Normal file
View File

@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(git *)",
"Bash(docker exec woocommerce *)",
"Bash(docker compose *)",
"Bash(composer *)",
"Bash(npm *)",
"Bash(ls *)",
"Bash(mkdir *)",
"Bash(cat *)",
"Bash(php *)"
]
}
}

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
.git
.gitea
.github
.vscode
.claude
.idea
node_modules
vendor
releases
wp-core
wc-core
CLAUDE.md
PLAN.md
SETUP.md
*.log
*.bak
*.po~
.DS_Store
Thumbs.db

19
.env-dist Normal file
View File

@@ -0,0 +1,19 @@
# Database
MYSQL_ROOT_PASSWORD=rootpass
MYSQL_DATABASE=wordpress
MYSQL_USER=wordpress
MYSQL_PASSWORD=wordpress
# WordPress
WP_PORT=8080
# Auto-setup (runs on first boot, set to 0 to disable)
WP_AUTO_SETUP=1
WP_URL=http://localhost:8080
WP_TITLE=WC Bootstrap Dev
WP_ADMIN_USER=admin
WP_ADMIN_PASSWORD=admin
WP_ADMIN_EMAIL=admin@example.com
# Build args (used during `docker compose build`)
# WOOCOMMERCE_VERSION=latest

View File

@@ -24,10 +24,31 @@ jobs:
run: | run: |
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 php -l find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 php -l
test:
name: PHPUnit Tests
runs-on: ubuntu-latest
needs: [lint]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, dom
tools: composer:v2
- name: Install Composer dependencies
run: composer install --no-interaction
- name: Run PHPUnit
run: composer exec -- phpunit
build-release: build-release:
name: Build Release name: Build Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [lint] needs: [test]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -88,8 +109,8 @@ jobs:
cp -a . "${STAGING_DIR}/${THEME_NAME}" cp -a . "${STAGING_DIR}/${THEME_NAME}"
cd "${STAGING_DIR}/${THEME_NAME}" cd "${STAGING_DIR}/${THEME_NAME}"
rm -rf .git .gitea .github .vscode .claude releases node_modules rm -rf .git .gitea .github .vscode .claude releases node_modules tests .phpunit.cache
rm -f CLAUDE.md PLAN.md composer.json composer.lock .gitignore .editorconfig rm -f CLAUDE.md PLAN.md composer.json composer.lock .gitignore .editorconfig phpunit.xml.dist
find . -name '*.log' -o -name '*.po~' -o -name '*.bak' -o -name '.DS_Store' | xargs rm -f 2>/dev/null || true find . -name '*.log' -o -name '*.po~' -o -name '*.bak' -o -name '.DS_Store' | xargs rm -f 2>/dev/null || true
cd "${STAGING_DIR}" cd "${STAGING_DIR}"

5
.gitignore vendored
View File

@@ -31,3 +31,8 @@ npm-debug.log
# Build artifacts (releases directory) # Build artifacts (releases directory)
releases/ releases/
# Docker runtime
.env
KNOWN_BUGS.md
.phpunit.cache/

View File

@@ -2,6 +2,122 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.1.6] - 2026-03-01
### Added
- **PHPUnit test suite** with Brain\Monkey for WordPress function mocking (27 tests, 54 assertions)
- **TemplateOverrideTest**: Tests for hook registration, Twig template resolution, output buffer stack (including nesting), context passing, global `$product` injection, and exception fallback
- **WooCommerceExtensionTest**: Tests for function/filter registration, `callFunction()` whitelist enforcement, `doAction`/`applyFilters`/`callUserFunc` output capture, `setupProductData`, and all output-capture wrappers
- **CI/CD test job**: PHPUnit runs between lint and build-release in Gitea pipeline; test artifacts excluded from release packages
- Class stubs for `WPBootstrap\Twig\TwigService` and `WC_Product` enabling isolated unit testing without WordPress/WooCommerce
## [0.1.5] - 2026-03-01
### Fixed
- **Empty page title on catalog pages** (`header.html.twig`): Replaced `page_title` context variable (never passed by WC) with direct `fn('woocommerce_page_title', false)` call
- **Missing breadcrumbs on catalog pages** (`archive-product.php`): Added `woocommerce_breadcrumb()` call before shop loop header
- **Missing product categories on catalog pages** (`content-product-cat.html.twig`): Renamed template from hyphen (`content-product-cat`) to underscore (`content-product_cat`) to match WC's `wc_get_template()` filename convention
- **Product grid 4 columns instead of 3** (`functions.php`, `loop-start.html.twig`): Changed default columns from 4 to 3 for better card proportions with sidebar
- **Double chevron on sort dropdown** (`wc-bootstrap.css`): Removed conflicting `appearance: auto` rule; set `appearance: none` to let Bootstrap's `form-select` class handle the dropdown arrow exclusively
- **Variable product add-to-cart button stays disabled** (`variable.html.twig`, `variation-add-to-cart-button.html.twig`): Added missing `data-product_id` and `data-product_variations` attributes to form; replaced HTML `disabled` attribute on button with CSS classes `disabled wc-variation-selection-needed` (WC JS only toggles CSS classes, never removes the HTML attribute)
- **Variable product select white background in dark mode** (`wc-bootstrap.css`): Increased dark mode override specificity to `(0,5,1)` to beat WC's `.woocommerce div.product form.cart .variations select` at `(0,4,3)` which uses `background` shorthand; also overrides `background-image` for Bootstrap's dark-mode-aware chevron SVG
- **Product gallery missing main image in thumbnail strip** (`product-image.html.twig`): Prepend main image ID to gallery IDs using `[post_thumbnail_id]|merge(gallery_image_ids)` with active state on first thumbnail; added `{% if thumb_url %}` guard to skip invalid attachment IDs
- **Related/upsells products show same product repeated** (`related.html.twig`, `up-sells.html.twig`): Added `wc_setup_product_data()` call before each product render and `wp_reset_postdata()` after loop to set global `$product` correctly for WC hooks
- **Grouped product add-to-cart button/pricing broken** (`grouped.html.twig`): Rewrote template to compute `quantites_required` and `show_add_to_cart_button` in loop (matching WC PHP logic); moved hidden `add-to-cart` input outside conditional; added `has_options()` and `is_sold_individually()` checks
- **Downloads page empty** (`downloads.html.twig`): Replaced fragile `fn('WC').customer.get_downloadable_products()` chain with direct `fn('wc_get_customer_available_downloads', get_current_user_id())` call
### Added
- **Product gallery JS** (`product-gallery.js`): Vanilla JS click handler for thumbnail-to-main-image swap with active state highlighting and gallery fade-in
- **`wc_setup_product_data()` Twig function** (`WooCommerceExtension.php`): Sets `$GLOBALS['product']` and calls `setup_postdata()` for correct product context in Twig loops
- **`wp_reset_postdata` Twig function** (`WooCommerceExtension.php`): Restores global post state after product loops
- **`sanitize_title` Twig filter** (`WooCommerceExtension.php`): Matches WC PHP's lowercase attribute name handling for variation form data attributes
- **Product thumbnails suppressor** (`product-thumbnails.html.twig`): Empty template override to prevent WC's default full-size gallery images rendering below custom thumbnail row
### Changed
- **Whitelisted functions** (`WooCommerceExtension.php`): Added `woocommerce_page_title` and `wc_get_customer_available_downloads` to `ALLOWED_FUNCTIONS`
- **Removed obsolete files**: Deleted `PLAN.md` and `SETUP.md` (superseded by CLAUDE.md)
## [0.1.4] - 2026-03-01
### Security
- **fn() function whitelist** (`WooCommerceExtension`): The `callFunction()` method (exposed as `fn()` in Twig templates) now restricts callable functions to an explicit `ALLOWED_FUNCTIONS` whitelist. Previously any PHP function could be called, risking arbitrary code execution if template context were compromised. Only the 6 functions actually used in templates are permitted.
- **Notice data attribute escaping**: Changed `{{ notice.data|raw }}` to `{{ notice.data|wp_kses_post }}` in success, error, and notice Twig templates. Defense-in-depth against potential XSS via data attributes.
- **Search query escaping** (`product-searchform.html.twig`): Added `|esc_attr` filter to `get_search_query()` output in the search input value attribute.
### Performance
- **Per-request ContextBuilder caching**: New `wc_bootstrap_get_theme_context()` function with static variable caching eliminates redundant `ContextBuilder::build()` calls (10-20 DB queries each) when multiple WooCommerce render functions fire in the same request.
### Changed
- **Shared page shell helper**: New `wc_bootstrap_render_in_page_shell()` function extracts the duplicated context-injection-and-render pattern from `wc_bootstrap_render_page()`, `wc_bootstrap_render_product_archive()`, and `wc_bootstrap_render_single_product()`.
- **Removed unused constants**: Removed `WC_BOOTSTRAP_VERSION` and `WC_BOOTSTRAP_URL` constants that were defined but never referenced.
## [0.1.3] - 2026-02-28
### Added
- Theme screenshot showing dark mode product archive with Bootstrap 5 card grid
## [0.1.2] - 2026-02-28
### Fixed
- Dark mode: text inputs and textareas showing white background due to WooCommerce's `.woocommerce form .form-row .input-text` (specificity `0,3,1`) overriding theme's checkout form rules with `var(--wc-form-color-background, #fff)` fallback
- Dark mode: `table-light` class on `<thead>` forcing white table headers in cart, checkout review, orders, and payment methods pages
- Dark mode: WooCommerce notice focus ring appearing white when `focus_populate_live_region()` JS programmatically focuses alerts for screen reader accessibility
- WooCommerce notice overrides not matching alerts rendered by Twig templates (added `.alert.woocommerce-*` compound selectors alongside `.woocommerce .woocommerce-*` descendant selectors)
- Order details table on thank-you page not wrapped in a card like other sections
- Thank-you page success message line-wrapping after icon due to block-level `<p>` inside inline alert context
## [0.1.1] - 2026-02-28
### Fixed
- Dark mode: native `<select>` elements showing white background due to WooCommerce's `--wc-form-color-background` falling back to `#fff`
- Dark mode: SelectWoo/Select2 dropdowns (country/state pickers) rendering with hardcoded `#fff` backgrounds, text colors, and borders
- Dark mode: checkout form focus ring color for inputs, textareas, and selects
- WooCommerce notice borders not matching Bootstrap alert styles due to insufficient CSS specificity (bumped to `.woocommerce .woocommerce-*` at `0,2,0`)
- WooCommerce notice `border-top: 3px solid` and `background-color: #f6f5f8` overriding Bootstrap alert colors
- Double icons on WooCommerce notices (WooCommerce icon font `::before` conflicting with Bootstrap Icons)
- Product card images overlapping top rounded corners on catalog page (added `overflow-hidden` to card)
## [0.1.0] - 2026-02-28
### Added
- Docker development environment: multistage Dockerfile (WooCommerce download, wp-bootstrap npm build, Composer deps, WordPress runtime), Compose stack with MariaDB, auto-setup entrypoint
- Private registry image name and restart policies for Docker stack
- **My Account polish**: endpoint icon map, `offcanvas-lg` responsive navigation with sticky sidebar, card-based dashboard with avatar welcome greeting and quick-action grid, card-wrapped forms with icon headers, view-order summary card with status badge
- **Product archive**: Bootstrap 5 card grid with responsive columns, sale badges, star ratings, offcanvas sidebar for mobile filters, shop-sidebar widget area
- **Single product layout**: two-column responsive grid (image gallery + product summary), bridge for `wc_get_template_part()` interception, disabled WooCommerce block compatibility layer
- Global `$product` injection into Twig context for loop sub-templates
- CSS overrides: WooCommerce float/width layout reset, sale badge repositioning, shop_table border reset, gallery opacity fallback, My Account `max-width` reset
### Fixed
- Rename `base.html.twig``wc-base.html.twig` to prevent shadowing the parent theme's page shell via Twig `FilesystemLoader` path priority
- CSS dependency handle from unregistered `woocommerce` to `woocommerce-general` for `wc-bootstrap-overrides` stylesheet
- WooCommerce float-based two-column layout (`width: 48%` + `float`) fighting Bootstrap grid on single product pages
- Nested content wrapper doubling parent theme's `.container`
- Sale badge escaping image column and blocking breadcrumb clicks
- `|nl2br|esc_html` filter order in order details (was escaping `<br>` tags)
- HTML entity double-encoding (`&hellip;`) in up-sells, cross-sells, and related product headings
- Wrong `function() is defined` guards in cart totals, review order, checkout login, and terms templates
- Duplicate deprecated hook fires in dashboard template
- Missing `|raw` on brand description HTML filter chain
- Missing `role="alert"` on variation add-to-cart for accessibility
- Missing `|esc_attr` on notification type class attribute
- Missing `is defined` guard on `hidden` variable in global form-login
- Pagination URLs using `?page=` instead of `get_pagenum_link()` for archives
- Double-escaped `&ndash;` in result count
- Underscore.js triple-brace syntax conflict in variation template (wrapped in `{% verbatim %}`)
## [0.0.1] - 2026-02-28 ## [0.0.1] - 2026-02-28
### Added ### Added
@@ -27,6 +143,12 @@ All notable changes to this project will be documented in this file.
- Theme wrapping signal via `woocommerce_is_theme_wrapped` filter - Theme wrapping signal via `woocommerce_is_theme_wrapped` filter
- CI/CD release workflow (Gitea Actions) - CI/CD release workflow (Gitea Actions)
- Translation support (`.pot` template ready) - Translation support (`.pot` template ready)
- Docker development environment (multistage Dockerfile, Compose, auto-setup entrypoint)
### Fixed
- Rename `base.html.twig``wc-base.html.twig` to prevent shadowing the parent theme's page shell via Twig `FilesystemLoader` path priority
- Correct CSS dependency handle from `woocommerce` to `woocommerce-general` for `wc-bootstrap-overrides` stylesheet
### Skipped ### Skipped

230
CLAUDE.md
View File

@@ -215,6 +215,10 @@ Recurring bugs and non-obvious behaviours discovered across sessions. **Read thi
- **Twig autoescape + WordPress escape filters = double encoding** -- register all `esc_*` filters with `['is_safe' => ['html']]` option in the plugin's `Template.php`. - **Twig autoescape + WordPress escape filters = double encoding** -- register all `esc_*` filters with `['is_safe' => ['html']]` option in the plugin's `Template.php`.
- **`wp i18n make-pot` does NOT scan Twig templates** -- any string used exclusively in `.html.twig` files must be manually added to the `.pot` file. - **`wp i18n make-pot` does NOT scan Twig templates** -- any string used exclusively in `.html.twig` files must be manually added to the `.pot` file.
- **`#, fuzzy` silently skips translations at runtime** -- always remove fuzzy flags after verifying translations. - **`#, fuzzy` silently skips translations at runtime** -- always remove fuzzy flags after verifying translations.
- **`|nl2br|esc_html` is wrong filter order** -- `nl2br` outputs `<br>` tags, then `esc_html` escapes them to `&lt;br&gt;`. Correct: `|esc_html|nl2br`.
- **`function() is defined` is semantically wrong** -- always evaluates truthy since the function is called regardless. Use `{% if function() %}` directly.
- **HTML entities in translated strings get double-encoded** -- `&hellip;` in `__()` becomes `&amp;hellip;`. Use Unicode `…` directly or append `|raw` for trusted filter output.
- **Filter chains producing HTML need `|raw`** -- e.g., `term_description()|wptexturize|wpautop|do_shortcode|raw`.
### Bootstrap 5 vs Plugin CSS Conflicts ### Bootstrap 5 vs Plugin CSS Conflicts
@@ -222,6 +226,34 @@ Recurring bugs and non-obvious behaviours discovered across sessions. **Read thi
- **CSS dependency chain**: `woocommerce` -> child theme overrides. Ensures correct cascade. - **CSS dependency chain**: `woocommerce` -> child theme overrides. Ensures correct cascade.
- jQuery `.show()`/`.hide()` cannot override Bootstrap `!important` (`d-none`). Toggle both class and inline style. - jQuery `.show()`/`.hide()` cannot override Bootstrap `!important` (`d-none`). Toggle both class and inline style.
- `overflow: visible !important` on `.wp-block-navigation__container` is essential for dropdowns inside block theme navigation. - `overflow: visible !important` on `.wp-block-navigation__container` is essential for dropdowns inside block theme navigation.
- **WooCommerce `shop_table` borders conflict with Bootstrap `.table`** -- reset with `.woocommerce table.shop_table { border: 0 }` and cell `border-left/right: 0`.
- **WooCommerce gallery JS requires modifier classes** -- `--with-images`, `--without-images`, `--columns-N` on `.woocommerce-product-gallery` + `style="opacity: 0"` for fade-in. Without these, zoom and photoswipe won't initialize.
- **WooCommerce dark mode select backgrounds** -- `woocommerce.css` sets `select { background-color: var(--wc-form-color-background, #fff) }`. The custom property is never defined for dark mode, so it falls back to `#fff`. Override with `[data-bs-theme="dark"] .woocommerce select`.
- **Select2/SelectWoo dark mode** -- `select2.css` hardcodes `#fff` on selection containers and dropdowns. Needs overrides for `.select2-selection`, `.select2-dropdown`, `.select2-search__field`, and highlighted options.
- **WooCommerce notice CSS specificity** -- Uses `border-top: 3px solid`, `background-color: #f6f5f8`, and icon font `::before` at specificity `0,1,0`. Must use `.woocommerce .woocommerce-*` AND `.alert.woocommerce-*` (both specificity `0,2,0`) to cover notices both inside `.woocommerce` wrappers and rendered directly by Twig templates.
- **WooCommerce notice focus ring** -- `woocommerce.js:focus_populate_live_region()` adds `tabindex="-1"` and calls `.focus()` on the first `.woocommerce-message[role="alert"]` after 500ms for screen reader announcement. The default browser focus ring appears white in dark mode. Suppress with `outline: 0; box-shadow: none` since the focus is non-interactive.
- **WooCommerce form input specificity** -- `.woocommerce form .form-row .input-text` (specificity `0,3,1`) sets `background-color: var(--wc-form-color-background, #fff)`. This beats theme checkout rules at `0,2,1`. Needs `[data-bs-theme="dark"]` override at `0,4,0`.
- **Bootstrap `table-light` breaks dark mode** -- Forces a light background on `<thead>` regardless of `data-bs-theme`. Remove it and let Bootstrap's default table styling handle theming.
- **WooCommerce float layout fights Bootstrap grid** -- `div.product div.images/summary` have `float:left/right; width:48%` in `woocommerce-layout.css`. Override with `float: none; width: 100%`.
- **Bootstrap `g-*` gutters add negative top margin** -- `g-4` sets both `--bs-gutter-x` and `--bs-gutter-y`; the `.row` gets `margin-top: calc(-1 * var(--bs-gutter-y))` pulling it upward. Use `gx-*` for horizontal-only gutters when vertical gap isn't desired.
### WooCommerce Variation JS
- **NEVER use HTML `disabled` attribute on the add-to-cart button.** WC's `add-to-cart-variation.js` only manages CSS classes (`disabled`, `wc-variation-selection-needed`) via jQuery `.addClass()`/`.removeClass()`. It never removes the HTML `disabled` attribute. The HTML attribute prevents click events from firing, making the button permanently unclickable.
- Use CSS classes `disabled wc-variation-selection-needed` instead. WC's `onAddToCart` handler checks `$button.is('.disabled')` (CSS class), not the HTML property.
- WC variation form requires `data-product_id` and `data-product_variations` on `<form class="variations_form">`. Without these, the JS doesn't initialize.
- Attribute select `name` and `data-attribute_name` must use `sanitize_title()` on the attribute name (lowercase, hyphens) to match the keys in the variation JSON data.
### WooCommerce Variation Select CSS Specificity
- WC's `.woocommerce div.product form.cart .variations select` at specificity `(0,4,3)` uses `background` shorthand — this resets BOTH `background-color` and `background-image`. A dark mode override must exceed `(0,4,3)` AND override `background-image` with `var(--bs-form-select-bg-img)` for Bootstrap's dark-mode-aware chevron SVG.
- Previous selector `[data-bs-theme="dark"] .variations .form-select` at `(0,3,0)` lost the specificity battle.
### WooCommerce Gallery Data Integrity
- `_product_image_gallery` post meta may contain IDs pointing to product variations or other non-attachment posts instead of image attachments. `wp_get_attachment_url()` returns empty/false for these.
- Always guard thumbnail rendering with `{% if thumb_url %}` after calling `wp_get_attachment_url(image_id)`.
- The main product image should be prepended to the gallery strip so users can switch back to it after viewing gallery images.
### Double Heading Prevention ### Double Heading Prevention
@@ -268,7 +300,7 @@ wp-bootstrap (parent theme, Bootstrap 5 FSE + Twig rendering)
### Important Constraints ### Important Constraints
- **WooCommerce plugin is read-only.** We have no control over its source code. All customizations happen in the child theme via template overrides and hooks. - **WooCommerce plugin is read-only.** We have no control over its source code. All customizations happen in the child theme via template overrides and hooks.
- **Docker environment is not yet set up.** Commands referencing `docker exec` (e.g., in the translation workflow) are not currently available. Local alternatives or manual steps must be used until the container is configured. - **Docker environment:** Container name is `woocommerce`. Use `docker exec woocommerce ...` for commands and `docker exec woocommerce apache2ctl graceful` to clear OPcache after PHP changes.
### Cross-Project Workflow ### Cross-Project Workflow
@@ -299,7 +331,11 @@ The child theme inherits from `wp-bootstrap` via WordPress `Template: wp-bootstr
## Architecture Decisions ## Architecture Decisions
- **Edit forms** use 3+9 column layout: `col-lg-3` sticky sidebar (progress indicator, section nav) + `col-lg-9` card-based sections. - **My Account layout** uses `col-lg-auto` (nav) + `col-lg` (content) so the nav auto-sizes to its content and the main area fills remaining space. Requires CSS resets for WooCommerce's float-based layout (`float: none; width: auto/100%`) and `max-width: none` on `.woocommerce-account main .woocommerce` to override the plugin's `max-width: 1000px`.
- **My Account navigation** uses `offcanvas-lg offcanvas-start` responsive pattern: full sidebar with icons on desktop (sticky via `position-sticky`), offcanvas slide-in on mobile with toggle button. Icons are mapped per endpoint via a Twig hash.
- **My Account dashboard** uses card-based quick actions (`row row-cols-1 row-cols-md-2 row-cols-xl-3 g-3`) with `bg-primary-subtle` icon containers that adapt to dark mode. Each action card is an `<a class="card">` for full-card clickability.
- **My Account forms** (edit-account, edit-address, lost-password, reset-password) are wrapped in `card shadow-sm` sections with icon headers for visual consistency.
- **Edit forms** use card-based section grouping (e.g., "Personal information" + "Password change" as separate cards) instead of `<fieldset>/<legend>`.
- **Detail pages** use 8+4 column layout with sticky sidebar. - **Detail pages** use 8+4 column layout with sticky sidebar.
- **Cards** use `<article class="card h-100">` with `stretched-link`. - **Cards** use `<article class="card h-100">` with `stretched-link`.
- **Form layout** uses centered `col-lg-8 col-xl-7` with card + shadow for auth forms. - **Form layout** uses centered `col-lg-8 col-xl-7` with card + shadow for auth forms.
@@ -309,8 +345,194 @@ The child theme inherits from `wp-bootstrap` via WordPress `Template: wp-bootstr
## Version History ## Version History
Current version: **v0.0.1** Current version: **v0.1.6**
## Session History ## Session History
<!-- AI assistants: document key learnings and session outcomes here --> ### 2026-03-01 — v0.1.6 Add PHPUnit Test Suite
**Scope:** Added PHPUnit test infrastructure with Brain\Monkey for WordPress function mocking, covering both PHP classes (`TemplateOverride`, `WooCommerceExtension`). Added test job to Gitea CI pipeline.
**Files created (7):**
- `phpunit.xml.dist` — PHPUnit 11 configuration (bootstrap, test suite, source coverage)
- `tests/bootstrap.php` — Loads Composer autoloader and class stubs
- `tests/Stubs/TwigService.php``WPBootstrap\Twig\TwigService` stub with injectable render callback and singleton reset
- `tests/Stubs/WcProduct.php` — Minimal `\WC_Product` stub with `get_id()`
- `tests/Unit/TemplateOverrideTest.php` — 9 tests: hook registration, Twig resolution, buffer stack (including nesting), context passing, global `$product` injection, exception fallback
- `tests/Unit/Twig/WooCommerceExtensionTest.php` — 18 tests: function/filter registration, `callFunction()` whitelist enforcement, output capture wrappers, `setupProductData`
**Files modified (4):**
- `composer.json` — Added `require-dev` (phpunit ^11.0, brain/monkey ^2.6) and `autoload-dev` PSR-4 mapping
- `.gitea/workflows/release.yml` — Added `test` job between `lint` and `build-release`; excluded `tests/`, `phpunit.xml.dist`, `.phpunit.cache` from release packages
- `.gitignore` — Changed `.phpunit.cache/test-results` to `.phpunit.cache/`
- `style.css` — Version bump 0.1.5 → 0.1.6
**Key decisions:**
- **Brain\Monkey over WP_Mock:** Lighter weight, better patchwork integration, same pattern as wp-bootstrap parent theme
- **TwigService stub with injectable callback:** Allows tests to control render output without full Twig environment; includes `reset()` for test isolation
- **`@` error suppression for `error_log` in fallback test:** PHP internal functions can't be mocked by Brain\Monkey without patchwork.json config; suppressing is simpler than adding patchwork config for a single test
- **No patchwork.json:** Avoids complexity; only `error_log` needed mocking and the `@` operator suffices for the test assertion
**Test results:** 27 tests, 54 assertions, all passing
### 2026-03-01 — v0.1.5 Fix 10 Known Bugs
**Scope:** Fixed all 10 bugs from KNOWN_BUGS.md — catalog page features (title, breadcrumbs, categories, filters, sort dropdown), single product fixes (variable form, gallery, related products, grouped products), and downloads page.
**Files changed (17):**
- `inc/Twig/WooCommerceExtension.php` — Added `woocommerce_page_title` and `wc_get_customer_available_downloads` to `ALLOWED_FUNCTIONS`; added `wc_setup_product_data()` method, `wp_reset_postdata` Twig function, and `sanitize_title` Twig filter
- `templates/single-product/add-to-cart/variable.html.twig` — Added `data-product_id` and `data-product_variations` attributes to form; applied `sanitize_title` filter on attribute names for variation matching
- `templates/single-product/add-to-cart/variation-add-to-cart-button.html.twig` — Removed HTML `disabled` attribute, replaced with CSS classes `disabled wc-variation-selection-needed` (WC JS only manages CSS classes, never the HTML attribute)
- `templates/single-product/product-image.html.twig` — Prepend main image to gallery thumbnail strip; added `{% if thumb_url %}` guard for invalid attachment IDs
- `templates/single-product/product-thumbnails.html.twig` — New empty override to suppress WC's default full-size gallery images
- `templates/single-product/related.html.twig` — Added `wc_setup_product_data()` before each product render + `wp_reset_postdata()` after loop
- `templates/single-product/up-sells.html.twig` — Same setup_postdata fix as related.html.twig
- `templates/single-product/add-to-cart/grouped.html.twig` — Rewrote to compute `quantites_required`/`show_add_to_cart_button` in loop (matching WC PHP); moved hidden input outside conditional; added `has_options()` and `is_sold_individually()` checks
- `templates/loop/header.html.twig` — Replaced `page_title` context variable with direct `fn('woocommerce_page_title', false)` call
- `woocommerce/archive-product.php` — Added `woocommerce_breadcrumb()` call before shop loop header
- `functions.php` — Changed loop columns from 4 to 3; added product gallery JS conditional enqueue
- `templates/loop/loop-start.html.twig` — Changed default columns from 4 to 3
- `assets/css/wc-bootstrap.css` — Replaced duplicate ordering select rules with `appearance: none`; increased dark mode variation select specificity to `(0,5,1)` to beat WC's `(0,4,3)` background shorthand
- `templates/content-product-cat.html.twig` — Renamed to `content-product_cat.html.twig` (WC uses underscore)
- `assets/js/product-gallery.js` — New: thumbnail click-to-swap, gallery fade-in, active state highlighting
- `templates/myaccount/downloads.html.twig` — Replaced `fn('WC').customer.get_downloadable_products()` with direct `fn('wc_get_customer_available_downloads', get_current_user_id())`
- `style.css` — Version bump 0.1.4 → 0.1.5
**Key decisions:**
- **`wc_setup_product_data()` over PHP bridge:** Adding a Twig function that sets `$GLOBALS['product']` + `setup_postdata()` is simpler and more maintainable than creating PHP bridge files for related/upsells rendering
- **`json_encode|esc_attr` over `wc_esc_json`:** Avoids adding a custom filter; `esc_attr` performs the same HTML entity encoding as `wc_esc_json`
- **3 columns default:** With sidebar taking `col-lg-3`, 3 product columns in `col-lg-9` gives better card proportions than 4
- **Vanilla JS gallery:** Lightweight click-to-swap handler instead of WC's built-in flexslider/photoswipe (which requires specific PHP setup for `wp_get_attachment_image_src` data attributes)
- **CSS class `disabled` over HTML `disabled` attribute:** WC's `add-to-cart-variation.js` `onShow`/`onHide` only toggle CSS classes (`disabled`, `wc-variation-selection-needed`). The `onAddToCart` handler checks `.is('.disabled')`. Using the HTML `disabled` attribute prevents click events entirely and WC JS never removes it.
- **CSS specificity `(0,5,1)` for dark mode selects:** WC's `.woocommerce div.product form.cart .variations select` at `(0,4,3)` uses `background` shorthand which resets `background-color` and `background-image`. Must exceed this specificity AND override `background-image` for Bootstrap's dark-mode SVG chevron.
**Key findings (WC variation JS):**
- WC's `add-to-cart-variation.js` event flow: `onChange``check_variations``findMatchingVariations``found_variation``onFoundVariation` (300ms setTimeout) → `show_variation``onShow` (removes CSS class only)
- `onShow` calls `$button.removeClass('disabled wc-variation-selection-needed')` — never touches the HTML `disabled` attribute/property
- `onAddToCart` checks `$button.is('.disabled')` — CSS class, not HTML attribute
- Gallery `_product_image_gallery` meta may contain IDs of product variations or posts instead of image attachments — always guard with `{% if thumb_url %}` after `wp_get_attachment_url()`
### 2026-03-01 — v0.1.4 Security Audit & Performance Fixes
**Scope:** Cross-theme security audit (12 findings), all fixed. Covers fn() whitelist, notice data escaping, search query escaping, per-request context caching, shared render helper, and unused constant removal.
**Files changed (6):**
- `inc/Twig/WooCommerceExtension.php` — Added `ALLOWED_FUNCTIONS` whitelist to `callFunction()`. Only 6 functions (`WC`, `_n`, `get_pagenum_link`, `wc_review_ratings_enabled`, `wc_get_product_category_list`, `wc_get_product_tag_list`) are permitted.
- `templates/notices/success.html.twig``notice.data|raw``notice.data|wp_kses_post`
- `templates/notices/error.html.twig``notice.data|raw``notice.data|wp_kses_post`
- `templates/notices/notice.html.twig``notice.data|raw``notice.data|wp_kses_post`
- `templates/product-searchform.html.twig` — Added `|esc_attr` on `get_search_query()` value
- `functions.php` — Removed unused `WC_BOOTSTRAP_VERSION`/`WC_BOOTSTRAP_URL` constants. Added `wc_bootstrap_get_theme_context()` with static caching and `wc_bootstrap_render_in_page_shell()` helper. Refactored 3 render functions to use shared helpers.
**Key decisions:**
- **fn() whitelist defense-in-depth**: Template files are static PHP, not user-editable, so exploitation requires file write access. Whitelist added anyway as defense-in-depth to prevent `exec()`, `system()`, etc. if template context were ever compromised.
- **`|wp_kses_post` over `|raw` for data attributes**: WooCommerce sanitizes notice data, but belt-and-suspenders approach prevents XSS if upstream behavior changes.
- **Static variable caching over transients**: Per-request `static $cached_context` is sufficient since WooCommerce pages build context once. No transient overhead or invalidation needed.
### 2026-02-28 — My Account Bootstrap 5 Polish
**Scope:** Redesigned 8 my-account Twig templates + CSS overrides to feel like a polished Bootstrap 5 application.
**Files changed (10):**
- `templates/myaccount/navigation.html.twig` — Added endpoint icon map, `offcanvas-lg` responsive pattern, sticky sidebar
- `templates/myaccount/dashboard.html.twig` — Replaced plain `<p>` tags with welcome card (avatar + greeting) and 5 quick-action cards in responsive grid
- `templates/myaccount/view-order.html.twig` — Replaced `<mark>` tags with summary card using `list-group-flush` and `components/status-badge.html.twig`; notes wrapped in card
- `templates/myaccount/form-edit-account.html.twig` — Wrapped in two card sections (Personal info + Password change) with icons
- `templates/myaccount/form-edit-address.html.twig` — Wrapped in card with dynamic icon (`bi-receipt` billing / `bi-truck` shipping)
- `templates/myaccount/form-lost-password.html.twig` — Wrapped in card with `bi-key` icon
- `templates/myaccount/form-reset-password.html.twig` — Wrapped in card with `bi-shield-lock` icon
- `templates/myaccount/lost-password-confirmation.html.twig` — Added `text-body-secondary` styling and "Back to login" button
- `templates/myaccount/my-account.html.twig` — Changed grid from `col-lg-3`/`col-lg-9` to `col-lg-auto`/`col-lg`
- `assets/css/wc-bootstrap.css` — Reset WooCommerce float layout, override `max-width: 1000px`, avatar rounding, card hover lift
**Design decisions:**
- **Nav column auto-sizing (`col-lg-auto`)**: Fixed-width columns (e.g., `col-lg-3` = 285px) caused label overflow with icons. Auto-sizing lets the nav take exactly the space it needs across locales while the content fills the rest.
- **WooCommerce layout overrides require matching specificity**: The plugin uses `.woocommerce-account .woocommerce-MyAccount-navigation` (specificity `0,2,0`) — single-class selectors don't override. Also `.woocommerce-account main .woocommerce` sets `max-width: 1000px` (specificity `0,2,1`) which must be reset to `none`.
- **`offcanvas-lg` over `d-none d-lg-block`**: Bootstrap's responsive offcanvas natively handles the desktop/mobile switch without duplicating nav markup. The toggle button uses `d-lg-none` visibility.
- **`bg-primary-subtle` for icon containers**: These Bootstrap 5.3 contextual utilities automatically adapt to dark mode, unlike hardcoded colors.
- **Welcome message restructured**: Separated greeting from logout link instead of using WooCommerce's default inline-linked `__()` string. This gives full control over card layout and avoids translated strings containing HTML structure assumptions.
- **Templates NOT changed** (already well-done): `orders.html.twig`, `my-address.html.twig`, `form-login.html.twig`, `payment-methods.html.twig`, `form-add-payment-method.html.twig`, `downloads.html.twig`
### 2026-02-28 — Single Product Bootstrap 5 Layout + Template Quirks Audit
**Scope:** Created Bootstrap 5 two-column layout for single product pages. Then audited all ~90 Twig templates for WooCommerce CSS quirks, Twig escaping bugs, and missing Bootstrap styling.
**Single product layout (3 files):**
- `woocommerce/content-single-product.php` — Bridge file for `wc_get_template_part()` interception
- `templates/content-single-product.html.twig` — Two-column `row gx-4 gx-lg-5` grid (images left, summary right)
- `assets/css/wc-bootstrap.css` — Float/width reset, sale badge positioning, shop_table border reset, gallery opacity fallback
**Template quirks audit (14 files fixed):**
- `order/order-details.html.twig` — Fixed `|nl2br|esc_html` filter order (was escaping `<br>` tags)
- `single-product/product-image.html.twig` — Added WC gallery modifier classes + opacity:0 for JS init
- `brands/brand-description.html.twig` — Added `|raw` to HTML-producing filter chain
- `single-product/up-sells.html.twig`, `cart/cross-sells.html.twig`, `single-product/related.html.twig` — Fixed `&hellip;` double-encoding in headings
- `myaccount/dashboard.html.twig` — Removed duplicate deprecated hook fires
- `product-searchform.html.twig` — Replaced `&hellip;` entity with Unicode `…`
- `cart/cart-totals.html.twig`, `checkout/review-order.html.twig`, `checkout/form-login.html.twig`, `checkout/terms.html.twig` — Removed wrong `function() is defined` guards
- `wc-base.html.twig` — Added `|esc_attr` on notification type in class attribute
- `global/form-login.html.twig` — Added `is defined` guard on `hidden` variable
- `single-product/add-to-cart/variation.html.twig` — Added `role="alert"` for accessibility
- `cart/mini-cart.html.twig` — Changed remove link to Bootstrap `btn btn-sm btn-outline-danger`
- `cart/cart-shipping.html.twig` — Made `form-check` class conditional on multiple shipping methods
**Infrastructure:**
- Created `.claude/settings.json` with allowed commands (git, docker, composer, npm, etc.)
### 2026-02-28 — v0.1.0 Changelog & Re-Release
**Scope:** Added CHANGELOG.md entry for v0.1.0 and re-released the tag.
**Files changed (1):**
- `CHANGELOG.md` — Added `[0.1.0]` section documenting all features and fixes between v0.0.1 and v0.1.0
### 2026-02-28 — v0.1.1 Dark Mode & Notice CSS Bugfixes
**Scope:** Fixed dark mode select backgrounds, WooCommerce notice styling conflicts, and product card image overflow.
**Files changed (2):**
- `assets/css/wc-bootstrap.css` — Dark mode overrides for native `<select>` and Select2/SelectWoo widgets; bumped notice selectors to `.woocommerce .woocommerce-*` (specificity `0,2,0`) to beat `woocommerce.css`; suppressed WooCommerce icon font `::before` on notices; added checkout form focus color for dark mode
- `templates/content-product.html.twig` — Added `overflow-hidden` to product card `<article>` for border-radius clipping
**Key learnings:**
- WooCommerce CSS sets `select { background-color: var(--wc-form-color-background, #fff) }` — the custom property is never defined for dark mode, so it falls back to white. Override with `[data-bs-theme="dark"] .woocommerce select`.
- Select2/SelectWoo hardcodes `#fff` backgrounds in `select2.css` — needs comprehensive overrides for selection containers, dropdowns, search fields, and highlighted options.
- WooCommerce notice CSS uses `border-top: 3px solid`, `background-color: #f6f5f8`, and WooCommerce icon font `::before` at specificity `0,1,0`. Single-class overrides don't win — must use `.woocommerce .woocommerce-*` (specificity `0,2,0`) and explicitly reset `border-top`.
- Never set `background-image` without `background-repeat: no-repeat` / `background-size` / `background-position` — SVGs will tile.
### 2026-02-28 — v0.1.2 Dark Mode Deep Fixes (Tables, Inputs, Notices)
**Scope:** Fixed dark mode rendering issues across checkout, thank-you, cart, and account pages — white table headers, white form inputs, notice focus rings, and missing card wrappers.
**Files changed (7):**
- `assets/css/wc-bootstrap.css` — Added `[data-bs-theme="dark"]` override for `.input-text` and `textarea` (WooCommerce specificity `0,3,1` beats theme at `0,2,1`); added `.alert.woocommerce-*` compound selectors for notice overrides outside `.woocommerce` wrapper; added focus ring suppression for programmatically focused notices
- `templates/order/order-details.html.twig` — Wrapped product table in `card shadow-sm` with `card-header`; removed `table-light` from `<thead>`
- `templates/checkout/thankyou.html.twig` — Added `d-flex align-items-center` to success alerts to fix icon/text line wrap
- `templates/checkout/review-order.html.twig` — Removed `table-light` from `<thead>`
- `templates/cart/cart.html.twig` — Removed `table-light` from `<thead>`
- `templates/myaccount/orders.html.twig` — Removed `table-light` from `<thead>`
- `templates/myaccount/payment-methods.html.twig` — Removed `table-light` from `<thead>`
**Key findings:**
- WooCommerce's `.woocommerce form .form-row .input-text` at specificity `0,3,1` sets `background-color: var(--wc-form-color-background, #fff)` which beats theme rules. The `textarea` generated by `woocommerce_form_field()` has class `input-text`, so it matches this rule. Needs `[data-bs-theme="dark"]` override at `0,4,0`.
- WooCommerce's `focus_populate_live_region()` in `woocommerce.js` adds `tabindex="-1"` and calls `.focus()` on `.woocommerce-message[role="alert"]` after 500ms for screen reader accessibility. The default browser focus ring appears white in dark mode.
- Notice overrides using `.woocommerce .woocommerce-*` descendant selectors don't match notices rendered by Twig templates where `.alert` and `.woocommerce-message` are on the same element without a `.woocommerce` wrapper ancestor. Both `.woocommerce .woocommerce-*` and `.alert.woocommerce-*` patterns needed.
- Bootstrap's `table-light` class forces a light background on `<thead>` regardless of dark mode. Remove it entirely — let the card-header or default table styling handle visual separation.

652
PLAN.md
View File

@@ -1,652 +0,0 @@
# WooCommerce to Bootstrap 5 -- Template Conversion Plan
This document outlines the full plan for converting WooCommerce's default templates into Bootstrap 5 themed Twig overrides within the `wc-bootstrap` child theme.
## Scope
**Source:** 233 WooCommerce PHP/HTML templates in `wp-content/plugins/woocommerce/templates/`
**Target:** Twig template overrides in `wp-content/themes/wc-bootstrap/templates/`
**Design system:** Bootstrap 5.3 (loaded via parent theme `wp-bootstrap`)
The plugin is **read-only** -- we never modify WooCommerce source. All customisation happens through Twig template overrides via `TemplateOverride::prependPath()` and WordPress hooks in `functions.php`.
---
## Existing Scaffold (v0.1.0)
Already in place:
| File | Purpose |
| ---- | ------- |
| `templates/base.html.twig` | Conditional wrapper (`_theme_wrapped` detection) |
| `templates/layouts/account.html.twig` | My Account layout shell |
| `templates/layouts/archive.html.twig` | Product archive / search layout |
| `templates/layouts/form.html.twig` | Centered form layout (card + shadow) |
| `templates/layouts/page.html.twig` | Generic content page |
| `templates/layouts/single.html.twig` | Detail page (8+4 grid) |
| `templates/components/card.html.twig` | Reusable product/post card |
| `templates/components/pagination.html.twig` | Bootstrap pagination nav |
| `inc/TemplateOverride.php` | Twig loader hook |
| `functions.php` | Style chain, render filter, wrapping signal |
| `assets/css/wc-bootstrap.css` | Button/alert/dark-mode overrides |
---
## Conversion Phases
### Phase 1 -- Global & Notices (Foundation)
Templates that appear on every WooCommerce page. Must be done first because all other templates depend on them.
#### 1.1 Global Templates
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `global/wrapper-start.php` | `global/wrapper-start.html.twig` | `<div class="container my-4">` (or skip if `_theme_wrapped`) |
| `global/wrapper-end.php` | `global/wrapper-end.html.twig` | Closing `</div>` |
| `global/breadcrumb.php` | `global/breadcrumb.html.twig` | `nav[aria-label] > ol.breadcrumb > li.breadcrumb-item` |
| `global/sidebar.php` | `global/sidebar.html.twig` | `aside.col-lg-3` with `offcanvas-lg` for mobile |
| `global/quantity-input.php` | `global/quantity-input.html.twig` | `input-group` with `btn-outline-secondary` +/- buttons |
| `global/form-login.php` | `global/form-login.html.twig` | Extend `layouts/form.html.twig`, `form-control`, `form-label` |
#### 1.2 Notice Templates
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `notices/notice.php` | `notices/notice.html.twig` | `alert alert-info alert-dismissible fade show` |
| `notices/error.php` | `notices/error.html.twig` | `alert alert-danger alert-dismissible fade show` |
| `notices/success.php` | `notices/success.html.twig` | `alert alert-success alert-dismissible fade show` |
**Notes:**
- Notices must support multiple messages (WooCommerce passes arrays)
- Include `btn-close` with `data-bs-dismiss="alert"`
- Respect dark mode via Bootstrap's adaptive colour utilities
---
### Phase 2 -- Product Archive & Shop Loop (Storefront)
The shop page, category pages, and product grid. This is the main entry point for customers.
#### 2.1 Archive Container
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `archive-product.php` | `archive-product.html.twig` | Extend `layouts/archive.html.twig`; 3+9 grid (filter sidebar + products) |
| `content-product.php` | `content-product.html.twig` | Extend `components/card.html.twig`; product card in grid |
| `content-product-cat.php` | `content-product-cat.html.twig` | Category card with thumbnail |
| `product-searchform.php` | `product-searchform.html.twig` | `input-group` with `form-control` + `btn btn-outline-primary` |
#### 2.2 Loop Components
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `loop/loop-start.php` | `loop/loop-start.html.twig` | `<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">` |
| `loop/loop-end.php` | `loop/loop-end.html.twig` | `</div>` |
| `loop/header.php` | `loop/header.html.twig` | Flex row: result-count left, orderby right |
| `loop/result-count.php` | `loop/result-count.html.twig` | `<p class="text-body-secondary mb-0">` |
| `loop/orderby.php` | `loop/orderby.html.twig` | `form-select form-select-sm` in flex container |
| `loop/pagination.php` | `loop/pagination.html.twig` | Delegate to `components/pagination.html.twig` |
| `loop/no-products-found.php` | `loop/no-products-found.html.twig` | `alert alert-info` with icon |
| `loop/add-to-cart.php` | `loop/add-to-cart.html.twig` | `btn btn-primary btn-sm w-100` |
| `loop/price.php` | `loop/price.html.twig` | `<span class="fs-5 fw-semibold">` (sale: `text-decoration-line-through text-body-secondary` + `text-danger`) |
| `loop/rating.php` | `loop/rating.html.twig` | Bootstrap Icons stars (`bi-star-fill`, `bi-star`) |
| `loop/sale-flash.php` | `loop/sale-flash.html.twig` | `badge bg-danger position-absolute top-0 start-0 m-2` |
---
### Phase 3 -- Single Product Page
The product detail page, including gallery, variations, tabs, and related products.
#### 3.1 Product Layout
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `single-product/product-image.php` | `single-product/product-image.html.twig` | Main image + thumbnail row; `img-fluid rounded`; lightbox via PhotoSwipe or Bootstrap modal |
| `single-product/title.php` | `single-product/title.html.twig` | `<h1 class="mb-2">` |
| `single-product/price.php` | `single-product/price.html.twig` | `fs-3 fw-bold`; sale styling as in loop |
| `single-product/short-description.php` | `single-product/short-description.html.twig` | `<div class="lead text-body-secondary mb-3">` |
| `single-product/meta.php` | `single-product/meta.html.twig` | `<dl class="row mb-3">` with `col-sm-3`/`col-sm-9` pairs |
| `single-product/rating.php` | `single-product/rating.html.twig` | Stars + link to reviews tab |
| `single-product/stock.php` | `single-product/stock.html.twig` | `badge bg-success` (in stock) / `badge bg-danger` (out of stock) |
| `single-product/sale-flash.php` | `single-product/sale-flash.html.twig` | `badge bg-danger fs-6` |
| `single-product/share.php` | `single-product/share.html.twig` | `btn-group` with Bootstrap Icon buttons |
| `single-product/product-attributes.php` | `single-product/product-attributes.html.twig` | `table table-sm table-striped` |
| `single-product/related.php` | `single-product/related.html.twig` | Section heading + `row row-cols-2 row-cols-lg-4 g-4` of cards |
| `single-product/up-sells.php` | `single-product/up-sells.html.twig` | Same grid as related |
#### 3.2 Add to Cart Forms
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `single-product/add-to-cart/simple.php` | `single-product/add-to-cart/simple.html.twig` | `input-group` (quantity) + `btn btn-primary btn-lg` |
| `single-product/add-to-cart/variable.php` | `single-product/add-to-cart/variable.html.twig` | `form-select` per attribute + quantity + button |
| `single-product/add-to-cart/grouped.php` | `single-product/add-to-cart/grouped.html.twig` | `table table-borderless` with quantity inputs per item |
| `single-product/add-to-cart/external.php` | `single-product/add-to-cart/external.html.twig` | `btn btn-outline-primary btn-lg` with external link icon |
| `single-product/add-to-cart/variation.php` | `single-product/add-to-cart/variation.html.twig` | Variation data container (hidden, JS-driven) |
| `single-product/add-to-cart/variation-add-to-cart-button.php` | `single-product/add-to-cart/variation-add-to-cart-button.html.twig` | Same as simple but variation-aware |
#### 3.3 Product Tabs
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `single-product/tabs/tabs.php` | `single-product/tabs/tabs.html.twig` | `nav nav-tabs` + `tab-content` with `tab-pane fade` |
| `single-product/tabs/description.php` | `single-product/tabs/description.html.twig` | `tab-pane` with prose content |
| `single-product/tabs/additional-information.php` | `single-product/tabs/additional-information.html.twig` | `tab-pane` with attributes table |
---
### Phase 4 -- Cart
The cart page with item management, shipping calculator, and totals.
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `cart/cart.php` | `cart/cart.html.twig` | `row`: `col-lg-8` (items table) + `col-lg-4` (totals sidebar) |
| `cart/cart-empty.php` | `cart/cart-empty.html.twig` | Centred `alert alert-info` + `btn btn-primary` to shop |
| `cart/cart-item-data.php` | `cart/cart-item-data.html.twig` | `<dl class="small text-body-secondary mb-0">` |
| `cart/cart-totals.php` | `cart/cart-totals.html.twig` | `card` with `list-group list-group-flush` rows |
| `cart/cart-shipping.php` | `cart/cart-shipping.html.twig` | `form-check` radio buttons per shipping method |
| `cart/cross-sells.php` | `cart/cross-sells.html.twig` | Section below cart; `row row-cols-2 row-cols-md-4 g-3` of small cards |
| `cart/mini-cart.php` | `cart/mini-cart.html.twig` | `offcanvas offcanvas-end` slide-in panel |
| `cart/proceed-to-checkout-button.php` | `cart/proceed-to-checkout-button.html.twig` | `btn btn-primary btn-lg w-100` |
| `cart/shipping-calculator.php` | `cart/shipping-calculator.html.twig` | Collapsible form (`collapse`) with `form-select` for country/state |
**Cart Table Structure:**
```html
<div class="table-responsive">
<table class="table align-middle">
<thead class="table-light">
<tr>
<th>Product</th>
<th>Price</th>
<th>Quantity</th>
<th>Subtotal</th>
<th></th><!-- Remove -->
</tr>
</thead>
<tbody><!-- cart items --></tbody>
</table>
</div>
```
---
### Phase 5 -- Checkout
The checkout flow including billing/shipping forms, order review, and payment.
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `checkout/form-checkout.php` | `checkout/form-checkout.html.twig` | `row`: `col-lg-7` (forms) + `col-lg-5` (order review, sticky) |
| `checkout/form-billing.php` | `checkout/form-billing.html.twig` | `card mb-4` with `form-control`, `form-label`, `form-select` fields |
| `checkout/form-shipping.php` | `checkout/form-shipping.html.twig` | Same card style; `form-check` for "ship to different address" toggle |
| `checkout/form-coupon.php` | `checkout/form-coupon.html.twig` | `input-group` inline: `form-control` + `btn btn-outline-secondary` |
| `checkout/form-login.php` | `checkout/form-login.html.twig` | Collapsible (`collapse`) login form above checkout |
| `checkout/review-order.php` | `checkout/review-order.html.twig` | `card` with `table table-sm` for items + `list-group` for totals |
| `checkout/payment.php` | `checkout/payment.html.twig` | `list-group` of `form-check` radio items per gateway; `btn btn-primary btn-lg w-100` place order |
| `checkout/payment-method.php` | `checkout/payment-method.html.twig` | `list-group-item` with radio + label + description collapse |
| `checkout/terms.php` | `checkout/terms.html.twig` | `form-check` checkbox with link to terms page |
| `checkout/thankyou.php` | `checkout/thankyou.html.twig` | `alert alert-success` + order details card |
| `checkout/order-received.php` | `checkout/order-received.html.twig` | Confirmation message with order summary |
| `checkout/cart-errors.php` | `checkout/cart-errors.html.twig` | `alert alert-danger` list |
---
### Phase 6 -- My Account Dashboard
The customer account area with orders, addresses, and account management.
#### 6.1 Account Shell
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `myaccount/my-account.php` | `myaccount/my-account.html.twig` | Extend `layouts/account.html.twig`; 3+9 grid (nav + content) |
| `myaccount/navigation.php` | `myaccount/navigation.html.twig` | `list-group` in sticky sidebar (desktop) / `nav nav-pills` (mobile) |
| `myaccount/dashboard.php` | `myaccount/dashboard.html.twig` | Welcome card + quick links as `row row-cols-1 row-cols-md-3 g-3` |
#### 6.2 Orders
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `myaccount/orders.php` | `myaccount/orders.html.twig` | `table-responsive` > `table table-hover` with status badges |
| `myaccount/view-order.php` | `myaccount/view-order.html.twig` | Order details card + address cards row |
#### 6.3 Addresses & Account
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `myaccount/my-address.php` | `myaccount/my-address.html.twig` | `row row-cols-1 row-cols-md-2 g-4`; billing + shipping cards |
| `myaccount/form-edit-address.php` | `myaccount/form-edit-address.html.twig` | `card` form with `form-control`, `form-select` |
| `myaccount/form-edit-account.php` | `myaccount/form-edit-account.html.twig` | `card` form for name, email, password change |
| `myaccount/downloads.php` | `myaccount/downloads.html.twig` | `table table-striped` with download links |
| `myaccount/payment-methods.php` | `myaccount/payment-methods.html.twig` | `list-group` of saved methods with delete button |
| `myaccount/form-add-payment-method.php` | `myaccount/form-add-payment-method.html.twig` | `card` form |
#### 6.4 Authentication
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `myaccount/form-login.php` | `myaccount/form-login.html.twig` | `row row-cols-1 row-cols-md-2 g-4`: login card + register card side-by-side |
| `myaccount/form-lost-password.php` | `myaccount/form-lost-password.html.twig` | Extend `layouts/form.html.twig`; centered card |
| `myaccount/form-reset-password.php` | `myaccount/form-reset-password.html.twig` | Extend `layouts/form.html.twig`; centered card |
| `myaccount/lost-password-confirmation.php` | `myaccount/lost-password-confirmation.html.twig` | `alert alert-success` |
---
### Phase 7 -- Order Details
Order display used on thank-you page, view-order, and emails.
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `order/order-details.php` | `order/order-details.html.twig` | `card` > `table-responsive` > `table` |
| `order/order-details-item.php` | `order/order-details-item.html.twig` | `<tr>` with product image thumbnail, name, quantity, price |
| `order/order-details-customer.php` | `order/order-details-customer.html.twig` | `row row-cols-1 row-cols-md-2`: billing + shipping address cards |
| `order/form-tracking.php` | `order/form-tracking.html.twig` | Extend `layouts/form.html.twig`; order ID + email fields |
| `order/order-again.php` | `order/order-again.html.twig` | `btn btn-outline-primary` |
---
### Phase 8 -- Email Templates
Email templates require special treatment: no external CSS, no Bootstrap JS, inline styles only.
**Strategy:** Use Bootstrap's colour palette as CSS custom properties mapped to inline styles. Prefix all classes with `wc-email-` to avoid email client conflicts (per architecture decision in CLAUDE.md).
#### 8.1 Email Shell
| WooCommerce Template | Theme Override | Purpose |
| -------------------- | -------------- | ------- |
| `emails/email-header.php` | `emails/email-header.html.twig` | Table-based header with logo, store name |
| `emails/email-footer.php` | `emails/email-footer.html.twig` | Table-based footer with store info |
| `emails/email-styles.php` | `emails/email-styles.html.twig` | Inline CSS aligned to Bootstrap colour palette |
#### 8.2 Email Content Components
| WooCommerce Template | Theme Override | Purpose |
| -------------------- | -------------- | ------- |
| `emails/email-order-details.php` | `emails/email-order-details.html.twig` | Order items table |
| `emails/email-order-items.php` | `emails/email-order-items.html.twig` | Single order item row |
| `emails/email-customer-details.php` | `emails/email-customer-details.html.twig` | Customer info section |
| `emails/email-addresses.php` | `emails/email-addresses.html.twig` | Billing + shipping addresses |
| `emails/email-downloads.php` | `emails/email-downloads.html.twig` | Downloads table |
#### 8.3 Transactional Emails (Priority Order)
High-traffic emails first:
1. `emails/customer-processing-order.php` -- Order confirmation
2. `emails/customer-completed-order.php` -- Order shipped/complete
3. `emails/customer-on-hold-order.php` -- Awaiting payment
4. `emails/customer-new-account.php` -- Welcome email
5. `emails/customer-reset-password.php` -- Password reset
6. `emails/customer-invoice.php` -- Invoice/receipt
7. `emails/customer-note.php` -- Order note
8. `emails/customer-refunded-order.php` -- Refund notification
9. `emails/customer-cancelled-order.php` -- Cancellation
10. `emails/customer-failed-order.php` -- Payment failure
Admin emails:
11. `emails/admin-new-order.php` -- New order notification
12. `emails/admin-cancelled-order.php` -- Cancellation alert
13. `emails/admin-failed-order.php` -- Payment failure alert
#### 8.4 Plain Text Emails
The `emails/plain/` directory contains plain-text variants. These need minimal conversion -- mainly ensuring consistent formatting and branding text. Lower priority; can be done as a batch after HTML emails.
---
### Phase 9 -- Supplementary Templates
Lower-traffic pages and edge cases.
#### 9.1 Brands
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `brands/brand-description.php` | `brands/brand-description.html.twig` | `<div class="mb-4">` with prose |
| `brands/taxonomy-product_brand.php` | `brands/taxonomy-product_brand.html.twig` | Extend `layouts/archive.html.twig` |
| `brands/shortcodes/brands-a-z.php` | `brands/shortcodes/brands-a-z.html.twig` | Letter nav + `row` grid of brand cards |
| `brands/shortcodes/single-brand.php` | `brands/shortcodes/single-brand.html.twig` | Brand card |
#### 9.2 Auth (OAuth)
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `auth/form-login.php` | `auth/form-login.html.twig` | Extend `layouts/form.html.twig` |
| `auth/form-grant-access.php` | `auth/form-grant-access.html.twig` | `card` with scope list + approve/deny buttons |
#### 9.3 Back-in-Stock
| WooCommerce Template | Theme Override | Bootstrap 5 Mapping |
| -------------------- | -------------- | -------------------- |
| `single-product/back-in-stock-form.php` | `single-product/back-in-stock-form.html.twig` | `card card-body` with email `input-group` |
---
## CSS Strategy
### File: `assets/css/wc-bootstrap.css`
Additions needed per phase:
| Phase | CSS Additions |
| ----- | ------------- |
| 1 | Quantity input-group sizing; notice icon spacing |
| 2 | Product card hover lift effect; sale badge positioning; star rating colours |
| 3 | Product gallery thumbnail grid; tab content transitions; variation swatches |
| 4 | Cart item thumbnail sizing; mini-cart offcanvas width |
| 5 | Checkout sticky sidebar offset; payment method description animation |
| 6 | Account navigation active state; order status badge colours |
| 8 | Email inline styles (separate `wc-email-*` classes, no Bootstrap dependency) |
### WooCommerce CSS Overrides
WooCommerce ships its own CSS that conflicts with Bootstrap. Key overrides:
```css
/* Reset WooCommerce grid in favour of Bootstrap */
.woocommerce ul.products { display: contents; }
/* Ensure Bootstrap form styles take precedence */
.woocommerce .form-row input.input-text { /* inherit from .form-control */ }
/* Price styling consistency */
.woocommerce .price del { @extend .text-decoration-line-through, .text-body-secondary; }
.woocommerce .price ins { text-decoration: none; @extend .text-danger, .fw-bold; }
```
### Dark Mode
All templates use Bootstrap's adaptive utilities (`bg-body-tertiary`, `text-body-secondary`, `border-*`) which automatically adapt via `data-bs-theme="dark"`. No manual dark-mode CSS should be needed except for:
- Custom sale badge colours
- Star rating fill colour
- Email templates (separate palette)
---
## JavaScript Strategy
Minimal custom JS. Bootstrap 5 JS (from parent theme) handles:
- Offcanvas (mini-cart, mobile sidebar)
- Collapse (shipping calculator, coupon form, login form)
- Tabs (product tabs)
- Modal (image lightbox, if replacing PhotoSwipe)
- Tooltips/popovers (optional)
- Alert dismiss (notices)
Custom JS needed for:
| Feature | Location | Purpose |
| ------- | -------- | ------- |
| Quantity +/- buttons | `assets/js/quantity.js` | Increment/decrement input value, trigger `change` event for WC JS |
| Variation swatches | `assets/js/variations.js` | Sync Bootstrap form-select with WC variation JS (if needed) |
| Mini-cart AJAX | `assets/js/mini-cart.js` | Update offcanvas cart count after add-to-cart |
| Sticky cart totals | Inline or CSS-only | `position: sticky; top: 1rem` on checkout/cart sidebar |
---
## Template Naming Convention
All theme overrides mirror the plugin's directory structure exactly:
```txt
woocommerce/templates/cart/cart.php
--> wc-bootstrap/templates/cart/cart.html.twig
woocommerce/templates/single-product/tabs/tabs.php
--> wc-bootstrap/templates/single-product/tabs/tabs.html.twig
```
---
## Component Reuse Map
Common Bootstrap patterns extracted into reusable Twig components:
| Component | File | Used By |
| --------- | ---- | ------- |
| Product card | `components/card.html.twig` | Archive, related, cross-sells, up-sells |
| Pagination | `components/pagination.html.twig` | Archive, orders, downloads |
| Price display | `components/price.html.twig` | Loop, single product, cart, order details |
| Star rating | `components/rating.html.twig` | Loop, single product |
| Address card | `components/address-card.html.twig` | My Account addresses, order details, checkout |
| Status badge | `components/status-badge.html.twig` | Orders list, view order |
| Quantity input | `components/quantity-input.html.twig` | Cart, single product add-to-cart |
| Form field | `components/form-field.html.twig` | Checkout, account forms, address forms |
---
## Order Status Badge Colours
| Status | Bootstrap Class |
| ------ | --------------- |
| Pending | `bg-warning text-dark` |
| Processing | `bg-info text-dark` |
| On Hold | `bg-secondary` |
| Completed | `bg-success` |
| Cancelled | `bg-danger` |
| Refunded | `bg-dark` |
| Failed | `bg-danger` |
---
## Accessibility Checklist
Every template must meet:
- [ ] Semantic HTML (`<nav>`, `<main>`, `<article>`, `<section>`, `<aside>`)
- [ ] ARIA labels on interactive elements (`aria-label`, `aria-expanded`, `aria-current`)
- [ ] `sr-only` / `visually-hidden` for icon-only buttons
- [ ] Focus-visible styles (Bootstrap default)
- [ ] Keyboard navigation for custom widgets (quantity buttons, variation selects)
- [ ] Colour contrast ratio >= 4.5:1 (Bootstrap defaults meet this)
- [ ] Form labels associated with inputs (`for`/`id` pairs)
- [ ] Error messages linked to fields via `aria-describedby`
---
## Testing Strategy
Since Docker is not yet available, testing follows a manual / staged approach:
### Per-Template Checklist
1. **Visual review** -- Load page in browser, verify Bootstrap 5 styling applied
2. **Responsive check** -- Test at 320px, 768px, 1024px, 1440px breakpoints
3. **Dark mode** -- Toggle `data-bs-theme="dark"`, verify all elements adapt
4. **Functionality** -- All forms submit, AJAX works, WC JS hooks fire
5. **Accessibility** -- Tab through page, check screen reader output
6. **No regressions** -- Existing WooCommerce features still work (add to cart, checkout flow, etc.)
### Integration Test Flow
Once multiple phases are complete, test the full customer journey:
1. Browse shop archive --> filter --> view product
2. Select variation --> add to cart --> view cart
3. Update quantity --> apply coupon --> proceed to checkout
4. Fill billing/shipping --> select payment --> place order
5. View thank-you page --> check email
6. Log in to My Account --> view order --> manage addresses
---
## Estimated Template Count by Phase
| Phase | Templates | Priority |
| ----- | --------- | -------- |
| 1 -- Global & Notices | 9 | Critical |
| 2 -- Archive & Loop | 15 | Critical |
| 3 -- Single Product | 21 | Critical |
| 4 -- Cart | 9 | High |
| 5 -- Checkout | 12 | High |
| 6 -- My Account | 17 | Medium |
| 7 -- Order Details | 5 | Medium |
| 8 -- Emails | ~30 | Low |
| 9 -- Supplementary | 7 | Low |
| **Total** | **~125** | |
New reusable components to create: **8**
**Note:** Not all 233 WooCommerce templates need overrides. Block templates (`templates/templates/`, `parts/`), block-notice variants, and email plain-text variants are excluded or deferred. The ~125 count covers all customer-facing PHP templates that produce visible HTML.
---
## Dependencies & Blockers
| Dependency | Status | Impact |
| ---------- | ------ | ------ |
| Docker environment | Not set up | Cannot test WP-CLI, i18n extraction; use local PHP server |
| WooCommerce-to-Twig bridge | Implemented | `TemplateOverride` intercepts `wc_get_template()` via before/after hooks; `WooCommerceExtension` provides ~50 Twig functions and 7 filters |
| Parent theme Twig functions | Available | `__()`, `esc_html()`, `esc_attr()`, etc. registered by `TwigService` |
| Bootstrap 5 CSS/JS | Available | Loaded by parent theme `wp-bootstrap` |
| Composer autoloader | Available | PSR-4 for `WcBootstrap\` namespace |
---
## File Checklist
Track completion per file. Mark with `[x]` when done.
### Phase 1 -- Global & Notices
- [x] `global/wrapper-start.html.twig`
- [x] `global/wrapper-end.html.twig`
- [x] `global/breadcrumb.html.twig`
- [x] `global/sidebar.html.twig`
- [x] `global/quantity-input.html.twig`
- [x] `global/form-login.html.twig`
- [x] `notices/notice.html.twig`
- [x] `notices/error.html.twig`
- [x] `notices/success.html.twig`
### Phase 2 -- Archive & Loop
- [x] `archive-product.html.twig`
- [x] `content-product.html.twig`
- [x] `content-product-cat.html.twig`
- [x] `product-searchform.html.twig`
- [x] `loop/loop-start.html.twig`
- [x] `loop/loop-end.html.twig`
- [x] `loop/header.html.twig`
- [x] `loop/result-count.html.twig`
- [x] `loop/orderby.html.twig`
- [x] `loop/pagination.html.twig`
- [x] `loop/no-products-found.html.twig`
- [x] `loop/add-to-cart.html.twig`
- [x] `loop/price.html.twig`
- [x] `loop/rating.html.twig`
- [x] `loop/sale-flash.html.twig`
### Phase 3 -- Single Product
- [x] `single-product/product-image.html.twig`
- [x] `single-product/title.html.twig`
- [x] `single-product/price.html.twig`
- [x] `single-product/short-description.html.twig`
- [x] `single-product/meta.html.twig`
- [x] `single-product/rating.html.twig`
- [x] `single-product/stock.html.twig`
- [x] `single-product/sale-flash.html.twig`
- [x] `single-product/share.html.twig`
- [x] `single-product/product-attributes.html.twig`
- [x] `single-product/related.html.twig`
- [x] `single-product/up-sells.html.twig`
- [x] `single-product/add-to-cart/simple.html.twig`
- [x] `single-product/add-to-cart/variable.html.twig`
- [x] `single-product/add-to-cart/grouped.html.twig`
- [x] `single-product/add-to-cart/external.html.twig`
- [x] `single-product/add-to-cart/variation.html.twig`
- [x] `single-product/add-to-cart/variation-add-to-cart-button.html.twig`
- [x] `single-product/tabs/tabs.html.twig`
- [x] `single-product/tabs/description.html.twig`
- [x] `single-product/tabs/additional-information.html.twig`
### Phase 4 -- Cart
- [x] `cart/cart.html.twig`
- [x] `cart/cart-empty.html.twig`
- [x] `cart/cart-item-data.html.twig`
- [x] `cart/cart-totals.html.twig`
- [x] `cart/cart-shipping.html.twig`
- [x] `cart/cross-sells.html.twig`
- [x] `cart/mini-cart.html.twig`
- [x] `cart/proceed-to-checkout-button.html.twig`
- [x] `cart/shipping-calculator.html.twig`
### Phase 5 -- Checkout
- [x] `checkout/form-checkout.html.twig`
- [x] `checkout/form-billing.html.twig`
- [x] `checkout/form-shipping.html.twig`
- [x] `checkout/form-coupon.html.twig`
- [x] `checkout/form-login.html.twig`
- [x] `checkout/review-order.html.twig`
- [x] `checkout/payment.html.twig`
- [x] `checkout/payment-method.html.twig`
- [x] `checkout/terms.html.twig`
- [x] `checkout/thankyou.html.twig`
- [x] `checkout/order-received.html.twig`
- [x] `checkout/cart-errors.html.twig`
### Phase 6 -- My Account
- [x] `myaccount/my-account.html.twig`
- [x] `myaccount/navigation.html.twig`
- [x] `myaccount/dashboard.html.twig`
- [x] `myaccount/orders.html.twig`
- [x] `myaccount/view-order.html.twig`
- [x] `myaccount/my-address.html.twig`
- [x] `myaccount/form-edit-address.html.twig`
- [x] `myaccount/form-edit-account.html.twig`
- [x] `myaccount/downloads.html.twig`
- [x] `myaccount/payment-methods.html.twig`
- [x] `myaccount/form-add-payment-method.html.twig`
- [x] `myaccount/form-login.html.twig`
- [x] `myaccount/form-lost-password.html.twig`
- [x] `myaccount/form-reset-password.html.twig`
- [x] `myaccount/lost-password-confirmation.html.twig`
### Phase 7 -- Order Details
- [x] `order/order-details.html.twig`
- [x] `order/order-details-item.html.twig`
- [x] `order/order-details-customer.html.twig`
- [x] `order/form-tracking.html.twig`
- [x] `order/order-again.html.twig`
### Phase 8 -- Emails (SKIPPED)
Skipped: WooCommerce email templates use `wc_get_template_html()` which bypasses
the Twig rendering pipeline. Email overrides would require traditional PHP files
in `woocommerce/emails/`, breaking the Twig-only pattern. Default WooCommerce
email templates are sufficient; email customization can be handled via plugins
(e.g., Kadence WooCommerce Email Designer) or the WooCommerce block email editor.
### Phase 9 -- Supplementary
- [x] `brands/brand-description.html.twig`
- [x] `brands/taxonomy-product_brand.html.twig`
- [x] `brands/shortcodes/brands-a-z.html.twig`
- [x] `brands/shortcodes/single-brand.html.twig`
- [x] `auth/form-login.html.twig`
- [x] `auth/form-grant-access.html.twig`
- [x] `single-product/back-in-stock-form.html.twig`
### Reusable Components
- [x] `components/price.html.twig`
- [x] `components/rating.html.twig`
- [x] `components/address-card.html.twig`
- [x] `components/status-badge.html.twig`
- [x] `components/quantity-input.html.twig`
- [x] `components/form-field.html.twig`

View File

@@ -28,8 +28,9 @@ The bridge hooks into WooCommerce's `woocommerce_before_template_part` and `wooc
- **99 Twig template overrides** covering all customer-facing WooCommerce pages - **99 Twig template overrides** covering all customer-facing WooCommerce pages
- **Rendering bridge** (`WooCommerceExtension` + `TemplateOverride`) that intercepts WooCommerce's PHP template pipeline - **Rendering bridge** (`WooCommerceExtension` + `TemplateOverride`) that intercepts WooCommerce's PHP template pipeline
- **~50 Twig functions** and **7 Twig filters** exposing WooCommerce and WordPress APIs to templates - **~50 Twig functions** and **8 Twig filters** exposing WooCommerce and WordPress APIs to templates
- Bootstrap 5 responsive markup with dark mode support - Bootstrap 5 responsive markup with dark mode support
- **PHPUnit test suite** with Brain\Monkey for isolated unit testing (no WordPress/WooCommerce required)
- HPOS compatible (uses `WC_Order` methods only, no `$post` global) - HPOS compatible (uses `WC_Order` methods only, no `$post` global)
- 8 reusable Twig components (card, pagination, price, rating, address-card, status-badge, quantity-input, form-field) - 8 reusable Twig components (card, pagination, price, rating, address-card, status-badge, quantity-input, form-field)
- Translation-ready - Translation-ready
@@ -57,14 +58,21 @@ The bridge hooks into WooCommerce's `woocommerce_before_template_part` and `wooc
wc-bootstrap/ wc-bootstrap/
├── assets/ ├── assets/
│ ├── css/wc-bootstrap.css # Bootstrap override styles │ ├── css/wc-bootstrap.css # Bootstrap override styles
│ └── js/quantity.js # Quantity +/- button handler │ └── js/
│ ├── product-gallery.js # Thumbnail click-to-swap gallery handler
│ └── quantity.js # Quantity +/- button handler
├── inc/ ├── inc/
│ ├── TemplateOverride.php # WC template interception (before/after hooks) │ ├── TemplateOverride.php # WC template interception (before/after hooks)
│ └── Twig/ │ └── Twig/
│ └── WooCommerceExtension.php # WC/WP Twig functions & filters │ └── WooCommerceExtension.php # WC/WP Twig functions & filters
├── languages/ # Translation files ├── languages/ # Translation files
├── templates/ # Bootstrap 5 Twig template overrides ├── templates/ # Bootstrap 5 Twig template overrides
│ ├── wc-base.html.twig # Base layout (extends parent's base.html.twig)
│ ├── layouts/ # Page-type layouts (account, archive, form, page, single)
│ ├── archive-product.html.twig │ ├── archive-product.html.twig
│ ├── content-product.html.twig
│ ├── content-product_cat.html.twig
│ ├── product-searchform.html.twig
│ ├── auth/ │ ├── auth/
│ ├── brands/ │ ├── brands/
│ ├── cart/ │ ├── cart/
@@ -76,6 +84,18 @@ wc-bootstrap/
│ ├── notices/ │ ├── notices/
│ ├── order/ │ ├── order/
│ └── single-product/ │ └── single-product/
├── tests/
│ ├── bootstrap.php # PHPUnit bootstrap (autoloader + stubs)
│ ├── Stubs/ # WordPress/WooCommerce class stubs
│ └── Unit/ # Unit tests (TemplateOverride, WooCommerceExtension)
├── docker/
│ ├── Dockerfile # Multistage build (WC download, npm, composer, WP)
│ ├── entrypoint.sh # Auto-setup wrapper entrypoint
│ └── setup.sh # First-run WP install + plugin/theme activation
├── phpunit.xml.dist # PHPUnit 11 configuration
├── .env-dist # Environment variable template
├── compose.yaml # WordPress + MariaDB services
├── compose.override.yaml # Dev overrides (bind mounts, debug flags)
├── composer.json ├── composer.json
├── functions.php ├── functions.php
└── style.css └── style.css
@@ -95,6 +115,29 @@ WooCommerce wc_get_template("cart/cart.php")
→ Result: Bootstrap 5 Twig output only → Result: Bootstrap 5 Twig output only
``` ```
### Docker Environment
```bash
# Copy and adjust environment variables (optional)
cp .env-dist .env
# Build and start
docker compose build
docker compose up -d
# WordPress installs automatically on first boot (admin/admin)
# → http://localhost:8080
# → http://localhost:8080/wp-admin/
# Manual setup (if WP_AUTO_SETUP=0)
docker compose exec wordpress setup.sh
# Tear down
docker compose down -v
```
The image preinstalls WooCommerce and wp-bootstrap. The override bind-mounts both themes for live development.
### Building Translations ### Building Translations
```bash ```bash
@@ -106,8 +149,8 @@ for po in languages/wc-bootstrap-*.po; do msgfmt -o "${po%.po}.mo" "$po"; done
Releases are automated via Gitea Actions. Push a tag matching `vX.X.X` to trigger a release build. Releases are automated via Gitea Actions. Push a tag matching `vX.X.X` to trigger a release build.
```bash ```bash
git tag -a v0.1.0 -m "Version 0.1.0 - Initial release" git tag -a v0.1.6 -m "Version 0.1.6 - Add PHPUnit test suite"
git push origin v0.1.0 git push origin v0.1.6
``` ```
## License ## License

182
SETUP.md
View File

@@ -1,182 +0,0 @@
# WP Bootstrap Child Theme Template -- Setup Guide
This template creates a Bootstrap 5 child theme for the `wp-bootstrap` parent theme
that overrides a WordPress plugin's Twig templates with Bootstrap 5 markup.
## Placeholders Reference
Search and replace these placeholders across all files when instantiating a new project:
### Project Identity
| Placeholder | Description | Example |
| --- | --- | --- |
| `WooCommerce Bootstrap` | Human-readable theme name | `WP JobRoom Theme` |
| `wc-bootstrap` | WordPress theme directory name (kebab-case) | `wp-jobroom-theme` |
| `wc-bootstrap` | WordPress text domain for i18n | `wp-jobroom-theme` |
| `WC_BOOTSTRAP` | PHP constant prefix (UPPER_SNAKE_CASE) | `WP_JOBROOM_THEME` |
| `wc_bootstrap` | PHP function prefix (lower_snake_case) | `wp_jobroom_theme` |
| `WcBootstrap` | PSR-4 PHP namespace | `WPJobroomTheme` |
| `magdev` | Composer vendor name | `magdev` |
### Plugin (upstream dependency)
| Placeholder | Description | Example |
| --- | --- | --- |
| `WooCommerce` | Human-readable plugin name | `WP JobRoom` |
| `woocommerce` | Plugin directory/handle name | `wp-jobroom` |
| `woocommerce` | Plugin text domain | `wp-jobroom` |
| `Magdev\Woocommerce` | Plugin PHP namespace | `Magdev\WpJobroom` |
| `https://github.com/woocommerce/woocommerce.git` | Plugin repository URL | `https://src.example.com/user/wp-myplugin` |
| `woocommerce_render_page` | Plugin's render page filter name | `wp_jobroom_render_page` |
| `woocommerce_is_theme_wrapped` | Plugin's is-wrapped filter name | `wp_jobroom_is_theme_wrapped` |
### Author & Repository
| Placeholder | Description | Example |
| --- | --- | --- |
| `Marco Grätsch` | Author's full name | `Marco Graetsch` |
| `magdev3.0@gmail.com` | Author's email | `magdev3.0@gmail.com` |
| `https://src.bundespruefstelle.ch/magdev` | Author's website URL | `https://src.example.com/user` |
| `ssh://git@src.bundespruefstelle.ch:2022/magdev/wc-bootstrap.git` | Theme repository URL | `https://src.example.com/user/wp-myplugin-theme` |
| `ssh://git@src.bundespruefstelle.ch:2022/magdev/wc-bootstrap.git/issues` | Issues tracker URL | `https://src.example.com/user/wp-myplugin-theme/issues` |
| `https://src.bundespruefstelle.ch/magdev/wp-bootstrap` | Parent theme repository URL | `https://src.example.com/user/wp-bootstrap` |
### Infrastructure
| Placeholder | Description | Example |
| --- | --- | --- |
| `woocommerce-wordpress` | Docker container name for WP-CLI | `myplugin-wordpress` |
## Files to Customize
After replacing placeholders, these files need project-specific customization:
### Required Changes
1. **`inc/TemplateOverride.php`** -- Update the `use` import to match your plugin's
actual Template class path (line: `use Magdev\Woocommerce\Frontend\Template;`)
2. **`functions.php`** -- Review and adapt:
- CSS dependency array in `enqueue_styles()` -- add plugin style handles
- Filter hooks -- match your plugin's actual filter names
- Remove or adapt features you don't need (sticky header, register page, etc.)
3. **`composer.json`** -- Verify namespace mapping matches your `inc/` directory structure
4. **`style.css`** -- Update the header to match your theme's specifics
5. **`.gitea/workflows/release.yml`** -- Update theme name in release title
### Optional Additions
6. **`inc/ProfileMenu.php`** -- Create if your plugin has a navigation menu that needs
Bootstrap 5 conversion (use wp-jobroom-theme's ProfileMenu.php as reference)
7. **`assets/css/theme-overrides.css`** -- Rename to `wc-bootstrap.css` and add
plugin-specific CSS class overrides mapping to Bootstrap 5
8. **`templates/`** -- Add template overrides mirroring your plugin's template structure
## Architecture Overview
```txt
wp-bootstrap (parent theme, Bootstrap 5 FSE + Twig rendering)
+-- wc-bootstrap (child theme, overrides plugin Twig templates with Bootstrap 5)
+-- woocommerce (plugin, provides post types, logic, base Twig templates)
```
### Template Override Flow
1. Plugin registers Twig `FilesystemLoader` with its `templates/` directory
2. Child theme's `TemplateOverride` hooks `init` at priority 20 (after plugin at 0)
3. `prependPath()` adds child theme's `templates/` before plugin's
4. Twig resolves templates: child theme first, plugin as fallback
### Page Rendering Flow
1. Plugin's Router catches a request and renders plugin content via Twig
2. Plugin fires `woocommerce_render_page` filter with pre-rendered HTML
3. Child theme's `render_page()` intercepts, delegates to parent theme's TwigService
4. Parent theme wraps content in its page shell (header, footer, navigation)
5. `_theme_wrapped` context flag tells plugin templates to suppress their own wrapper
### CSS Cascade
```txt
1. wp-bootstrap (parent) -- Bootstrap 5 framework
2. woocommerce -- Plugin's custom CSS
3. wc-bootstrap-style -- Child theme style.css (metadata)
4. wc-bootstrap-overrides -- Bootstrap 5 overrides for plugin classes
```
## Plugin Requirements
For this template to work, the plugin must:
1. **Use Twig with `FilesystemLoader`** -- for template overriding via `prependPath()`
2. **Expose a singleton Template class** -- so the child theme can access the Twig environment
3. **Fire a render page filter** -- so the child theme can delegate rendering to the parent theme
4. **Fire an is-wrapped filter** -- so plugin templates know to suppress their outer wrapper
5. **Register its styles with a known handle** -- for CSS dependency chain ordering
## Quick Start
```bash
# 1. Clone/copy this template
cp -r wp-theme-template/ wp-content/themes/my-new-theme/
# 2. Replace all placeholders (example using sed)
cd wp-content/themes/my-new-theme/
find . -type f \( -name "*.php" -o -name "*.md" -o -name "*.css" -o -name "*.json" -o -name "*.yml" -o -name "*.twig" \) \
-exec sed -i 's/WooCommerce Bootstrap/My Plugin Theme/g' {} +
# ... repeat for all placeholders
# 3. Rename the CSS override file
mv assets/css/theme-overrides.css assets/css/my-plugin-theme.css
# 4. Install dependencies
composer install
# 5. Initialize git
git init && git checkout -b dev
git add . && git commit -m "Initial theme scaffold"
# 6. Start overriding plugin templates in templates/
```
## Common Patterns
### Adding a New Template Override
1. Find the plugin's template in `plugins/woocommerce/templates/path/file.html.twig`
2. Create the same path in `themes/wc-bootstrap/templates/path/file.html.twig`
3. Convert HTML to Bootstrap 5 components
4. Preserve all context variables and block names from the original
5. Preserve plugin JS-bound CSS classes (repeater fields, interactive widgets)
### Layout Hierarchy
```txt
base.html.twig -- Notifications, breadcrumbs, container wrapping
+-- layouts/page.html.twig -- Standard content pages
+-- layouts/form.html.twig -- Auth and edit forms (centered card)
+-- layouts/single.html.twig -- Detail pages (8+4 with sidebar)
+-- layouts/archive.html.twig -- Search/list pages (3+9 with filters)
+-- layouts/account.html.twig -- User account pages
```
### Bootstrap 5 Component Mappings
| Plugin Pattern | Bootstrap 5 Equivalent |
| --- | --- |
| Custom button classes | `btn btn-primary`, `btn-outline-*` |
| Custom alert/notification | `alert alert-*` with `alert-dismissible` |
| Custom grid system | `row`, `col-*`, `row-cols-*` |
| Custom form fields | `form-control`, `form-floating`, `form-select` |
| Custom cards | `card`, `card-body`, `card-title` |
| Custom modal/dialog | `modal`, `modal-dialog`, `modal-content` |
| Custom dropdown | `dropdown`, `dropdown-menu`, `data-bs-toggle` |
| Custom tabs | `nav-tabs`, `tab-content`, `tab-pane` |
| Custom pagination | `pagination`, `page-item`, `page-link` |
| Custom breadcrumb | `breadcrumb`, `breadcrumb-item` |

View File

@@ -42,29 +42,43 @@
when notices are rendered outside our Twig templates. when notices are rendered outside our Twig templates.
========================================================================== */ ========================================================================== */
.woocommerce-info, /* Override woocommerce.css which sets border-top: 3px solid, background-color:
.woocommerce-message, #f6f5f8, and a WooCommerce icon font ::before on notice classes.
.woocommerce-error { Two selector patterns at specificity 0,2,0:
- .woocommerce .woocommerce-* — notices inside a .woocommerce wrapper
- .alert.woocommerce-* — notices rendered by our Twig templates */
.woocommerce .woocommerce-info,
.woocommerce .woocommerce-message,
.woocommerce .woocommerce-error,
.alert.woocommerce-info,
.alert.woocommerce-message,
.alert.woocommerce-error {
position: relative; position: relative;
padding: 1rem 3rem 1rem 1rem; padding: 1rem 3rem 1rem 1rem;
margin-bottom: 1rem; margin: 0 0 1rem;
border: 1px solid transparent; border: var(--bs-border-width) solid transparent;
border-top: var(--bs-border-width) solid transparent;
border-radius: var(--bs-border-radius); border-radius: var(--bs-border-radius);
background-color: transparent;
background-image: none;
} }
.woocommerce-info { .woocommerce .woocommerce-info,
.alert.woocommerce-info {
color: var(--bs-info-text-emphasis); color: var(--bs-info-text-emphasis);
background-color: var(--bs-info-bg-subtle); background-color: var(--bs-info-bg-subtle);
border-color: var(--bs-info-border-subtle); border-color: var(--bs-info-border-subtle);
} }
.woocommerce-message { .woocommerce .woocommerce-message,
.alert.woocommerce-message {
color: var(--bs-success-text-emphasis); color: var(--bs-success-text-emphasis);
background-color: var(--bs-success-bg-subtle); background-color: var(--bs-success-bg-subtle);
border-color: var(--bs-success-border-subtle); border-color: var(--bs-success-border-subtle);
} }
.woocommerce-error { .woocommerce .woocommerce-error,
.alert.woocommerce-error {
color: var(--bs-danger-text-emphasis); color: var(--bs-danger-text-emphasis);
background-color: var(--bs-danger-bg-subtle); background-color: var(--bs-danger-bg-subtle);
border-color: var(--bs-danger-border-subtle); border-color: var(--bs-danger-border-subtle);
@@ -72,6 +86,27 @@
padding-left: 1rem; padding-left: 1rem;
} }
/* WooCommerce JS (woocommerce.js:focus_populate_live_region) adds tabindex="-1"
and calls .focus() on notices for screen reader accessibility. The default
browser focus ring appears white in dark mode — suppress it since these are
non-interactive elements (the focus is only for screen reader announcement). */
.alert.woocommerce-info:focus,
.alert.woocommerce-message:focus,
.alert.woocommerce-error:focus {
outline: 0;
box-shadow: none;
}
/* Suppress WooCommerce icon font ::before — our templates use Bootstrap Icons */
.woocommerce .woocommerce-info::before,
.woocommerce .woocommerce-message::before,
.woocommerce .woocommerce-error::before,
.alert.woocommerce-info::before,
.alert.woocommerce-message::before,
.alert.woocommerce-error::before {
display: none;
}
/* ========================================================================== /* ==========================================================================
Quantity Input Quantity Input
Sizing for the Bootstrap input-group quantity widget. Sizing for the Bootstrap input-group quantity widget.
@@ -109,12 +144,107 @@
width: 100%; width: 100%;
} }
/* Product link wrapping card content — remove underline, inherit text color */
.product.card a.woocommerce-LoopProduct-link {
text-decoration: none;
color: inherit;
}
/* Product title in card body */
.product.card .woocommerce-loop-product__title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
/* Push price to bottom of card body for even card heights */
.product.card .card-body .price {
margin-top: auto;
}
/* Add-to-cart button — Bootstrap btn-outline-primary style */
.product.card .button {
display: inline-block;
width: 100%;
font-weight: 400;
line-height: 1.5;
text-align: center;
text-decoration: none;
vertical-align: middle;
cursor: pointer;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
border-radius: var(--bs-border-radius);
color: var(--bs-primary);
border: var(--bs-border-width) solid var(--bs-primary);
background-color: transparent;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
}
.product.card .button:hover {
color: #fff;
background-color: var(--bs-primary);
border-color: var(--bs-primary);
}
/* "View options" button for variable products */
.product.card .button.product_type_variable {
color: var(--bs-secondary);
border-color: var(--bs-secondary);
}
.product.card .button.product_type_variable:hover {
color: #fff;
background-color: var(--bs-secondary);
border-color: var(--bs-secondary);
}
/* "Read more" button for external/non-purchasable products */
.product.card .button.product_type_external {
color: var(--bs-info);
border-color: var(--bs-info);
}
.product.card .button.product_type_external:hover {
color: #fff;
background-color: var(--bs-info);
border-color: var(--bs-info);
}
/* Added-to-cart visual feedback */
.product.card .added_to_cart {
display: inline-block;
width: 100%;
text-align: center;
font-size: 0.875rem;
margin-top: 0.5rem;
color: var(--bs-success);
text-decoration: none;
}
/* WooCommerce result count and ordering bar */
.woocommerce-result-count {
margin-bottom: 0;
line-height: 2.5;
}
/* Ordering select — Bootstrap's .form-select handles all styling.
Remove duplicate rules that conflict with Bootstrap's dropdown arrow.
WooCommerce's woocommerce-layout.css sets background-image on selects;
ensure Bootstrap's chevron wins via appearance: none. */
.woocommerce-ordering .form-select {
appearance: none;
}
/* ========================================================================== /* ==========================================================================
Sale Badge Sale Badge
Positioning for the sale overlay badge on product cards. Position inside gallery area, not overlapping breadcrumb.
WooCommerce sets top: -.5em which bleeds above the containing block.
========================================================================== */ ========================================================================== */
.onsale { .woocommerce span.onsale {
top: 0.5em;
left: 0.5em;
z-index: 1; z-index: 1;
} }
@@ -165,6 +295,18 @@
border-radius: var(--bs-border-radius); border-radius: var(--bs-border-radius);
} }
/* ==========================================================================
Single Product Layout
Reset WooCommerce's float-based two-column layout for single product pages.
Bootstrap's row/col-lg-6 grid handles the layout instead.
========================================================================== */
.woocommerce div.product div.images,
.woocommerce div.product div.summary {
float: none;
width: 100%;
}
/* ========================================================================== /* ==========================================================================
Variation Selectors Variation Selectors
Spacing for variable product attribute dropdowns. Spacing for variable product attribute dropdowns.
@@ -179,12 +321,37 @@
} }
/* ========================================================================== /* ==========================================================================
WooCommerce Grid Override Shop / Archive Layout
Reset WooCommerce's default grid to let Bootstrap handle layout. Reset WooCommerce's default float grid to let Bootstrap handle layout.
Override woocommerce-layout.css float-based widths and clearfixes.
========================================================================== */ ========================================================================== */
.woocommerce ul.products { /* Reset float-based result count / ordering bar — use flexbox instead */
display: contents; .post-type-archive-product .woocommerce-result-count,
.tax-product_cat .woocommerce-result-count,
.tax-product_tag .woocommerce-result-count,
.post-type-archive-product .woocommerce-ordering,
.tax-product_cat .woocommerce-ordering,
.tax-product_tag .woocommerce-ordering {
float: none;
}
/* Reset WooCommerce's product grid floats and widths */
.woocommerce ul.products,
.woocommerce .products {
list-style: none;
padding: 0;
margin: 0;
width: 100%;
clear: both;
}
.woocommerce ul.products li.product,
.woocommerce .products .product {
float: none;
width: auto;
margin: 0;
padding: 0;
} }
/* ========================================================================== /* ==========================================================================
@@ -243,10 +410,81 @@
========================================================================== */ ========================================================================== */
/* Bootstrap 5 dark mode uses data-bs-theme="dark" attribute on <html> */ /* Bootstrap 5 dark mode uses data-bs-theme="dark" attribute on <html> */
[data-bs-theme="dark"] {
/* Checkout form focus color for dark mode */ /* Native <select> elements — woocommerce.css sets:
select { background-color: var(--wc-form-color-background, #fff) }
The custom property is never defined for dark mode, so it falls back to #fff. */
[data-bs-theme="dark"] .woocommerce select,
[data-bs-theme="dark"] .wc-block-checkout select {
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
border-color: var(--bs-border-color);
} }
/* Text inputs & textareas — same issue as <select>: woocommerce.css sets
.form-row .input-text { background-color: var(--wc-form-color-background, #fff) }
with higher specificity than the theme's checkout form rules. */
[data-bs-theme="dark"] .woocommerce .form-row .input-text,
[data-bs-theme="dark"] .woocommerce .form-row textarea {
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
border-color: var(--bs-border-color);
}
/* SelectWoo / Select2 — select2.css hardcodes #fff backgrounds on selection
containers and dropdowns. Override to use Bootstrap's dark mode variables. */
[data-bs-theme="dark"] .select2-container--default .select2-selection--single,
[data-bs-theme="dark"] .select2-container--default .select2-selection--multiple {
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
border-color: var(--bs-border-color);
}
[data-bs-theme="dark"] .select2-container--default .select2-selection--single .select2-selection__rendered {
color: var(--bs-body-color);
}
[data-bs-theme="dark"] .select2-container--default .select2-selection--single .select2-selection__arrow b {
border-color: var(--bs-secondary-color) transparent transparent transparent;
}
[data-bs-theme="dark"] .select2-dropdown {
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
border-color: var(--bs-border-color);
}
[data-bs-theme="dark"] .select2-container--default .select2-search--dropdown .select2-search__field {
background-color: var(--bs-tertiary-bg);
color: var(--bs-body-color);
border-color: var(--bs-border-color);
}
[data-bs-theme="dark"] .select2-container--default .select2-results__option[aria-selected=true],
[data-bs-theme="dark"] .select2-container--default .select2-results__option[data-selected=true] {
background-color: var(--bs-tertiary-bg);
}
[data-bs-theme="dark"] .select2-container--default .select2-results__option--highlighted[aria-selected],
[data-bs-theme="dark"] .select2-container--default .select2-results__option--highlighted[data-selected] {
background-color: var(--bs-primary);
color: #fff;
}
/* Variable product attribute selectors — woocommerce.css sets:
.woocommerce div.product form.cart .variations select { background: url(...) no-repeat }
at specificity (0,4,3). We must match or exceed that.
Override both background-color (white fallback) and background-image (black SVG chevron)
so Bootstrap's dark-mode form-select styling takes effect. */
[data-bs-theme="dark"] .woocommerce div.product form.cart .variations select,
[data-bs-theme="dark"] .woocommerce div.product .variations .form-select {
background-color: var(--bs-body-bg);
background-image: var(--bs-form-select-bg-img);
color: var(--bs-body-color);
border-color: var(--bs-border-color);
}
/* Checkout form focus color for dark mode */
[data-bs-theme="dark"] .woocommerce-checkout .form-row input.input-text:focus, [data-bs-theme="dark"] .woocommerce-checkout .form-row input.input-text:focus,
[data-bs-theme="dark"] .woocommerce-checkout .form-row textarea:focus, [data-bs-theme="dark"] .woocommerce-checkout .form-row textarea:focus,
[data-bs-theme="dark"] .woocommerce-checkout .form-row select:focus { [data-bs-theme="dark"] .woocommerce-checkout .form-row select:focus {
@@ -280,16 +518,50 @@ header.sticky-top.is-stuck {
/* ========================================================================== /* ==========================================================================
My Account My Account
Navigation and layout for the My Account area. Navigation and layout for the My Account area.
Reset WooCommerce's float-based layout to let Bootstrap flex grid handle it.
========================================================================== */ ========================================================================== */
.woocommerce-account main .woocommerce,
.woocommerce-cart main .woocommerce,
.woocommerce-checkout main .woocommerce,
.post-type-archive-product main .woocommerce,
.tax-product_cat main .woocommerce,
.tax-product_tag main .woocommerce {
max-width: none;
}
.woocommerce-account .woocommerce-MyAccount-navigation {
float: none;
width: auto;
}
.woocommerce-account .woocommerce-MyAccount-content {
float: none;
width: 100%;
}
.woocommerce-MyAccount-navigation .list-group-item {
white-space: nowrap;
}
.woocommerce-MyAccount-navigation .list-group-item.active { .woocommerce-MyAccount-navigation .list-group-item.active {
font-weight: 600; font-weight: 600;
} }
/* Order status marks */ /* Dashboard: avatar rounding */
.woocommerce-order-details mark { .woocommerce-MyAccount-content .avatar {
background: none; border-radius: 50%;
font-weight: 600; }
/* Dashboard: quick action card hover lift */
.woocommerce-MyAccount-content a.card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
color: inherit;
}
.woocommerce-MyAccount-content a.card:hover {
transform: translateY(-2px);
box-shadow: var(--bs-box-shadow) !important;
} }
/* View-order notes */ /* View-order notes */
@@ -345,3 +617,30 @@ header.sticky-top.is-stuck {
.product-quantity { .product-quantity {
white-space: nowrap; white-space: nowrap;
} }
/* ==========================================================================
Shop Table
Reset WooCommerce's border styles on .shop_table to let Bootstrap's
.table class handle borders via --bs-table-* custom properties.
========================================================================== */
.woocommerce table.shop_table {
border: 0;
border-collapse: collapse;
}
.woocommerce table.shop_table td,
.woocommerce table.shop_table th {
border-left: 0;
border-right: 0;
}
/* ==========================================================================
Product Gallery
WooCommerce JS sets opacity: 1 after initialization. Ensure the gallery
is visible even if WC JS doesn't run (e.g., gallery features disabled).
========================================================================== */
.woocommerce-product-gallery--without-images {
opacity: 1 !important;
}

View File

@@ -0,0 +1,56 @@
/**
* Product Gallery Thumbnail Click Handler
*
* Swaps the main product image when a gallery thumbnail is clicked.
* The first thumbnail is the main product image (always included in the strip).
* Also handles gallery fade-in (WooCommerce sets opacity: 0 by default).
*
* @package WcBootstrap
* @since 0.1.5
*/
(function () {
'use strict';
function initGallery() {
var gallery = document.querySelector('.woocommerce-product-gallery');
if (!gallery) return;
// Fade in the gallery (WooCommerce expects JS to set opacity: 1).
gallery.style.opacity = '1';
var mainImageContainer = gallery.querySelector('.woocommerce-product-gallery__image');
if (!mainImageContainer) return;
var mainImage = mainImageContainer.querySelector('img');
if (!mainImage) return;
var thumbs = gallery.querySelectorAll('.wc-gallery-thumb');
if (!thumbs.length) return;
thumbs.forEach(function (thumb) {
thumb.style.cursor = 'pointer';
thumb.addEventListener('click', function () {
var fullSrc = this.getAttribute('data-full-src');
if (!fullSrc) return;
mainImage.setAttribute('src', fullSrc);
// Update active state.
thumbs.forEach(function (t) {
t.style.opacity = '0.6';
t.classList.remove('border-primary', 'active');
t.classList.add('border');
});
this.style.opacity = '1';
this.classList.add('border-primary', 'active');
this.classList.remove('border');
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initGallery);
} else {
initGallery();
}
})();

17
compose.override.yaml Normal file
View File

@@ -0,0 +1,17 @@
services:
db:
container_name: woocommerce-db
restart: unless-stopped
wordpress:
container_name: woocommerce
restart: unless-stopped
volumes:
- ../wp-bootstrap:/var/www/html/wp-content/themes/wp-bootstrap
- .:/var/www/html/wp-content/themes/wc-bootstrap
environment:
WORDPRESS_DEBUG: "1"
WORDPRESS_CONFIG_EXTRA: |
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', true);
define('SCRIPT_DEBUG', true);

48
compose.yaml Normal file
View File

@@ -0,0 +1,48 @@
services:
db:
image: mariadb:latest
restart: always
volumes:
- db_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpass}
MYSQL_DATABASE: ${MYSQL_DATABASE:-wordpress}
MYSQL_USER: ${MYSQL_USER:-wordpress}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-wordpress}
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
wordpress:
image: hub.bundespruefstelle.ch/woocommerce:latest
restart: always
build:
context: .
dockerfile: docker/Dockerfile
additional_contexts:
wp-bootstrap: ../wp-bootstrap
target: wp_runtime
ports:
- "${WP_PORT:-8080}:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: ${MYSQL_DATABASE:-wordpress}
WORDPRESS_DB_USER: ${MYSQL_USER:-wordpress}
WORDPRESS_DB_PASSWORD: ${MYSQL_PASSWORD:-wordpress}
WP_AUTO_SETUP: ${WP_AUTO_SETUP:-1}
WP_URL: ${WP_URL:-http://localhost:${WP_PORT:-8080}}
WP_TITLE: ${WP_TITLE:-WC Bootstrap Dev}
WP_ADMIN_USER: ${WP_ADMIN_USER:-admin}
WP_ADMIN_PASSWORD: ${WP_ADMIN_PASSWORD:-admin}
WP_ADMIN_EMAIL: ${WP_ADMIN_EMAIL:-admin@example.com}
depends_on:
db:
condition: service_healthy
volumes:
- wp_data:/var/www/html
volumes:
db_data:
wp_data:

View File

@@ -14,11 +14,20 @@
"php": ">=8.3", "php": ">=8.3",
"twig/twig": "^3.0" "twig/twig": "^3.0"
}, },
"require-dev": {
"brain/monkey": "^2.6",
"phpunit/phpunit": "^11.0"
},
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"WcBootstrap\\": "inc/" "WcBootstrap\\": "inc/"
} }
}, },
"autoload-dev": {
"psr-4": {
"WcBootstrap\\Tests\\": "tests/"
}
},
"config": { "config": {
"optimize-autoloader": true, "optimize-autoloader": true,
"sort-packages": true "sort-packages": true

2038
composer.lock generated

File diff suppressed because it is too large Load Diff

73
docker/Dockerfile Normal file
View File

@@ -0,0 +1,73 @@
#syntax=docker/dockerfile:1
ARG PHP_VERSION=8.4
###############################################################################
# Stage 1 — Download WooCommerce from WordPress.org
###############################################################################
FROM alpine:3.21 AS woocommerce
RUN apk add --no-cache curl unzip jq
ARG WOOCOMMERCE_VERSION=latest
RUN set -ex; \
if [ "$WOOCOMMERCE_VERSION" = "latest" ]; then \
URL=$(curl -sSf "https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&slug=woocommerce" \
| jq -r '.download_link'); \
else \
URL="https://downloads.wordpress.org/plugin/woocommerce.${WOOCOMMERCE_VERSION}.zip"; \
fi; \
curl -sSfL -o /tmp/woocommerce.zip "$URL"; \
unzip -q /tmp/woocommerce.zip -d /tmp/plugins; \
rm /tmp/woocommerce.zip
###############################################################################
# Stage 2 — Build wp-bootstrap theme assets
###############################################################################
FROM node:20-alpine AS wp-bootstrap-assets
COPY --from=wp-bootstrap . /build
WORKDIR /build
RUN npm ci && npm run build \
&& rm -rf node_modules src .git .gitea .github .vscode .claude .idea \
CLAUDE.md PLAN.md package.json package-lock.json .editorconfig \
releases .gitignore
###############################################################################
# Stage 3 — Install Composer dependencies for both themes
###############################################################################
FROM composer:2 AS composer
COPY --from=wp-bootstrap-assets /build /themes/wp-bootstrap
WORKDIR /themes/wp-bootstrap
RUN composer install --no-dev --optimize-autoloader --no-interaction
COPY . /themes/wc-bootstrap
WORKDIR /themes/wc-bootstrap
RUN composer install --no-dev --optimize-autoloader --no-interaction
###############################################################################
# Stage 4 — Final WordPress image
###############################################################################
FROM wordpress:php${PHP_VERSION} AS wp_runtime
RUN curl -sSfL -o /usr/local/bin/wp \
https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \
&& chmod +x /usr/local/bin/wp
COPY --from=woocommerce /tmp/plugins/woocommerce \
/var/www/html/wp-content/plugins/woocommerce
COPY --from=composer /themes/wp-bootstrap \
/var/www/html/wp-content/themes/wp-bootstrap
COPY --from=composer /themes/wc-bootstrap \
/var/www/html/wp-content/themes/wc-bootstrap
COPY --link --chmod=0755 docker/setup.sh /usr/local/bin/setup.sh
COPY --link --chmod=0755 docker/entrypoint.sh /usr/local/bin/wc-entrypoint.sh
ENTRYPOINT ["wc-entrypoint.sh"]
CMD ["apache2-foreground"]

21
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
#
# Wrapper entrypoint: runs first-time WordPress setup in the background,
# then hands off to the official WordPress entrypoint.
#
# Set WP_AUTO_SETUP=0 to disable automatic setup.
#
if [ "${WP_AUTO_SETUP:-1}" = "1" ]; then
(
cd /var/www/html
until [ -f wp-config.php ]; do sleep 2; done
sleep 3
if ! wp core is-installed --allow-root 2>/dev/null; then
setup.sh
fi
) &
fi
exec docker-entrypoint.sh "$@"

47
docker/setup.sh Normal file
View File

@@ -0,0 +1,47 @@
#!/bin/bash
#
# First-run setup: installs WordPress, activates WooCommerce and wc-bootstrap.
#
# Usage:
# docker compose exec wordpress setup.sh
#
set -euo pipefail
WP_URL="${WP_URL:-http://localhost:8080}"
WP_TITLE="${WP_TITLE:-WC Bootstrap Dev}"
WP_ADMIN_USER="${WP_ADMIN_USER:-admin}"
WP_ADMIN_PASSWORD="${WP_ADMIN_PASSWORD:-admin}"
WP_ADMIN_EMAIL="${WP_ADMIN_EMAIL:-admin@example.com}"
cd /var/www/html
# Wait for WordPress files (entrypoint copies them on first boot)
until [ -f wp-config.php ]; do
echo "Waiting for WordPress to initialize..."
sleep 2
done
if ! wp core is-installed --allow-root 2>/dev/null; then
echo "Installing WordPress..."
wp core install \
--url="$WP_URL" \
--title="$WP_TITLE" \
--admin_user="$WP_ADMIN_USER" \
--admin_password="$WP_ADMIN_PASSWORD" \
--admin_email="$WP_ADMIN_EMAIL" \
--skip-email \
--allow-root
fi
echo "Activating WooCommerce..."
wp plugin activate woocommerce --allow-root 2>/dev/null || true
echo "Activating wc-bootstrap theme..."
wp theme activate wc-bootstrap --allow-root 2>/dev/null || true
echo ""
echo "Setup complete!"
echo " URL: $WP_URL"
echo " Admin: $WP_URL/wp-admin/"
echo " User: $WP_ADMIN_USER"
echo " Password: $WP_ADMIN_PASSWORD"

View File

@@ -14,17 +14,11 @@ if ( ! defined( 'ABSPATH' ) ) {
exit; exit;
} }
/** /**
* Define theme constants. * Define theme constants.
*
* CRITICAL: WordPress reads the version from TWO places:
* 1. style.css header "Version:" — WordPress uses THIS for admin display
* 2. This PHP constant — used internally by the theme
* Both MUST be updated on every release.
*/ */
define( 'WC_BOOTSTRAP_VERSION', '0.0.1' );
define( 'WC_BOOTSTRAP_PATH', get_stylesheet_directory() . '/' ); define( 'WC_BOOTSTRAP_PATH', get_stylesheet_directory() . '/' );
define( 'WC_BOOTSTRAP_URL', get_stylesheet_directory_uri() . '/' );
/** /**
* Load Composer autoloader if present. * Load Composer autoloader if present.
@@ -114,7 +108,7 @@ function wc_bootstrap_enqueue_styles(): void {
wp_enqueue_style( wp_enqueue_style(
'wc-bootstrap-overrides', 'wc-bootstrap-overrides',
get_stylesheet_directory_uri() . '/assets/css/wc-bootstrap.css', get_stylesheet_directory_uri() . '/assets/css/wc-bootstrap.css',
array( 'wc-bootstrap-style', 'woocommerce' ), array( 'wc-bootstrap-style', 'woocommerce-general' ),
$theme_version $theme_version
); );
} }
@@ -136,9 +130,68 @@ function wc_bootstrap_enqueue_scripts(): void {
$theme_version, $theme_version,
true true
); );
// Product gallery thumbnail handler for single product pages.
if ( function_exists( 'is_product' ) && is_product() ) {
wp_enqueue_script(
'wc-bootstrap-gallery',
get_stylesheet_directory_uri() . '/assets/js/product-gallery.js',
array(),
$theme_version,
true
);
}
} }
add_action( 'wp_enqueue_scripts', 'wc_bootstrap_enqueue_scripts' ); add_action( 'wp_enqueue_scripts', 'wc_bootstrap_enqueue_scripts' );
/**
* Build the parent theme context for a page render.
*
* Caches the ContextBuilder result per request to avoid redundant database
* queries when multiple WooCommerce rendering functions need the same context.
*
* @since 0.1.1
*
* @return array Theme context array.
*/
function wc_bootstrap_get_theme_context(): array {
static $cached_context = null;
if ( null === $cached_context ) {
$context_builder = new \WPBootstrap\Template\ContextBuilder();
$cached_context = $context_builder->build();
}
return $cached_context;
}
/**
* Render content inside the parent theme's page shell.
*
* Injects the given HTML content into the parent theme's page template,
* replacing the post content. Title and thumbnail are blanked so the
* parent theme does not render its own headings — the content handles that.
*
* @since 0.1.1
*
* @param string $content HTML content to render inside the page shell.
*/
function wc_bootstrap_render_in_page_shell( string $content ): void {
$theme_context = wc_bootstrap_get_theme_context();
$twig = \WPBootstrap\Twig\TwigService::getInstance();
$theme_context['post'] = array_merge(
$theme_context['post'] ?? [],
[
'content' => $content,
'title' => '',
'thumbnail' => '',
]
);
echo $twig->render( 'pages/page.html.twig', $theme_context );
}
/** /**
* Handle plugin page rendering via plugin render filter. * Handle plugin page rendering via plugin render filter.
* *
@@ -156,26 +209,10 @@ add_action( 'wp_enqueue_scripts', 'wc_bootstrap_enqueue_scripts' );
function wc_bootstrap_render_page( bool $rendered, string $content, array $context ): bool { function wc_bootstrap_render_page( bool $rendered, string $content, array $context ): bool {
if ( ! class_exists( '\WPBootstrap\Twig\TwigService' ) if ( ! class_exists( '\WPBootstrap\Twig\TwigService' )
|| ! class_exists( '\WPBootstrap\Template\ContextBuilder' ) ) { || ! class_exists( '\WPBootstrap\Template\ContextBuilder' ) ) {
return false; // Can't render, let plugin use its own fallback return false;
} }
$context_builder = new \WPBootstrap\Template\ContextBuilder(); wc_bootstrap_render_in_page_shell( $content );
$theme_context = $context_builder->build();
$twig = \WPBootstrap\Twig\TwigService::getInstance();
// Inject plugin content as the page post content so page.html.twig renders it
// inside the standard content block. Title is empty so the parent theme does not
// render its own <h1> — plugin templates handle their own headings.
$theme_context['post'] = array_merge(
$theme_context['post'] ?? [],
[
'content' => $content,
'title' => '',
'thumbnail' => '',
]
);
echo $twig->render( 'pages/page.html.twig', $theme_context );
return true; return true;
} }
add_filter( 'woocommerce_render_page', 'wc_bootstrap_render_page', 10, 3 ); add_filter( 'woocommerce_render_page', 'wc_bootstrap_render_page', 10, 3 );
@@ -223,3 +260,178 @@ function wc_bootstrap_sticky_header_script(): void {
<?php <?php
} }
add_action( 'wp_footer', 'wc_bootstrap_sticky_header_script' ); add_action( 'wp_footer', 'wc_bootstrap_sticky_header_script' );
/**
* Register the shop sidebar widget area.
*
* Provides a widget area for product filters and shop-specific widgets.
* Uses Bootstrap-styled markup matching the parent theme's sidebar pattern.
*
* @since 0.1.0
*/
function wc_bootstrap_register_sidebars(): void {
register_sidebar( array(
'name' => __( 'Shop Sidebar', 'wc-bootstrap' ),
'id' => 'shop-sidebar',
'description' => __( 'Add widgets here to appear in the shop sidebar.', 'wc-bootstrap' ),
'before_widget' => '<div id="%1$s" class="widget mb-4 %2$s">',
'after_widget' => '</div>',
'before_title' => '<h3 class="sidebar-heading h6 text-uppercase fw-semibold">',
'after_title' => '</h3>',
) );
}
add_action( 'widgets_init', 'wc_bootstrap_register_sidebars' );
/**
* Set the number of product columns in the shop loop.
*
* @return int Number of columns.
* @since 0.1.0
*/
function wc_bootstrap_loop_columns(): int {
return 3;
}
add_filter( 'loop_shop_columns', 'wc_bootstrap_loop_columns' );
/**
* Remove WooCommerce's default sidebar hook.
*
* The child theme's archive-product.php renders the sidebar inline within the
* Bootstrap grid layout, so the default woocommerce_sidebar hook must not render
* a second sidebar outside the layout.
*
* @since 0.1.0
*/
function wc_bootstrap_remove_default_sidebar(): void {
remove_action( 'woocommerce_sidebar', 'woocommerce_get_sidebar', 10 );
}
add_action( 'init', 'wc_bootstrap_remove_default_sidebar' );
/**
* Replace WooCommerce's content wrapper with a no-op.
*
* The parent theme's page shell already wraps content in a .container,
* so WooCommerce's default wrapper (another .container + #primary + <main>)
* creates a double-nested container that constrains width. Remove it and
* let the parent theme handle the outer layout.
*
* @since 0.1.0
*/
function wc_bootstrap_remove_content_wrappers(): void {
remove_action( 'woocommerce_before_main_content', 'woocommerce_output_content_wrapper', 10 );
remove_action( 'woocommerce_after_main_content', 'woocommerce_output_content_wrapper_end', 10 );
}
add_action( 'init', 'wc_bootstrap_remove_content_wrappers' );
/**
* Prevent the parent theme from rendering WooCommerce pages.
*
* The parent theme's TemplateController hooks template_redirect at priority 10
* and renders its own templates for all requests (then exits). For WooCommerce
* pages we need to render our own Bootstrap layouts instead.
* Returning false tells the parent theme to skip rendering for this request.
*
* @param bool $should_render Whether the parent theme should render.
* @return bool
*
* @since 0.1.0
*/
function wc_bootstrap_skip_parent_template( bool $should_render ): bool {
if ( is_post_type_archive( 'product' ) || is_page( wc_get_page_id( 'shop' ) ) ) {
return false;
}
if ( function_exists( 'is_product_taxonomy' ) && is_product_taxonomy() ) {
return false;
}
if ( function_exists( 'is_product' ) && is_product() ) {
return false;
}
return $should_render;
}
add_filter( 'wp_bootstrap_should_render_template', 'wc_bootstrap_skip_parent_template' );
/**
* Disable WooCommerce's block template compatibility layer.
*
* The parent theme has theme.json which makes wp_is_block_theme() return true.
* WooCommerce detects this and removes classic template hooks (title, price,
* add-to-cart, etc.) from single product and archive pages, expecting blocks
* to handle rendering instead. Since we render via classic hooks + Twig, we
* need the hooks to stay registered.
*
* @param bool $disabled Whether the compatibility layer is disabled.
* @return bool
*
* @since 0.1.0
*/
function wc_bootstrap_disable_block_compatibility( bool $disabled ): bool {
return true;
}
add_filter( 'woocommerce_disable_compatibility_layer', 'wc_bootstrap_disable_block_compatibility' );
/**
* Render product archive pages with Bootstrap 5 layout.
*
* Since the parent theme's TemplateController is blocked for product archives
* (via wp_bootstrap_should_render_template filter), we render the page ourselves
* at priority 11 using the parent theme's TwigService and page shell.
*
* The archive-product.php file provides the Bootstrap layout (sidebar + product
* grid) and is captured via output buffering, then injected into the parent
* theme's page template — the same pattern as wc_bootstrap_render_page().
*
* @since 0.1.0
*/
function wc_bootstrap_render_product_archive(): void {
$is_shop = is_post_type_archive( 'product' ) || is_page( wc_get_page_id( 'shop' ) );
$is_tax = function_exists( 'is_product_taxonomy' ) && is_product_taxonomy();
if ( ! $is_shop && ! $is_tax ) {
return;
}
if ( ! class_exists( '\WPBootstrap\Twig\TwigService' )
|| ! class_exists( '\WPBootstrap\Template\ContextBuilder' ) ) {
return;
}
ob_start();
include get_stylesheet_directory() . '/woocommerce/archive-product.php';
$content = ob_get_clean();
wc_bootstrap_render_in_page_shell( $content );
exit;
}
add_action( 'template_redirect', 'wc_bootstrap_render_product_archive', 11 );
/**
* Render single product pages with Bootstrap 5 layout.
*
* Since the parent theme's TemplateController is blocked for single products
* (via wp_bootstrap_should_render_template filter), we render the page ourselves
* at priority 11 using the parent theme's TwigService and page shell.
*
* The single-product.php file fires WooCommerce hooks and is captured via output
* buffering, then injected into the parent theme's page template — the same
* pattern as wc_bootstrap_render_product_archive().
*
* @since 0.1.0
*/
function wc_bootstrap_render_single_product(): void {
if ( ! function_exists( 'is_product' ) || ! is_product() ) {
return;
}
if ( ! class_exists( '\WPBootstrap\Twig\TwigService' )
|| ! class_exists( '\WPBootstrap\Template\ContextBuilder' ) ) {
return;
}
ob_start();
include get_stylesheet_directory() . '/woocommerce/single-product.php';
$content = ob_get_clean();
wc_bootstrap_render_in_page_shell( $content );
exit;
}
add_action( 'template_redirect', 'wc_bootstrap_render_single_product', 11 );

View File

@@ -79,7 +79,16 @@ class TemplateOverride {
try { try {
$twig = TwigService::getInstance(); $twig = TwigService::getInstance();
echo $twig->render( $twigTemplate, $args ); $context = $args;
// Inject the global $product into the Twig context.
// WooCommerce PHP templates access it via `global $product;` but Twig
// templates have isolated variable scopes and need it passed explicitly.
if ( ! isset( $context['product'] ) && ! empty( $GLOBALS['product'] ) ) {
$context['product'] = $GLOBALS['product'];
}
echo $twig->render( $twigTemplate, $context );
// Buffer the upcoming PHP include so we can discard it. // Buffer the upcoming PHP include so we can discard it.
ob_start(); ob_start();

View File

@@ -37,6 +37,9 @@ class WooCommerceExtension extends AbstractExtension {
new TwigFilter( 'esc_attr', 'esc_attr', [ 'is_safe' => [ 'html' ] ] ), new TwigFilter( 'esc_attr', 'esc_attr', [ 'is_safe' => [ 'html' ] ] ),
new TwigFilter( 'esc_url', 'esc_url', [ 'is_safe' => [ 'html' ] ] ), new TwigFilter( 'esc_url', 'esc_url', [ 'is_safe' => [ 'html' ] ] ),
// Slug/sanitize filters.
new TwigFilter( 'sanitize_title', 'sanitize_title' ),
// Text processing filters. // Text processing filters.
new TwigFilter( 'wpautop', 'wpautop', [ 'is_safe' => [ 'html' ] ] ), new TwigFilter( 'wpautop', 'wpautop', [ 'is_safe' => [ 'html' ] ] ),
new TwigFilter( 'wp_kses_post', 'wp_kses_post', [ 'is_safe' => [ 'html' ] ] ), new TwigFilter( 'wp_kses_post', 'wp_kses_post', [ 'is_safe' => [ 'html' ] ] ),
@@ -107,6 +110,10 @@ class WooCommerceExtension extends AbstractExtension {
// Dynamic function calls. // Dynamic function calls.
new TwigFunction( 'call_user_func', [ $this, 'callUserFunc' ], [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'call_user_func', [ $this, 'callUserFunc' ], [ 'is_safe' => [ 'html' ] ] ),
new TwigFunction( 'fn', [ $this, 'callFunction' ] ), new TwigFunction( 'fn', [ $this, 'callFunction' ] ),
// Product loop helpers (set global $product for WC hooks in Twig loops).
new TwigFunction( 'wc_setup_product_data', [ $this, 'setupProductData' ] ),
new TwigFunction( 'wp_reset_postdata', 'wp_reset_postdata' ),
]; ];
} }
@@ -235,24 +242,67 @@ class WooCommerceExtension extends AbstractExtension {
} }
/** /**
* Call a PHP function by name and return its result. * Allowlist of PHP functions that can be called via fn() in Twig templates.
*
* Prevents arbitrary function execution (e.g., exec, system) if template
* context were ever compromised. Only functions actually used in templates
* are permitted.
*/
private const ALLOWED_FUNCTIONS = [
'WC',
'_n',
'get_pagenum_link',
'wc_review_ratings_enabled',
'wc_get_product_category_list',
'wc_get_product_tag_list',
'woocommerce_page_title',
'wc_get_customer_available_downloads',
];
/**
* Call a whitelisted PHP function by name and return its result.
* *
* Enables `fn('WC')` in templates to access the WooCommerce singleton * Enables `fn('WC')` in templates to access the WooCommerce singleton
* and chain method calls via Twig's property accessor. * and chain method calls via Twig's property accessor.
* *
* Only functions in the ALLOWED_FUNCTIONS list can be called. This prevents
* arbitrary code execution if template context were ever compromised.
*
* @param string $name Function name. * @param string $name Function name.
* @param mixed ...$args Arguments. * @param mixed ...$args Arguments.
* @return mixed Function return value. * @return mixed Function return value.
* *
* @throws \RuntimeException If function does not exist. * @throws \RuntimeException If function is not allowed or does not exist.
*/ */
public function callFunction( string $name, ...$args ): mixed { public function callFunction( string $name, ...$args ): mixed {
if ( ! in_array( $name, self::ALLOWED_FUNCTIONS, true ) ) {
throw new \RuntimeException( "Function {$name} is not allowed. Add it to ALLOWED_FUNCTIONS." );
}
if ( ! function_exists( $name ) ) { if ( ! function_exists( $name ) ) {
throw new \RuntimeException( "Function {$name} does not exist." ); throw new \RuntimeException( "Function {$name} does not exist." );
} }
return $name( ...$args ); return $name( ...$args );
} }
/**
* Set up global product data for WC hook-based rendering in Twig loops.
*
* WooCommerce hooks (woocommerce_before_shop_loop_item, etc.) read from
* the global $product. When iterating products in Twig (related, upsells),
* the global must be updated before rendering each product card.
*
* @param \WC_Product $product Product object.
* @return string Empty string (Twig requires a return value).
*/
public function setupProductData( \WC_Product $product ): string {
$GLOBALS['product'] = $product;
$post = get_post( $product->get_id() );
if ( $post ) {
setup_postdata( $GLOBALS['post'] = $post );
}
return '';
}
/** /**
* Capture wc_print_notices() output. * Capture wc_print_notices() output.
* *

21
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>inc</directory>
</include>
</source>
</phpunit>

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

View File

@@ -7,7 +7,7 @@ Description: A Bootstrap 5 child theme for WP Bootstrap that overrides all WooCo
Requires at least: 6.7 Requires at least: 6.7
Tested up to: 6.7 Tested up to: 6.7
Requires PHP: 8.3 Requires PHP: 8.3
Version: 0.0.1 Version: 0.1.6
License: GNU General Public License v2 or later License: GNU General Public License v2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html License URI: http://www.gnu.org/licenses/gpl-2.0.html
Template: wp-bootstrap Template: wp-bootstrap

View File

@@ -16,7 +16,7 @@
# @since 0.1.0 # @since 0.1.0
#} #}
{% extends "base.html.twig" %} {% extends "wc-base.html.twig" %}
{% block breadcrumbs %} {% block breadcrumbs %}
{{ do_action('woocommerce_before_main_content') }} {{ do_action('woocommerce_before_main_content') }}

View File

@@ -24,6 +24,6 @@
{% endif %} {% endif %}
<div class="text"> <div class="text">
{{ term_description()|wptexturize|wpautop|do_shortcode }} {{ term_description()|wptexturize|wpautop|do_shortcode|raw }}
</div> </div>
</div> </div>

View File

@@ -24,7 +24,7 @@
{% if available_methods is defined and available_methods|length > 0 %} {% if available_methods is defined and available_methods|length > 0 %}
<ul id="shipping_method_{{ index|default(0) }}" class="list-unstyled mb-2"> <ul id="shipping_method_{{ index|default(0) }}" class="list-unstyled mb-2">
{% for method_id, method in available_methods %} {% for method_id, method in available_methods %}
<li class="form-check"> <li{% if available_methods|length > 1 %} class="form-check"{% endif %}>
{% if available_methods|length > 1 %} {% if available_methods|length > 1 %}
<input type="radio" <input type="radio"
name="shipping_method[{{ index|default(0) }}]" name="shipping_method[{{ index|default(0) }}]"
@@ -40,7 +40,7 @@
class="shipping_method" class="shipping_method"
data-index="{{ index|default(0) }}" /> data-index="{{ index|default(0) }}" />
{% endif %} {% endif %}
<label class="form-check-label" for="shipping_method_{{ index|default(0) }}_{{ method_id|esc_attr }}"> <label class="{{ available_methods|length > 1 ? 'form-check-label' : '' }}" for="shipping_method_{{ index|default(0) }}_{{ method_id|esc_attr }}">
{{ method.get_label()|raw }} {{ method.get_label()|raw }}
</label> </label>
{{ do_action('woocommerce_after_shipping_rate', method, index|default(0)) }} {{ do_action('woocommerce_after_shipping_rate', method, index|default(0)) }}

View File

@@ -44,7 +44,7 @@
{# Shipping #} {# Shipping #}
{{ do_action('woocommerce_cart_totals_before_shipping') }} {{ do_action('woocommerce_cart_totals_before_shipping') }}
{% if wc_shipping_enabled() is defined and wc_shipping_enabled() %} {% if wc_shipping_enabled() %}
<li class="list-group-item cart-shipping"> <li class="list-group-item cart-shipping">
{{ do_action('woocommerce_cart_totals_shipping') }} {{ do_action('woocommerce_cart_totals_shipping') }}
</li> </li>

View File

@@ -36,7 +36,7 @@
<div class="table-responsive"> <div class="table-responsive">
<table class="table align-middle shop_table shop_table_responsive cart woocommerce-cart-form__contents"> <table class="table align-middle shop_table shop_table_responsive cart woocommerce-cart-form__contents">
<thead class="table-light"> <thead>
<tr> <tr>
<th class="product-thumbnail" scope="col" style="width: 80px;"> <th class="product-thumbnail" scope="col" style="width: 80px;">
<span class="visually-hidden">{{ __('Thumbnail') }}</span> <span class="visually-hidden">{{ __('Thumbnail') }}</span>

View File

@@ -18,7 +18,7 @@
<section class="cross-sells mt-5"> <section class="cross-sells mt-5">
{% set heading = heading|default(__('You may be interested in&hellip;')) %} {% set heading = heading|default(__('You may be interested in&hellip;')) %}
{% if heading %} {% if heading %}
<h2 class="h4 mb-4">{{ heading }}</h2> <h2 class="h4 mb-4">{{ heading|raw }}</h2>
{% endif %} {% endif %}
{{ woocommerce_product_loop_start() }} {{ woocommerce_product_loop_start() }}

View File

@@ -26,7 +26,7 @@
<li class="woocommerce-mini-cart-item d-flex gap-3 py-2 border-bottom {{ item.css_class|default('') }}"> <li class="woocommerce-mini-cart-item d-flex gap-3 py-2 border-bottom {{ item.css_class|default('') }}">
{# Remove link #} {# Remove link #}
<a href="{{ item.remove_url|esc_url }}" <a href="{{ item.remove_url|esc_url }}"
class="remove remove_from_cart_button text-danger" class="btn btn-sm btn-outline-danger remove remove_from_cart_button"
aria-label="{{ __('Remove this item') }}" aria-label="{{ __('Remove this item') }}"
data-product_id="{{ item.product_id }}" data-product_id="{{ item.product_id }}"
data-cart_item_key="{{ item.key }}"> data-cart_item_key="{{ item.key }}">

View File

@@ -9,7 +9,7 @@
# @since 0.1.0 # @since 0.1.0
#} #}
{% if is_user_logged_in() is defined and not is_user_logged_in() %} {% if not is_user_logged_in() %}
<div class="woocommerce-form-login-toggle mb-4"> <div class="woocommerce-form-login-toggle mb-4">
<div class="alert alert-info d-flex align-items-center" role="status"> <div class="alert alert-info d-flex align-items-center" role="status">
<i class="bi bi-person me-2" aria-hidden="true"></i> <i class="bi bi-person me-2" aria-hidden="true"></i>

View File

@@ -19,7 +19,7 @@
<div class="card shadow-sm woocommerce-checkout-review-order-table"> <div class="card shadow-sm woocommerce-checkout-review-order-table">
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-sm mb-0 shop_table woocommerce-checkout-review-order-table"> <table class="table table-sm mb-0 shop_table woocommerce-checkout-review-order-table">
<thead class="table-light"> <thead>
<tr> <tr>
<th class="product-name" scope="col">{{ __('Product') }}</th> <th class="product-name" scope="col">{{ __('Product') }}</th>
<th class="product-total text-end" scope="col">{{ __('Subtotal') }}</th> <th class="product-total text-end" scope="col">{{ __('Subtotal') }}</th>
@@ -62,7 +62,7 @@
{{ do_action('woocommerce_review_order_before_shipping') }} {{ do_action('woocommerce_review_order_before_shipping') }}
{% if wc_shipping_enabled() is defined and wc_shipping_enabled() %} {% if wc_shipping_enabled() %}
{{ do_action('woocommerce_review_order_shipping') }} {{ do_action('woocommerce_review_order_shipping') }}
{% endif %} {% endif %}

View File

@@ -9,7 +9,7 @@
{{ do_action('woocommerce_checkout_before_terms_and_conditions') }} {{ do_action('woocommerce_checkout_before_terms_and_conditions') }}
{% if wc_terms_and_conditions_checkbox_enabled() is defined and wc_terms_and_conditions_checkbox_enabled() %} {% if wc_terms_and_conditions_checkbox_enabled() %}
<div class="woocommerce-terms-and-conditions-wrapper mb-3"> <div class="woocommerce-terms-and-conditions-wrapper mb-3">
{{ do_action('woocommerce_checkout_terms_and_conditions') }} {{ do_action('woocommerce_checkout_terms_and_conditions') }}

View File

@@ -27,7 +27,7 @@
</a> </a>
</p> </p>
{% else %} {% else %}
<div class="alert alert-success mb-4" role="alert"> <div class="alert alert-success d-flex align-items-center mb-4" role="alert">
<i class="bi bi-check-circle me-2" aria-hidden="true"></i> <i class="bi bi-check-circle me-2" aria-hidden="true"></i>
{% include 'checkout/order-received.html.twig' with { order: order } %} {% include 'checkout/order-received.html.twig' with { order: order } %}
</div> </div>
@@ -68,7 +68,7 @@
{{ do_action('woocommerce_thankyou_' ~ order.get_payment_method(), order.get_id()) }} {{ do_action('woocommerce_thankyou_' ~ order.get_payment_method(), order.get_id()) }}
{{ do_action('woocommerce_thankyou', order.get_id()) }} {{ do_action('woocommerce_thankyou', order.get_id()) }}
{% else %} {% else %}
<div class="alert alert-success mb-4" role="alert"> <div class="alert alert-success d-flex align-items-center mb-4" role="alert">
<i class="bi bi-check-circle me-2" aria-hidden="true"></i> <i class="bi bi-check-circle me-2" aria-hidden="true"></i>
{% include 'checkout/order-received.html.twig' %} {% include 'checkout/order-received.html.twig' %}
</div> </div>

View File

@@ -16,7 +16,7 @@
<ul class="pagination justify-content-center"> <ul class="pagination justify-content-center">
{# Previous button #} {# Previous button #}
<li class="page-item{% if current_page <= 1 %} disabled{% endif %}"> <li class="page-item{% if current_page <= 1 %} disabled{% endif %}">
<a class="page-link" href="?page={{ current_page - 1 }}" aria-label="{{ __('Previous') }}"> <a class="page-link" href="{{ fn('get_pagenum_link', current_page > 1 ? current_page - 1 : 1)|esc_url }}" aria-label="{{ __('Previous') }}">
<span aria-hidden="true">&laquo;</span> <span aria-hidden="true">&laquo;</span>
</a> </a>
</li> </li>
@@ -29,7 +29,7 @@
</li> </li>
{% elseif i == 1 or i == max_pages or (i >= current_page - 2 and i <= current_page + 2) %} {% elseif i == 1 or i == max_pages or (i >= current_page - 2 and i <= current_page + 2) %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page={{ i }}">{{ i }}</a> <a class="page-link" href="{{ fn('get_pagenum_link', i)|esc_url }}">{{ i }}</a>
</li> </li>
{% elseif i == current_page - 3 or i == current_page + 3 %} {% elseif i == current_page - 3 or i == current_page + 3 %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -40,7 +40,7 @@
{# Next button #} {# Next button #}
<li class="page-item{% if current_page >= max_pages %} disabled{% endif %}"> <li class="page-item{% if current_page >= max_pages %} disabled{% endif %}">
<a class="page-link" href="?page={{ current_page + 1 }}" aria-label="{{ __('Next') }}"> <a class="page-link" href="{{ fn('get_pagenum_link', current_page + 1)|esc_url }}" aria-label="{{ __('Next') }}">
<span aria-hidden="true">&raquo;</span> <span aria-hidden="true">&raquo;</span>
</a> </a>
</li> </li>

View File

@@ -2,17 +2,17 @@
# Product Content in Loop (Bootstrap 5 Override) # Product Content in Loop (Bootstrap 5 Override)
# #
# Renders a single product card within the shop loop grid. # Renders a single product card within the shop loop grid.
# Uses Bootstrap 5 card component with stretched-link. # Uses Bootstrap 5 card component with WooCommerce hook output.
# #
# Expected context: # Rendered via the content-product.php bridge file (not TemplateOverride)
# product - WC_Product object with: # because wc_get_template_part() does not fire the template_part hooks.
# .name - Product name #
# .permalink - Product URL # Hook output structure:
# .image - Product thumbnail HTML # woocommerce_before_shop_loop_item → <a> link open
# .price_html - Formatted price HTML # woocommerce_before_shop_loop_item_title → sale badge, product image
# .rating_html - Star rating HTML # woocommerce_shop_loop_item_title → <h2> product title
# .is_on_sale - Whether product is on sale # woocommerce_after_shop_loop_item_title → star rating, price
# .add_to_cart - Add-to-cart button context # woocommerce_after_shop_loop_item → </a> link close, add-to-cart button
# #
# WooCommerce PHP equivalent: content-product.php # WooCommerce PHP equivalent: content-product.php
# #
@@ -21,7 +21,7 @@
#} #}
<div class="col"> <div class="col">
<article class="card h-100 shadow-sm product"> <article class="card h-100 shadow-sm overflow-hidden product">
{{ do_action('woocommerce_before_shop_loop_item') }} {{ do_action('woocommerce_before_shop_loop_item') }}
{# Product image with sale badge overlay #} {# Product image with sale badge overlay #}

View File

@@ -0,0 +1,56 @@
{#
# Single Product Content (Bootstrap 5 Override)
#
# Renders the single product page with a Bootstrap 5 two-column grid:
# Left column (col-lg-6): Product images (sale flash + gallery)
# Right column (col-lg-6): Product summary (title, rating, price, excerpt,
# add-to-cart, meta, sharing)
# Full-width rows below: Tabs, upsells, related products
#
# All individual components are rendered via WooCommerce action hooks,
# which trigger the Bootstrap 5 sub-templates through TemplateOverride.
#
# Rendered via the content-single-product.php bridge file (not TemplateOverride)
# because wc_get_template_part() does not fire the template_part hooks.
#
# Hook output structure:
# woocommerce_before_single_product_summary → sale flash (10), product images (20)
# woocommerce_single_product_summary → title (5), rating (10), price (10),
# excerpt (20), add-to-cart (30),
# meta (40), sharing (50)
# woocommerce_after_single_product_summary → tabs (10), upsells (15), related (20)
#
# Context (from bridge file):
# product - WC_Product object
# product_id - Product post ID
# product_class - Space-separated CSS class string from wc_get_product_class()
#
# WooCommerce PHP equivalent: content-single-product.php
#
# @package WcBootstrap
# @since 0.1.0
#}
<div id="product-{{ product_id }}" class="{{ product_class }}">
{# Two-column layout: images left, summary right #}
<div class="row gx-4 gx-lg-5 mb-5">
{# Left column: Sale flash + Product images #}
<div class="col-lg-6 position-relative">
{{ do_action('woocommerce_before_single_product_summary') }}
</div>
{# Right column: Product summary #}
<div class="col-lg-6">
<div class="summary entry-summary">
{{ do_action('woocommerce_single_product_summary') }}
</div>
</div>
</div>
{# Full-width sections: Tabs, Upsells, Related Products #}
{{ do_action('woocommerce_after_single_product_summary') }}
</div>
{{ do_action('woocommerce_after_single_product') }}

View File

@@ -2,7 +2,7 @@
# Shop Breadcrumb (Bootstrap 5 Override) # Shop Breadcrumb (Bootstrap 5 Override)
# #
# Replaces WooCommerce's breadcrumb with Bootstrap 5 breadcrumb component. # Replaces WooCommerce's breadcrumb with Bootstrap 5 breadcrumb component.
# Skipped when parent theme is wrapping (base.html.twig handles breadcrumbs). # Skipped when parent theme is wrapping (wc-base.html.twig handles breadcrumbs).
# #
# Expected context (from WooCommerce woocommerce_breadcrumb()): # Expected context (from WooCommerce woocommerce_breadcrumb()):
# breadcrumb - Array of [label, url] tuples # breadcrumb - Array of [label, url] tuples

View File

@@ -16,7 +16,7 @@
#} #}
{% if not is_user_logged_in() %} {% if not is_user_logged_in() %}
<form class="woocommerce-form woocommerce-form-login login{% if hidden %} d-none{% endif %}" method="post"> <form class="woocommerce-form woocommerce-form-login login{% if hidden is defined and hidden %} d-none{% endif %}" method="post">
{{ do_action('woocommerce_login_form_start') }} {{ do_action('woocommerce_login_form_start') }}

View File

@@ -3,7 +3,7 @@
# #
# Replaces WooCommerce's theme-specific wrapper divs with Bootstrap 5 layout. # Replaces WooCommerce's theme-specific wrapper divs with Bootstrap 5 layout.
# When the parent theme wraps the page (_theme_wrapped), this outputs nothing # When the parent theme wraps the page (_theme_wrapped), this outputs nothing
# since base.html.twig already provides the container. # since wc-base.html.twig already provides the container.
# #
# WooCommerce PHP equivalent: global/wrapper-start.php # WooCommerce PHP equivalent: global/wrapper-start.php
# #

View File

@@ -7,7 +7,7 @@
# @since 0.1.0 # @since 0.1.0
#} #}
{% extends "base.html.twig" %} {% extends "wc-base.html.twig" %}
{% block content %} {% block content %}
<div class="mb-4"> <div class="mb-4">

View File

@@ -8,7 +8,7 @@
# @since 0.1.0 # @since 0.1.0
#} #}
{% extends "base.html.twig" %} {% extends "wc-base.html.twig" %}
{% block content %} {% block content %}
<div class="mb-4"> <div class="mb-4">

View File

@@ -8,7 +8,7 @@
# @since 0.1.0 # @since 0.1.0
#} #}
{% extends "base.html.twig" %} {% extends "wc-base.html.twig" %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">

View File

@@ -7,7 +7,7 @@
# @since 0.1.0 # @since 0.1.0
#} #}
{% extends "base.html.twig" %} {% extends "wc-base.html.twig" %}
{% block content %} {% block content %}
<div class="mb-4"> <div class="mb-4">

View File

@@ -9,7 +9,7 @@
# @since 0.1.0 # @since 0.1.0
#} #}
{% extends "base.html.twig" %} {% extends "wc-base.html.twig" %}
{% block content %} {% block content %}
<article> <article>

View File

@@ -2,11 +2,8 @@
# Product Archive Header (Bootstrap 5 Override) # Product Archive Header (Bootstrap 5 Override)
# #
# Renders the archive page title and optional description. # Renders the archive page title and optional description.
# # Calls woocommerce_page_title() directly (matching the PHP template)
# Expected context: # because wc_get_template('loop/header.php') passes no context args.
# show_page_title - Whether to display the title (boolean, filtered)
# page_title - Archive page title string
# archive_description - Optional archive description HTML
# #
# WooCommerce PHP equivalent: loop/header.php # WooCommerce PHP equivalent: loop/header.php
# #
@@ -15,9 +12,9 @@
#} #}
<header class="woocommerce-products-header mb-4"> <header class="woocommerce-products-header mb-4">
{% if show_page_title is not defined or show_page_title %} {% if apply_filters('woocommerce_show_page_title', true) %}
<h1 class="woocommerce-products-header__title page-title mb-2"> <h1 class="woocommerce-products-header__title page-title mb-2">
{{ page_title|default('')|esc_html }} {{ fn('woocommerce_page_title', false)|esc_html }}
</h1> </h1>
{% endif %} {% endif %}

View File

@@ -14,4 +14,4 @@
{% set cols = columns|default(3) %} {% set cols = columns|default(3) %}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-{{ cols < 3 ? cols : 3 }} row-cols-lg-{{ cols }} g-4 products"> <div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-{{ cols }} g-4 products">

View File

@@ -27,7 +27,7 @@
{% if total <= per_page|default(total) or total == 0 %} {% if total <= per_page|default(total) or total == 0 %}
{{ _n('Showing the single result', 'Showing all %d results', total)|format(total) }} {{ _n('Showing the single result', 'Showing all %d results', total)|format(total) }}
{% else %} {% else %}
{{ __('Showing %1$d&ndash;%2$d of %3$d results')|format(first, last, total) }} {{ __('Showing %1$d&ndash;%2$d of %3$d results')|format(first, last, total)|raw }}
{% endif %} {% endif %}
{% endif %} {% endif %}
</p> </p>

View File

@@ -1,7 +1,8 @@
{# {#
# My Account Dashboard (Bootstrap 5 Override) # My Account Dashboard (Bootstrap 5 Override)
# #
# Shows the welcome screen on the account dashboard. # Shows a card-based dashboard with welcome greeting and
# quick action links to each account section.
# #
# Expected context: # Expected context:
# current_user - WP_User object # current_user - WP_User object
@@ -12,29 +13,53 @@
# @since 0.1.0 # @since 0.1.0
#} #}
<p> {% set quick_actions = [
{{ __('Hello %1$s (not %1$s? <a href="%2$s">Log out</a>)')|format( { endpoint: 'orders', icon: 'bi-bag', label: __('Orders'), desc: __('View and track your orders') },
'<strong>' ~ current_user.display_name|esc_html ~ '</strong>', { endpoint: 'edit-address', icon: 'bi-geo-alt', label: __('Addresses'), desc: __('Manage billing & shipping') },
wc_logout_url()|esc_url { endpoint: 'edit-account', icon: 'bi-person-gear', label: __('Account details'), desc: __('Update name, email & password') },
)|wp_kses_post }} { endpoint: 'downloads', icon: 'bi-download', label: __('Downloads'), desc: __('Access purchased files') },
</p> { endpoint: 'payment-methods', icon: 'bi-credit-card', label: __('Payment methods'), desc: __('Manage saved cards') },
] %}
<p> <div class="card shadow-sm mb-4">
{% if wc_shipping_enabled() %} <div class="card-body d-flex align-items-center gap-3">
{{ __('From your account dashboard you can view your <a href="%1$s">recent orders</a>, manage your <a href="%2$s">shipping and billing addresses</a>, and <a href="%3$s">edit your password and account details</a>.')|format( <div class="flex-shrink-0">
wc_get_endpoint_url('orders')|esc_url, {{ get_avatar(current_user.ID, 64)|raw }}
wc_get_endpoint_url('edit-address')|esc_url, </div>
wc_get_endpoint_url('edit-account')|esc_url <div class="flex-grow-1">
)|wp_kses_post }} <h2 class="h5 mb-1">
{% else %} {{ __('Hello, %s!')|format(current_user.display_name|esc_html) }}
{{ __('From your account dashboard you can view your <a href="%1$s">recent orders</a>, manage your <a href="%2$s">billing address</a>, and <a href="%3$s">edit your password and account details</a>.')|format( </h2>
wc_get_endpoint_url('orders')|esc_url, <p class="text-body-secondary mb-0">
wc_get_endpoint_url('edit-address')|esc_url, {{ __('Not %s?')|format(current_user.display_name|esc_html) }}
wc_get_endpoint_url('edit-account')|esc_url <a href="{{ wc_logout_url()|esc_url }}">{{ __('Log out') }}</a>
)|wp_kses_post }} </p>
{% endif %} </div>
</p> </div>
</div>
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-3 g-3 mb-4">
{% for action in quick_actions %}
<div class="col">
<a href="{{ wc_get_endpoint_url(action.endpoint)|esc_url }}"
class="card h-100 text-decoration-none shadow-sm">
<div class="card-body d-flex align-items-start gap-3">
<div class="flex-shrink-0">
<span class="d-inline-flex align-items-center justify-content-center
bg-primary-subtle text-primary rounded-3"
style="width: 3rem; height: 3rem;"
aria-hidden="true">
<i class="bi {{ action.icon }} fs-4"></i>
</span>
</div>
<div>
<h3 class="h6 mb-1 text-body">{{ action.label }}</h3>
<p class="text-body-secondary small mb-0">{{ action.desc }}</p>
</div>
</div>
</a>
</div>
{% endfor %}
</div>
{{ do_action('woocommerce_account_dashboard') }} {{ do_action('woocommerce_account_dashboard') }}
{{ do_action('woocommerce_before_my_account') }}
{{ do_action('woocommerce_after_my_account') }}

View File

@@ -9,6 +9,7 @@
# @since 0.1.0 # @since 0.1.0
#} #}
{# Match PHP template: use WC()->customer->get_downloadable_products() #}
{% set downloads = fn('WC').customer.get_downloadable_products() %} {% set downloads = fn('WC').customer.get_downloadable_products() %}
{% set has_downloads = downloads is not empty %} {% set has_downloads = downloads is not empty %}

View File

@@ -1,7 +1,8 @@
{# {#
# Edit Account Form (Bootstrap 5 Override) # Edit Account Form (Bootstrap 5 Override)
# #
# Account details editing form with Bootstrap form styling. # Account details editing form with card-based sections
# for personal information and password change.
# #
# Expected context: # Expected context:
# user - WP_User object # user - WP_User object
@@ -18,6 +19,14 @@
{{ do_action('woocommerce_edit_account_form_start') }} {{ do_action('woocommerce_edit_account_form_start') }}
<div class="card shadow-sm mb-4">
<div class="card-header">
<h2 class="h5 mb-0">
<i class="bi bi-person me-1" aria-hidden="true"></i>
{{ __('Personal information') }}
</h2>
</div>
<div class="card-body">
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-sm-6"> <div class="col-sm-6">
<label for="account_first_name" class="form-label"> <label for="account_first_name" class="form-label">
@@ -67,7 +76,7 @@
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-0">
<label for="account_email" class="form-label"> <label for="account_email" class="form-label">
{{ __('Email address') }}&nbsp;<span class="text-danger" aria-hidden="true">*</span> {{ __('Email address') }}&nbsp;<span class="text-danger" aria-hidden="true">*</span>
<span class="visually-hidden">{{ __('Required') }}</span> <span class="visually-hidden">{{ __('Required') }}</span>
@@ -83,10 +92,17 @@
</div> </div>
{{ do_action('woocommerce_edit_account_form_fields') }} {{ do_action('woocommerce_edit_account_form_fields') }}
</div>
</div>
<fieldset class="mb-3"> <div class="card shadow-sm mb-4">
<legend class="h5 border-bottom pb-2 mb-3">{{ __('Password change') }}</legend> <div class="card-header">
<h2 class="h5 mb-0">
<i class="bi bi-shield-lock me-1" aria-hidden="true"></i>
{{ __('Password change') }}
</h2>
</div>
<div class="card-body">
<div class="mb-3"> <div class="mb-3">
<label for="password_current" class="form-label"> <label for="password_current" class="form-label">
{{ __('Current password (leave blank to leave unchanged)') }} {{ __('Current password (leave blank to leave unchanged)') }}
@@ -109,7 +125,7 @@
autocomplete="off" /> autocomplete="off" />
</div> </div>
<div class="mb-3"> <div class="mb-0">
<label for="password_2" class="form-label"> <label for="password_2" class="form-label">
{{ __('Confirm new password') }} {{ __('Confirm new password') }}
</label> </label>
@@ -119,7 +135,8 @@
id="password_2" id="password_2"
autocomplete="off" /> autocomplete="off" />
</div> </div>
</fieldset> </div>
</div>
{{ do_action('woocommerce_edit_account_form') }} {{ do_action('woocommerce_edit_account_form') }}

View File

@@ -1,7 +1,8 @@
{# {#
# Edit Address Form (Bootstrap 5 Override) # Edit Address Form (Bootstrap 5 Override)
# #
# Address editing form with Bootstrap form styling. # Address editing form wrapped in a Bootstrap card with
# contextual icon for billing/shipping.
# #
# Expected context: # Expected context:
# load_address - Address type ('billing' or 'shipping'), or empty # load_address - Address type ('billing' or 'shipping'), or empty
@@ -14,15 +15,22 @@
#} #}
{% set page_title = load_address == 'billing' ? __('Billing address') : __('Shipping address') %} {% set page_title = load_address == 'billing' ? __('Billing address') : __('Shipping address') %}
{% set address_icon = load_address == 'billing' ? 'bi-receipt' : 'bi-truck' %}
{{ do_action('woocommerce_before_edit_account_address_form') }} {{ do_action('woocommerce_before_edit_account_address_form') }}
{% if not load_address %} {% if not load_address %}
{% include 'myaccount/my-address.html.twig' %} {% include 'myaccount/my-address.html.twig' %}
{% else %} {% else %}
<div class="card shadow-sm">
<div class="card-header">
<h2 class="h5 mb-0">
<i class="bi {{ address_icon }} me-1" aria-hidden="true"></i>
{{ apply_filters('woocommerce_my_account_edit_address_title', page_title, load_address) }}
</h2>
</div>
<div class="card-body">
<form method="post" novalidate> <form method="post" novalidate>
<h2 class="h4 mb-4">{{ apply_filters('woocommerce_my_account_edit_address_title', page_title, load_address) }}</h2>
<div class="woocommerce-address-fields"> <div class="woocommerce-address-fields">
{{ do_action('woocommerce_before_edit_address_form_' ~ load_address) }} {{ do_action('woocommerce_before_edit_address_form_' ~ load_address) }}
@@ -44,6 +52,8 @@
</div> </div>
</div> </div>
</form> </form>
</div>
</div>
{% endif %} {% endif %}
{{ do_action('woocommerce_after_edit_account_address_form') }} {{ do_action('woocommerce_after_edit_account_address_form') }}

View File

@@ -1,7 +1,8 @@
{# {#
# Lost Password Form (Bootstrap 5 Override) # Lost Password Form (Bootstrap 5 Override)
# #
# Form to request a password reset email. # Form to request a password reset email, wrapped in a
# Bootstrap card for visual consistency.
# #
# WooCommerce PHP equivalent: myaccount/form-lost-password.php # WooCommerce PHP equivalent: myaccount/form-lost-password.php
# #
@@ -11,9 +12,17 @@
{{ do_action('woocommerce_before_lost_password_form') }} {{ do_action('woocommerce_before_lost_password_form') }}
<form method="post" class="woocommerce-ResetPassword lost_reset_password"> <div class="card shadow-sm">
<div class="card-header">
<h2 class="h5 mb-0">
<i class="bi bi-key me-1" aria-hidden="true"></i>
{{ __('Lost your password?') }}
</h2>
</div>
<div class="card-body">
<form method="post" class="woocommerce-ResetPassword lost_reset_password">
<p class="mb-3"> <p class="text-body-secondary mb-3">
{{ apply_filters('woocommerce_lost_password_message', __('Lost your password? Please enter your username or email address. You will receive a link to create a new password via email.')) }} {{ apply_filters('woocommerce_lost_password_message', __('Lost your password? Please enter your username or email address. You will receive a link to create a new password via email.')) }}
</p> </p>
@@ -42,6 +51,8 @@
{{ wp_nonce_field('lost_password', 'woocommerce-lost-password-nonce') }} {{ wp_nonce_field('lost_password', 'woocommerce-lost-password-nonce') }}
</form> </form>
</div>
</div>
{{ do_action('woocommerce_after_lost_password_form') }} {{ do_action('woocommerce_after_lost_password_form') }}

View File

@@ -1,7 +1,8 @@
{# {#
# Reset Password Form (Bootstrap 5 Override) # Reset Password Form (Bootstrap 5 Override)
# #
# Form to set a new password after clicking the reset link. # Form to set a new password after clicking the reset link,
# wrapped in a Bootstrap card for visual consistency.
# #
# Expected context: # Expected context:
# args - Array with 'key' and 'login' values # args - Array with 'key' and 'login' values
@@ -14,9 +15,17 @@
{{ do_action('woocommerce_before_reset_password_form') }} {{ do_action('woocommerce_before_reset_password_form') }}
<form method="post" class="woocommerce-ResetPassword lost_reset_password"> <div class="card shadow-sm">
<div class="card-header">
<h2 class="h5 mb-0">
<i class="bi bi-shield-lock me-1" aria-hidden="true"></i>
{{ __('Set new password') }}
</h2>
</div>
<div class="card-body">
<form method="post" class="woocommerce-ResetPassword lost_reset_password">
<p class="mb-3"> <p class="text-body-secondary mb-3">
{{ apply_filters('woocommerce_reset_password_message', __('Enter a new password below.')) }} {{ apply_filters('woocommerce_reset_password_message', __('Enter a new password below.')) }}
</p> </p>
@@ -63,6 +72,8 @@
{{ wp_nonce_field('reset_password', 'woocommerce-reset-password-nonce') }} {{ wp_nonce_field('reset_password', 'woocommerce-reset-password-nonce') }}
</form> </form>
</div>
</div>
{{ do_action('woocommerce_after_reset_password_form') }} {{ do_action('woocommerce_after_reset_password_form') }}

View File

@@ -16,8 +16,13 @@
{{ do_action('woocommerce_before_lost_password_confirmation_message') }} {{ do_action('woocommerce_before_lost_password_confirmation_message') }}
<p> <p class="text-body-secondary">
{{ apply_filters('woocommerce_lost_password_confirmation_message', __('A password reset email has been sent to the email address on file for your account, but may take several minutes to show up in your inbox. Please wait at least 10 minutes before attempting another reset.')) }} {{ apply_filters('woocommerce_lost_password_confirmation_message', __('A password reset email has been sent to the email address on file for your account, but may take several minutes to show up in your inbox. Please wait at least 10 minutes before attempting another reset.')) }}
</p> </p>
<a href="{{ wc_get_page_permalink('myaccount')|esc_url }}" class="btn btn-outline-primary">
<i class="bi bi-arrow-left me-1" aria-hidden="true"></i>
{{ __('Back to login') }}
</a>
{{ do_action('woocommerce_after_lost_password_confirmation_message') }} {{ do_action('woocommerce_after_lost_password_confirmation_message') }}

View File

@@ -13,11 +13,11 @@
{{ do_action('woocommerce_before_my_account') }} {{ do_action('woocommerce_before_my_account') }}
<div class="row g-4"> <div class="row g-4">
<div class="col-lg-3"> <div class="col-lg-auto">
{% include 'myaccount/navigation.html.twig' %} {% include 'myaccount/navigation.html.twig' %}
</div> </div>
<div class="col-lg-9"> <div class="col-lg">
<div class="woocommerce-MyAccount-content"> <div class="woocommerce-MyAccount-content">
{{ do_action('woocommerce_account_content') }} {{ do_action('woocommerce_account_content') }}
</div> </div>

View File

@@ -2,7 +2,8 @@
# My Account Navigation (Bootstrap 5 Override) # My Account Navigation (Bootstrap 5 Override)
# #
# Renders the sidebar navigation for the My Account area # Renders the sidebar navigation for the My Account area
# using Bootstrap list-group component. # using Bootstrap list-group component with icons.
# Responsive: offcanvas on mobile, sticky sidebar on desktop.
# #
# WooCommerce PHP equivalent: myaccount/navigation.php # WooCommerce PHP equivalent: myaccount/navigation.php
# #
@@ -10,14 +11,53 @@
# @since 0.1.0 # @since 0.1.0
#} #}
<nav class="woocommerce-MyAccount-navigation" aria-label="{{ __('Account navigation') }}"> {% set endpoint_icons = {
'dashboard': 'bi-speedometer2',
'orders': 'bi-bag',
'downloads': 'bi-download',
'edit-address': 'bi-geo-alt',
'payment-methods': 'bi-credit-card',
'edit-account': 'bi-person-gear',
'customer-logout': 'bi-box-arrow-right',
} %}
{{ do_action('woocommerce_before_account_navigation') }}
<button class="btn btn-outline-secondary w-100 mb-3 d-lg-none"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#accountNav"
aria-controls="accountNav">
<i class="bi bi-list me-1" aria-hidden="true"></i>
{{ __('Account menu') }}
</button>
<div class="offcanvas-lg offcanvas-start" id="accountNav" tabindex="-1"
aria-labelledby="accountNavLabel">
<div class="offcanvas-header d-lg-none">
<h5 class="offcanvas-title" id="accountNavLabel">{{ __('Account menu') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"
data-bs-target="#accountNav" aria-label="{{ __('Close') }}"></button>
</div>
<div class="offcanvas-body p-0">
<nav class="woocommerce-MyAccount-navigation position-sticky"
style="top: 5rem;"
aria-label="{{ __('Account navigation') }}">
<div class="list-group"> <div class="list-group">
{% for endpoint, label in wc_get_account_menu_items() %} {% for endpoint, label in wc_get_account_menu_items() %}
{% set is_active = wc_get_account_menu_item_classes(endpoint) matches '/is-active/' %}
<a href="{{ wc_get_account_endpoint_url(endpoint)|esc_url }}" <a href="{{ wc_get_account_endpoint_url(endpoint)|esc_url }}"
class="list-group-item list-group-item-action{% if wc_get_account_menu_item_classes(endpoint) matches '/is-active/' %} active{% endif %}" class="list-group-item list-group-item-action d-flex align-items-center{% if is_active %} active{% endif %}"
{% if wc_get_account_menu_item_classes(endpoint) matches '/is-active/' %}aria-current="page"{% endif %}> {% if is_active %}aria-current="page"{% endif %}>
{% if endpoint_icons[endpoint] is defined %}
<i class="bi {{ endpoint_icons[endpoint] }} me-2" aria-hidden="true"></i>
{% endif %}
{{ label|esc_html }} {{ label|esc_html }}
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
</nav> </nav>
</div>
</div>
{{ do_action('woocommerce_after_account_navigation') }}

View File

@@ -21,7 +21,7 @@
{% if has_orders %} {% if has_orders %}
<div class="table-responsive"> <div class="table-responsive">
<table class="woocommerce-orders-table table table-hover align-middle mb-4"> <table class="woocommerce-orders-table table table-hover align-middle mb-4">
<thead class="table-light"> <thead>
<tr> <tr>
{% for column_id, column_name in wc_get_account_orders_columns() %} {% for column_id, column_name in wc_get_account_orders_columns() %}
<th scope="col">{{ column_name|esc_html }}</th> <th scope="col">{{ column_name|esc_html }}</th>

View File

@@ -17,7 +17,7 @@
{% if has_methods %} {% if has_methods %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-4"> <table class="table table-hover align-middle mb-4">
<thead class="table-light"> <thead>
<tr> <tr>
{% for column_id, column_name in wc_get_account_payment_methods_columns() %} {% for column_id, column_name in wc_get_account_payment_methods_columns() %}
<th>{{ column_name|esc_html }}</th> <th>{{ column_name|esc_html }}</th>

View File

@@ -1,7 +1,8 @@
{# {#
# View Order (Bootstrap 5 Override) # View Order (Bootstrap 5 Override)
# #
# Shows details of a specific order on the account page. # Shows details of a specific order on the account page
# with summary card, status badge, and order notes.
# HPOS compatible: uses WC_Order methods only. # HPOS compatible: uses WC_Order methods only.
# #
# Expected context: # Expected context:
@@ -16,21 +17,51 @@
{% set notes = order.get_customer_order_notes() %} {% set notes = order.get_customer_order_notes() %}
<p> <div class="card shadow-sm mb-4">
{{ __('Order #%1$s was placed on %2$s and is currently %3$s.')|format( <div class="card-header d-flex justify-content-between align-items-center">
'<mark class="order-number">' ~ order.get_order_number() ~ '</mark>', <h2 class="h5 mb-0">
'<mark class="order-date">' ~ wc_format_datetime(order.get_date_created()) ~ '</mark>', {{ __('Order #%s')|format(order.get_order_number()) }}
'<mark class="order-status">' ~ wc_get_order_status_name(order.get_status()) ~ '</mark>' </h2>
)|wp_kses_post }} {% include 'components/status-badge.html.twig' with { status: order.get_status() } %}
</p> </div>
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between">
<span class="text-body-secondary">{{ __('Date') }}</span>
<time datetime="{{ order.get_date_created().date('c')|esc_attr }}">
{{ wc_format_datetime(order.get_date_created())|esc_html }}
</time>
</li>
<li class="list-group-item d-flex justify-content-between">
<span class="text-body-secondary">{{ __('Status') }}</span>
{% include 'components/status-badge.html.twig' with { status: order.get_status() } %}
</li>
<li class="list-group-item d-flex justify-content-between">
<span class="text-body-secondary">{{ __('Total') }}</span>
<strong>{{ order.get_formatted_order_total()|wp_kses_post }}</strong>
</li>
{% if order.get_payment_method_title() %}
<li class="list-group-item d-flex justify-content-between">
<span class="text-body-secondary">{{ __('Payment method') }}</span>
<span>{{ order.get_payment_method_title()|esc_html }}</span>
</li>
{% endif %}
</ul>
</div>
{% if notes %} {% if notes %}
<h2 class="h5 mt-4 mb-3">{{ __('Order updates') }}</h2> <div class="card shadow-sm mb-4">
<div class="list-group mb-4"> <div class="card-header">
<h3 class="h6 mb-0">
<i class="bi bi-chat-left-text me-1" aria-hidden="true"></i>
{{ __('Order updates') }}
</h3>
</div>
<div class="list-group list-group-flush">
{% for note in notes %} {% for note in notes %}
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-body-secondary"> <small class="text-body-secondary">
<i class="bi bi-clock me-1" aria-hidden="true"></i>
{{ date_i18n(__('l jS \\o\\f F Y, h:ia'), strtotime(note.comment_date)) }} {{ date_i18n(__('l jS \\o\\f F Y, h:ia'), strtotime(note.comment_date)) }}
</small> </small>
</div> </div>
@@ -40,6 +71,7 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div>
{% endif %} {% endif %}
{{ do_action('woocommerce_view_order', order_id) }} {{ do_action('woocommerce_view_order', order_id) }}

View File

@@ -23,7 +23,7 @@
{% else %} {% else %}
<ul class="mb-0 ps-3"> <ul class="mb-0 ps-3">
{% for notice in notices %} {% for notice in notices %}
<li {{ notice.data|default('')|raw }}> <li {{ notice.data|default('')|wp_kses_post }}>
{{ notice.notice|raw }} {{ notice.notice|raw }}
</li> </li>
{% endfor %} {% endfor %}

View File

@@ -17,7 +17,7 @@
{% if notices is defined and notices|length > 0 %} {% if notices is defined and notices|length > 0 %}
{% for notice in notices %} {% for notice in notices %}
<div class="alert alert-info alert-dismissible fade show woocommerce-info" <div class="alert alert-info alert-dismissible fade show woocommerce-info"
{{ notice.data|default('')|raw }} {{ notice.data|default('')|wp_kses_post }}
role="status"> role="status">
<i class="bi bi-info-circle me-2" aria-hidden="true"></i> <i class="bi bi-info-circle me-2" aria-hidden="true"></i>
{{ notice.notice|raw }} {{ notice.notice|raw }}

View File

@@ -17,7 +17,7 @@
{% if notices is defined and notices|length > 0 %} {% if notices is defined and notices|length > 0 %}
{% for notice in notices %} {% for notice in notices %}
<div class="alert alert-success alert-dismissible fade show woocommerce-message" <div class="alert alert-success alert-dismissible fade show woocommerce-message"
{{ notice.data|default('')|raw }} {{ notice.data|default('')|wp_kses_post }}
role="alert"> role="alert">
<i class="bi bi-check-circle me-2" aria-hidden="true"></i> <i class="bi bi-check-circle me-2" aria-hidden="true"></i>
{{ notice.notice|raw }} {{ notice.notice|raw }}

View File

@@ -31,11 +31,13 @@
<section class="woocommerce-order-details"> <section class="woocommerce-order-details">
{{ do_action('woocommerce_order_details_before_order_table', order) }} {{ do_action('woocommerce_order_details_before_order_table', order) }}
<h2 class="h5 mb-3">{{ __('Order details') }}</h2> <div class="card shadow-sm mb-4">
<div class="card-header">
<h2 class="h5 mb-0">{{ __('Order details') }}</h2>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead>
<tr> <tr>
<th>{{ __('Product') }}</th> <th>{{ __('Product') }}</th>
<th class="text-end">{{ __('Total') }}</th> <th class="text-end">{{ __('Total') }}</th>
@@ -71,12 +73,13 @@
{% if order.get_customer_note() %} {% if order.get_customer_note() %}
<tr> <tr>
<th>{{ __('Note:') }}</th> <th>{{ __('Note:') }}</th>
<td class="text-end">{{ order.get_customer_note()|nl2br|esc_html }}</td> <td class="text-end">{{ order.get_customer_note()|esc_html|nl2br }}</td>
</tr> </tr>
{% endif %} {% endif %}
</tfoot> </tfoot>
</table> </table>
</div> </div>
</div>
{{ do_action('woocommerce_order_details_after_order_table', order) }} {{ do_action('woocommerce_order_details_after_order_table', order) }}
</section> </section>

View File

@@ -20,8 +20,8 @@
<input type="search" <input type="search"
id="{{ field_id }}" id="{{ field_id }}"
class="form-control" class="form-control"
placeholder="{{ __('Search products&hellip;') }}" placeholder="{{ __('Search products') }}"
value="{{ get_search_query() }}" value="{{ get_search_query()|esc_attr }}"
name="s" /> name="s" />
<button type="submit" class="btn btn-outline-primary" aria-label="{{ __('Search') }}"> <button type="submit" class="btn btn-outline-primary" aria-label="{{ __('Search') }}">
<i class="bi bi-search" aria-hidden="true"></i> <i class="bi bi-search" aria-hidden="true"></i>

View File

@@ -4,11 +4,11 @@
# Add-to-cart form for grouped products: table of child products with quantities. # Add-to-cart form for grouped products: table of child products with quantities.
# #
# Expected context: # Expected context:
# product - WC_Product_Grouped object # product - WC_Product_Grouped object (global, injected by TemplateOverride)
# grouped_products - Array of child WC_Product objects # grouped_products - Array of child WC_Product objects
# grouped_product_columns - Array of column definitions #
# quantites_required - Whether quantities are required # Note: quantites_required and show_add_to_cart_button are computed inside the
# show_add_to_cart_button - Whether to show the submit button # loop (matching WooCommerce's PHP template behavior), not passed as context.
# #
# WooCommerce PHP equivalent: single-product/add-to-cart/grouped.php # WooCommerce PHP equivalent: single-product/add-to-cart/grouped.php
# #
@@ -21,19 +21,48 @@
<form class="cart grouped_form" action="{{ product.get_permalink()|esc_url }}" method="post" enctype="multipart/form-data"> <form class="cart grouped_form" action="{{ product.get_permalink()|esc_url }}" method="post" enctype="multipart/form-data">
<div class="table-responsive mb-4"> <div class="table-responsive mb-4">
<table class="woocommerce-grouped-product-list group_table table table-borderless align-middle"> <table class="woocommerce-grouped-product-list group_table table table-borderless align-middle">
{% set quantites_required = false %}
{% set show_add_to_cart_button = false %}
{{ do_action('woocommerce_grouped_product_list_before') }} {{ do_action('woocommerce_grouped_product_list_before') }}
{% for grouped_product in grouped_products %} {% for grouped_product in grouped_products %}
{% set child_id = grouped_product.get_id() %} {% set child_id = grouped_product.get_id() %}
<tr id="product-{{ child_id }}" class="{{ grouped_product.get_stock_status() }}">
{# Set up global product data for each child (matches PHP's setup_postdata). #}
{{ wc_setup_product_data(grouped_product) }}
{% if grouped_product.is_purchasable() and not grouped_product.has_options() %}
{% set quantites_required = true %}
{% endif %}
{% if grouped_product.is_in_stock() %}
{% set show_add_to_cart_button = true %}
{% endif %}
<tr id="product-{{ child_id }}" class="woocommerce-grouped-product-list-item {{ grouped_product.get_stock_status() }}">
<td class="woocommerce-grouped-product-list-item__quantity" style="width: 140px;"> <td class="woocommerce-grouped-product-list-item__quantity" style="width: 140px;">
{% if grouped_product.is_purchasable() and grouped_product.is_in_stock() %} {% if not grouped_product.is_purchasable() or grouped_product.has_options() or not grouped_product.is_in_stock() %}
{# Non-purchasable, has options (variable), or out-of-stock: show view link #}
{% if grouped_product.is_visible() %}
<a href="{{ grouped_product.get_permalink()|esc_url }}" class="btn btn-sm btn-outline-secondary">
{{ __('View product') }}
</a>
{% endif %}
{% elseif grouped_product.is_sold_individually() %}
<div class="form-check">
<input type="checkbox"
name="quantity[{{ child_id }}]"
value="1"
class="form-check-input wc-grouped-product-add-to-cart-checkbox"
id="quantity-{{ child_id }}" />
</div>
{% else %}
{% include 'global/quantity-input.html.twig' with { {% include 'global/quantity-input.html.twig' with {
input_id: 'quantity_' ~ child_id, input_id: 'quantity_' ~ child_id,
input_name: 'quantity[' ~ child_id ~ ']', input_name: 'quantity[' ~ child_id ~ ']',
input_value: 0, input_value: '',
min_value: 0, min_value: 0,
max_value: grouped_product.get_max_purchase_quantity()|default(0), max_value: grouped_product.get_max_purchase_quantity(),
step: 1, step: 1,
placeholder: '0', placeholder: '0',
inputmode: 'numeric', inputmode: 'numeric',
@@ -46,7 +75,7 @@
</td> </td>
<td class="woocommerce-grouped-product-list-item__label"> <td class="woocommerce-grouped-product-list-item__label">
<label for="quantity_{{ child_id }}"> <label for="product-{{ child_id }}">
{% if grouped_product.is_visible() %} {% if grouped_product.is_visible() %}
<a href="{{ grouped_product.get_permalink()|esc_url }}" class="text-decoration-none"> <a href="{{ grouped_product.get_permalink()|esc_url }}" class="text-decoration-none">
{{ grouped_product.get_name()|esc_html }} {{ grouped_product.get_name()|esc_html }}
@@ -65,14 +94,17 @@
</tr> </tr>
{% endfor %} {% endfor %}
{{ wp_reset_postdata() }}
{{ do_action('woocommerce_grouped_product_list_after') }} {{ do_action('woocommerce_grouped_product_list_after') }}
</table> </table>
</div> </div>
{% if show_add_to_cart_button is not defined or show_add_to_cart_button %} <input type="hidden" name="add-to-cart" value="{{ product.get_id() }}" />
{% if quantites_required and show_add_to_cart_button %}
{{ do_action('woocommerce_before_add_to_cart_button') }} {{ do_action('woocommerce_before_add_to_cart_button') }}
<input type="hidden" name="add-to-cart" value="{{ product.get_id() }}" />
<button type="submit" class="btn btn-primary btn-lg single_add_to_cart_button"> <button type="submit" class="btn btn-primary btn-lg single_add_to_cart_button">
{{ product.single_add_to_cart_text() }} {{ product.single_add_to_cart_text() }}
</button> </button>

View File

@@ -24,22 +24,26 @@
action="{{ product.get_permalink()|esc_url }}" action="{{ product.get_permalink()|esc_url }}"
method="post" method="post"
enctype="multipart/form-data" enctype="multipart/form-data"
{{ variations_attr|default('')|raw }}> data-product_id="{{ product.get_id() }}"
data-product_variations="{{ available_variations|json_encode|esc_attr }}">
{{ do_action('woocommerce_before_variations_form') }} {{ do_action('woocommerce_before_variations_form') }}
{% if available_variations is defined %} {% if available_variations is not same as(false) %}
{# Variation attribute selectors #} {# Variation attribute selectors.
WC PHP uses sanitize_title() on attribute names for name/data-attribute_name
so they match the lowercase keys in the variation data JSON. #}
<div class="variations mb-4"> <div class="variations mb-4">
{% for attribute_name, options in attributes %} {% for attribute_name, options in attributes %}
{% set sanitized_name = attribute_name|sanitize_title %}
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold" for="{{ attribute_name|esc_attr }}"> <label class="form-label fw-semibold" for="{{ sanitized_name|esc_attr }}">
{{ wc_attribute_label(attribute_name)|esc_html }} {{ wc_attribute_label(attribute_name)|esc_html }}
</label> </label>
<select id="{{ attribute_name|esc_attr }}" <select id="{{ sanitized_name|esc_attr }}"
class="form-select" class="form-select"
name="attribute_{{ attribute_name|esc_attr }}" name="attribute_{{ sanitized_name|esc_attr }}"
data-attribute_name="attribute_{{ attribute_name|esc_attr }}"> data-attribute_name="attribute_{{ sanitized_name|esc_attr }}">
<option value="">{{ __('Choose an option') }}</option> <option value="">{{ __('Choose an option') }}</option>
{% if options is iterable %} {% if options is iterable %}
{% for option in options %} {% for option in options %}
@@ -54,25 +58,22 @@
{% endfor %} {% endfor %}
</div> </div>
{{ do_action('woocommerce_after_variations_table') }} {# Reset link — WC JS toggles visibility based on attribute selection #}
<a class="reset_variations btn btn-link btn-sm p-0 text-decoration-none" href="#" style="visibility: hidden;">
{# Reset link #}
<div class="reset_variations_wrapper mb-3" style="display: none;">
<a class="reset_variations btn btn-link btn-sm p-0 text-decoration-none" href="#">
{{ __('Clear') }} {{ __('Clear') }}
</a> </a>
</div> <div class="reset_variations_alert screen-reader-text" role="alert" aria-live="polite" aria-relevant="all"></div>
{# Single variation display + add-to-cart button #} {{ do_action('woocommerce_after_variations_table') }}
{# Single variation display + add-to-cart button.
The woocommerce_single_variation hook outputs:
- priority 10: empty .single_variation div (JS populates via underscore template)
- priority 20: .variations_button div with quantity + add-to-cart button
No manual wrapper divs — the hooks handle the full DOM structure. #}
<div class="single_variation_wrap"> <div class="single_variation_wrap">
{{ do_action('woocommerce_before_single_variation') }} {{ do_action('woocommerce_before_single_variation') }}
<div class="woocommerce-variation single_variation"></div>
<div class="woocommerce-variation-add-to-cart variations_button">
{{ do_action('woocommerce_single_variation') }} {{ do_action('woocommerce_single_variation') }}
</div>
{{ do_action('woocommerce_after_single_variation') }} {{ do_action('woocommerce_after_single_variation') }}
</div> </div>
{% else %} {% else %}
@@ -86,3 +87,4 @@
</form> </form>
{{ do_action('woocommerce_after_add_to_cart_form') }} {{ do_action('woocommerce_after_add_to_cart_form') }}

View File

@@ -37,8 +37,7 @@
{{ do_action('woocommerce_after_add_to_cart_quantity') }} {{ do_action('woocommerce_after_add_to_cart_quantity') }}
<button type="submit" <button type="submit"
class="btn btn-primary btn-lg single_add_to_cart_button" class="btn btn-primary btn-lg single_add_to_cart_button disabled wc-variation-selection-needed">
disabled>
{{ product.single_add_to_cart_text() }} {{ product.single_add_to_cart_text() }}
</button> </button>
</div> </div>

View File

@@ -11,6 +11,7 @@
# @since 0.1.0 # @since 0.1.0
#} #}
{% verbatim %}
<script type="text/template" id="tmpl-variation-template"> <script type="text/template" id="tmpl-variation-template">
<div class="woocommerce-variation-description mb-2"> <div class="woocommerce-variation-description mb-2">
{{{ data.variation.variation_description }}} {{{ data.variation.variation_description }}}
@@ -22,9 +23,10 @@
{{{ data.variation.availability_html }}} {{{ data.variation.availability_html }}}
</div> </div>
</script> </script>
{% endverbatim %}
<script type="text/template" id="tmpl-unavailable-variation-template"> <script type="text/template" id="tmpl-unavailable-variation-template">
<p class="alert alert-warning mb-0"> <p class="alert alert-warning mb-0" role="alert">
{{ __('Sorry, this product is unavailable. Please choose a different combination.') }} {{ __('Sorry, this product is unavailable. Please choose a different combination.') }}
</p> </p>
</script> </script>

View File

@@ -4,12 +4,7 @@
# Renders SKU, categories, and tags as a definition list. # Renders SKU, categories, and tags as a definition list.
# #
# Expected context: # Expected context:
# product - WC_Product object with: # product - WC_Product object (from TemplateOverride)
# .get_sku() - SKU string
# .get_id() - Product ID
# sku - SKU string (fallback)
# categories_html - Pre-rendered category links HTML
# tags_html - Pre-rendered tag links HTML
# #
# WooCommerce PHP equivalent: single-product/meta.php # WooCommerce PHP equivalent: single-product/meta.php
# #
@@ -17,6 +12,14 @@
# @since 0.1.0 # @since 0.1.0
#} #}
{# Compute categories/tags HTML when not passed as context. #}
{% if categories_html is not defined %}
{% set categories_html = fn('wc_get_product_category_list', product.get_id(), ', ') %}
{% endif %}
{% if tags_html is not defined %}
{% set tags_html = fn('wc_get_product_tag_list', product.get_id(), ', ') %}
{% endif %}
<div class="product_meta border-top pt-3 mt-3"> <div class="product_meta border-top pt-3 mt-3">
{{ do_action('woocommerce_product_meta_start') }} {{ do_action('woocommerce_product_meta_start') }}
@@ -26,13 +29,13 @@
<dd class="col-sm-9">{{ product.get_sku()|esc_html }}</dd> <dd class="col-sm-9">{{ product.get_sku()|esc_html }}</dd>
{% endif %} {% endif %}
{% if categories_html is defined and categories_html %} {% if categories_html %}
<dt class="col-sm-3 text-body-secondary">{{ __('Categories:') }}</dt> <dt class="col-sm-3 text-body-secondary">{{ fn('_n', 'Category:', 'Categories:', product.get_category_ids()|length, 'woocommerce') }}</dt>
<dd class="col-sm-9">{{ categories_html|raw }}</dd> <dd class="col-sm-9">{{ categories_html|raw }}</dd>
{% endif %} {% endif %}
{% if tags_html is defined and tags_html %} {% if tags_html %}
<dt class="col-sm-3 text-body-secondary">{{ __('Tags:') }}</dt> <dt class="col-sm-3 text-body-secondary">{{ fn('_n', 'Tag:', 'Tags:', product.get_tag_ids()|length, 'woocommerce') }}</dt>
<dd class="col-sm-9">{{ tags_html|raw }}</dd> <dd class="col-sm-9">{{ tags_html|raw }}</dd>
{% endif %} {% endif %}
</dl> </dl>

View File

@@ -16,39 +16,52 @@
# @since 0.1.0 # @since 0.1.0
#} #}
{% set gallery_classes = 'woocommerce-product-gallery' %} {# Compute image data from the product object (PHP template does this locally). #}
{% set post_thumbnail_id = product.get_image_id() %}
{% set gallery_image_ids = product.get_gallery_image_ids() %}
{% set cols = apply_filters('woocommerce_product_thumbnails_columns', 4) %}
{% set has_images = post_thumbnail_id %}
{% set gallery_classes = 'woocommerce-product-gallery woocommerce-product-gallery--columns-' ~ cols %}
{% if has_images %}
{% set gallery_classes = gallery_classes ~ ' woocommerce-product-gallery--with-images' %}
{% else %}
{% set gallery_classes = gallery_classes ~ ' woocommerce-product-gallery--without-images' %}
{% endif %}
<div class="{{ gallery_classes }}" data-columns="{{ columns|default(4) }}"> <div class="{{ gallery_classes }}" data-columns="{{ cols }}" style="opacity: 0; transition: opacity .25s ease-in-out;">
<div class="woocommerce-product-gallery__wrapper"> <div class="woocommerce-product-gallery__wrapper">
{# Main product image #} {# Main product image #}
{% if main_image_html is defined and main_image_html %} {% if post_thumbnail_id %}
<div class="woocommerce-product-gallery__image mb-3">
{{ main_image_html|raw }}
</div>
{% elseif post_thumbnail_id is defined and post_thumbnail_id %}
<div class="woocommerce-product-gallery__image mb-3"> <div class="woocommerce-product-gallery__image mb-3">
<img src="{{ wp_get_attachment_url(post_thumbnail_id)|esc_url }}" <img src="{{ wp_get_attachment_url(post_thumbnail_id)|esc_url }}"
class="img-fluid rounded" class="img-fluid rounded wp-post-image"
alt="{{ product.get_name()|esc_attr }}" /> alt="{{ product.get_name()|esc_attr }}" />
</div> </div>
{% else %} {% else %}
<div class="woocommerce-product-gallery__image mb-3"> <div class="woocommerce-product-gallery__image woocommerce-product-gallery__image--placeholder mb-3">
<img src="{{ wc_placeholder_img_src()|esc_url }}" <img src="{{ wc_placeholder_img_src()|esc_url }}"
class="img-fluid rounded" class="img-fluid rounded wp-post-image"
alt="{{ __('Placeholder')|esc_attr }}" /> alt="{{ __('Awaiting product image')|esc_attr }}" />
</div> </div>
{% endif %} {% endif %}
{# Thumbnail gallery strip #} {# Thumbnail gallery strip — includes main image so user can switch back.
{% if gallery_image_ids is defined and gallery_image_ids|length > 0 %} Build a combined list: main image first, then gallery images.
<div class="row row-cols-{{ columns|default(4) }} g-2"> Skip any IDs that don't resolve to a valid attachment URL. #}
{% for image_id in gallery_image_ids %} {% if gallery_image_ids|length > 0 and post_thumbnail_id %}
{% set all_thumb_ids = [post_thumbnail_id]|merge(gallery_image_ids) %}
<div class="row row-cols-{{ cols }} g-2 mt-2">
{% for image_id in all_thumb_ids %}
{% set thumb_url = wp_get_attachment_url(image_id) %}
{% if thumb_url %}
<div class="col"> <div class="col">
<img src="{{ wp_get_attachment_url(image_id)|esc_url }}" <img src="{{ thumb_url|esc_url }}"
class="img-fluid rounded border cursor-pointer wc-gallery-thumb" class="img-fluid rounded border wc-gallery-thumb{% if loop.first %} border-primary active{% endif %}"
alt="{{ product.get_name()|esc_attr }}" alt="{{ product.get_name()|esc_attr }}"
data-full-src="{{ wp_get_attachment_url(image_id)|esc_url }}" /> data-full-src="{{ thumb_url|esc_url }}"
style="{% if loop.first %}opacity: 1{% else %}opacity: 0.6{% endif %}" />
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -0,0 +1,13 @@
{#
# Product Thumbnails (Bootstrap 5 Override)
#
# Intentionally empty — the product-image.html.twig template renders its own
# thumbnail gallery strip using Bootstrap grid. This override suppresses the
# default WC output from woocommerce_show_product_thumbnails() which would
# render additional full-size gallery images below the thumbnail row.
#
# WooCommerce PHP equivalent: single-product/product-thumbnails.php
#
# @package WcBootstrap
# @since 0.1.5
#}

View File

@@ -4,10 +4,7 @@
# Renders the star rating with review count link on the single product page. # Renders the star rating with review count link on the single product page.
# #
# Expected context: # Expected context:
# product - WC_Product object # product - WC_Product object (from TemplateOverride)
# rating_count - Number of ratings
# review_count - Number of reviews
# average - Average rating (0-5)
# #
# WooCommerce PHP equivalent: single-product/rating.php # WooCommerce PHP equivalent: single-product/rating.php
# #
@@ -15,7 +12,17 @@
# @since 0.1.0 # @since 0.1.0
#} #}
{% if rating_count is defined and rating_count > 0 %} {# Guard: bail if ratings are disabled. #}
{% if fn('wc_review_ratings_enabled') %}
{# Compute rating data from product object when not passed as context. #}
{% if rating_count is not defined %}
{% set rating_count = product.get_rating_count() %}
{% set review_count = product.get_review_count() %}
{% set average = product.get_average_rating() %}
{% endif %}
{% if rating_count > 0 %}
<div class="woocommerce-product-rating d-flex align-items-center gap-2 mb-3"> <div class="woocommerce-product-rating d-flex align-items-center gap-2 mb-3">
<div class="wc-star-rating d-flex align-items-center gap-1" <div class="wc-star-rating d-flex align-items-center gap-1"
role="img" role="img"
@@ -31,10 +38,12 @@
{% endfor %} {% endfor %}
</div> </div>
{% if review_count is defined and review_count > 0 %} {% if review_count > 0 %}
<a href="#reviews" class="woocommerce-review-link text-body-secondary text-decoration-none small" rel="nofollow"> <a href="#reviews" class="woocommerce-review-link text-body-secondary text-decoration-none small" rel="nofollow">
{{ _n('%s customer review', '%s customer reviews', review_count)|format(review_count) }} {{ fn('_n', '%s customer review', '%s customer reviews', review_count, 'woocommerce')|format(review_count) }}
</a> </a>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
{% endif %}{# wc_review_ratings_enabled #}

View File

@@ -18,14 +18,16 @@
<section class="related products mt-5"> <section class="related products mt-5">
{% set heading = heading|default(__('Related products')) %} {% set heading = heading|default(__('Related products')) %}
{% if heading %} {% if heading %}
<h2 class="h4 mb-4">{{ heading|esc_html }}</h2> <h2 class="h4 mb-4">{{ heading|raw }}</h2>
{% endif %} {% endif %}
{{ woocommerce_product_loop_start() }} {{ woocommerce_product_loop_start() }}
{% for product in related_products %} {% for related_product in related_products %}
{% include 'content-product.html.twig' with { product: product } %} {{ wc_setup_product_data(related_product) }}
{% include 'content-product.html.twig' %}
{% endfor %} {% endfor %}
{{ wp_reset_postdata() }}
{{ woocommerce_product_loop_end() }} {{ woocommerce_product_loop_end() }}
</section> </section>

View File

@@ -2,7 +2,8 @@
# Product Short Description (Bootstrap 5 Override) # Product Short Description (Bootstrap 5 Override)
# #
# Expected context: # Expected context:
# short_description - Product short description HTML # product - WC_Product object (from TemplateOverride)
# short_description - Product short description HTML (optional)
# #
# WooCommerce PHP equivalent: single-product/short-description.php # WooCommerce PHP equivalent: single-product/short-description.php
# #
@@ -10,7 +11,12 @@
# @since 0.1.0 # @since 0.1.0
#} #}
{% if short_description is defined and short_description %} {# Compute short description when not passed as context. #}
{% if short_description is not defined %}
{% set short_description = apply_filters('woocommerce_short_description', product.get_short_description()) %}
{% endif %}
{% if short_description %}
<div class="woocommerce-product-details__short-description lead text-body-secondary mb-3"> <div class="woocommerce-product-details__short-description lead text-body-secondary mb-3">
{{ short_description|raw }} {{ short_description|raw }}
</div> </div>

View File

@@ -16,7 +16,12 @@
# @since 0.1.0 # @since 0.1.0
#} #}
{% if product_tabs is defined and product_tabs|length > 0 %} {# Compute tabs from filter when not passed as context (wc_get_template passes no args). #}
{% if product_tabs is not defined %}
{% set product_tabs = apply_filters('woocommerce_product_tabs', {}) %}
{% endif %}
{% if product_tabs|length > 0 %}
<div class="woocommerce-tabs wc-tabs-wrapper mt-5"> <div class="woocommerce-tabs wc-tabs-wrapper mt-5">
{# Tab navigation #} {# Tab navigation #}
<ul class="nav nav-tabs" id="productTabs" role="tablist"> <ul class="nav nav-tabs" id="productTabs" role="tablist">

View File

@@ -18,14 +18,16 @@
<section class="up-sells upsells products mt-5"> <section class="up-sells upsells products mt-5">
{% set heading = heading|default(__('You may also like&hellip;')) %} {% set heading = heading|default(__('You may also like&hellip;')) %}
{% if heading %} {% if heading %}
<h2 class="h4 mb-4">{{ heading }}</h2> <h2 class="h4 mb-4">{{ heading|raw }}</h2>
{% endif %} {% endif %}
{{ woocommerce_product_loop_start() }} {{ woocommerce_product_loop_start() }}
{% for product in upsells %} {% for upsell in upsells %}
{% include 'content-product.html.twig' with { product: product } %} {{ wc_setup_product_data(upsell) }}
{% include 'content-product.html.twig' %}
{% endfor %} {% endfor %}
{{ wp_reset_postdata() }}
{{ woocommerce_product_loop_end() }} {{ woocommerce_product_loop_end() }}
</section> </section>

View File

@@ -1,7 +1,8 @@
{# {#
# Base Template (Bootstrap 5 Override) # Base Template (Bootstrap 5 Override)
# #
# Overrides the plugin's base.html.twig with Bootstrap 5 components. # WooCommerce base layout (renamed from base.html.twig to avoid
# shadowing the parent theme's full page shell).
# Provides the basic structure and block definitions. # Provides the basic structure and block definitions.
# #
# When _theme_wrapped is true, the parent theme already provides the page # When _theme_wrapped is true, the parent theme already provides the page
@@ -23,7 +24,7 @@
{% block notifications %} {% block notifications %}
{% if notifications is defined and notifications|length > 0 %} {% if notifications is defined and notifications|length > 0 %}
{% for notification in notifications %} {% for notification in notifications %}
<div class="alert alert-{{ notification.type|default('info') }} alert-dismissible fade show" role="alert"> <div class="alert alert-{{ notification.type|default('info')|esc_attr }} alert-dismissible fade show" role="alert">
{{ notification.message|wp_kses_post }} {{ notification.message|wp_kses_post }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{{ __('Close') }}"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{{ __('Close') }}"></button>
</div> </div>
@@ -33,7 +34,7 @@
{% if flash_messages is defined %} {% if flash_messages is defined %}
{% for type, messages in flash_messages %} {% for type, messages in flash_messages %}
{% for message in messages %} {% for message in messages %}
<div class="alert alert-{{ type }} alert-dismissible fade show" role="alert"> <div class="alert alert-{{ type|esc_attr }} alert-dismissible fade show" role="alert">
{{ message|wp_kses_post }} {{ message|wp_kses_post }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{{ __('Close') }}"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{{ __('Close') }}"></button>
</div> </div>

View File

@@ -0,0 +1,53 @@
<?php
/**
* Stub for WPBootstrap\Twig\TwigService.
*
* Provides just enough surface for TemplateOverride to resolve
* and render templates during unit tests.
*/
namespace WPBootstrap\Twig;
class TwigService
{
private static ?self $instance = null;
/** @var callable|null Render callback set by tests. */
private static $renderCallback = null;
public static function getInstance(): self
{
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Allow tests to override the render behaviour.
*/
public static function setRenderCallback(?callable $callback): void
{
self::$renderCallback = $callback;
}
/**
* Render a template with the given context.
*/
public function render(string $template, array $context = []): string
{
if (null !== self::$renderCallback) {
return (self::$renderCallback)($template, $context);
}
return '';
}
/**
* Reset singleton between tests.
*/
public static function reset(): void
{
self::$instance = null;
self::$renderCallback = null;
}
}

21
tests/Stubs/WcProduct.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
/**
* Stub for WC_Product.
*
* Minimal implementation for type-hint satisfaction in unit tests.
*/
class WC_Product
{
private int $id;
public function __construct(int $id = 0)
{
$this->id = $id;
}
public function get_id(): int
{
return $this->id;
}
}

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace WcBootstrap\Tests\Unit;
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
use WcBootstrap\TemplateOverride;
use WPBootstrap\Twig\TwigService;
class TemplateOverrideTest extends TestCase
{
private TemplateOverride $override;
private string $templatePath;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
TwigService::reset();
// WC_BOOTSTRAP_PATH must point to the theme root so
// resolveTwigTemplate() can locate files under templates/.
$this->templatePath = dirname(__DIR__, 2) . '/';
if (!defined('WC_BOOTSTRAP_PATH')) {
define('WC_BOOTSTRAP_PATH', $this->templatePath);
}
$this->override = new TemplateOverride();
}
protected function tearDown(): void
{
TwigService::reset();
Monkey\tearDown();
parent::tearDown();
}
// ── register() ──────────────────────────────────────────────
public function testRegisterAddsHooksWhenTwigServiceExists(): void
{
$calls = [];
Functions\when('add_action')->alias(function () use (&$calls): void {
$calls[] = func_get_args();
});
$this->override->register();
$this->assertCount(2, $calls);
$this->assertSame('woocommerce_before_template_part', $calls[0][0]);
$this->assertSame('woocommerce_after_template_part', $calls[1][0]);
}
// ── beforeTemplatePart / afterTemplatePart ───────────────────
public function testBeforeTemplatePartRendersAndBuffersWhenTwigTemplateExists(): void
{
// Use a real template file that exists in the theme.
// cart/cart.php -> cart/cart.html.twig
$templateName = 'cart/cart.php';
TwigService::setRenderCallback(function (string $tpl, array $ctx): string {
return '<div>twig-rendered</div>';
});
$this->override->beforeTemplatePart($templateName, '', '', []);
// Output buffer should be active — the PHP template output is being captured.
$level = ob_get_level();
$this->assertGreaterThan(0, $level);
// Now simulate the PHP template echoing something.
echo 'php-output-should-be-discarded';
$this->override->afterTemplatePart($templateName, '', '', []);
// The Twig output was already echoed before the buffer, so we
// just verify the buffer was cleaned (level decreased).
$this->assertSame($level - 1, ob_get_level());
}
public function testBeforeTemplatePartSkipsWhenNoTwigTemplate(): void
{
$levelBefore = ob_get_level();
// Use a template name that has no Twig override.
$this->override->beforeTemplatePart('nonexistent/template.php', '', '', []);
// No buffer should have been started.
$this->assertSame($levelBefore, ob_get_level());
}
public function testAfterTemplatePartIgnoresUnbufferedTemplate(): void
{
$levelBefore = ob_get_level();
// Calling after without a matching before should be safe.
$this->override->afterTemplatePart('cart/cart.php', '', '', []);
$this->assertSame($levelBefore, ob_get_level());
}
public function testNestedTemplatesHandledCorrectly(): void
{
// Both templates must exist as Twig files.
$outer = 'cart/cart.php';
$inner = 'cart/cart-empty.php';
TwigService::setRenderCallback(fn() => '<div>rendered</div>');
$levelBefore = ob_get_level();
$this->override->beforeTemplatePart($outer, '', '', []);
$outerLevel = ob_get_level();
$this->override->beforeTemplatePart($inner, '', '', []);
$innerLevel = ob_get_level();
$this->assertSame($outerLevel + 1, $innerLevel);
// Close inner first (stack order).
$this->override->afterTemplatePart($inner, '', '', []);
$this->assertSame($outerLevel, ob_get_level());
$this->override->afterTemplatePart($outer, '', '', []);
$this->assertSame($levelBefore, ob_get_level());
}
public function testBeforeTemplatePartPassesArgsAndProductToTwig(): void
{
$templateName = 'cart/cart.php';
$captured = null;
TwigService::setRenderCallback(function (string $tpl, array $ctx) use (&$captured): string {
$captured = $ctx;
return '';
});
$args = ['foo' => 'bar'];
$this->override->beforeTemplatePart($templateName, '', '', $args);
// Clean up the buffer.
$this->override->afterTemplatePart($templateName, '', '', $args);
$this->assertIsArray($captured);
$this->assertSame('bar', $captured['foo']);
}
public function testBeforeTemplatePartInjectsGlobalProduct(): void
{
$templateName = 'cart/cart.php';
$captured = null;
$product = new \WC_Product(42);
$GLOBALS['product'] = $product;
TwigService::setRenderCallback(function (string $tpl, array $ctx) use (&$captured): string {
$captured = $ctx;
return '';
});
$this->override->beforeTemplatePart($templateName, '', '', []);
$this->override->afterTemplatePart($templateName, '', '', []);
$this->assertSame($product, $captured['product']);
unset($GLOBALS['product']);
}
public function testBeforeTemplatePartFallsBackOnRenderException(): void
{
$templateName = 'cart/cart.php';
TwigService::setRenderCallback(function (): string {
throw new \RuntimeException('Twig error');
});
$levelBefore = ob_get_level();
// Should not throw — falls back silently and lets PHP render.
@$this->override->beforeTemplatePart($templateName, '', '', []);
// No buffer started on failure.
$this->assertSame($levelBefore, ob_get_level());
}
}

View File

@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace WcBootstrap\Tests\Unit\Twig;
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
use Twig\TwigFunction;
use Twig\TwigFilter;
use WcBootstrap\Twig\WooCommerceExtension;
class WooCommerceExtensionTest extends TestCase
{
private WooCommerceExtension $extension;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
$this->extension = new WooCommerceExtension();
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
// ── getFunctions / getFilters ───────────────────────────────
public function testGetFunctionsReturnsNonEmptyArray(): void
{
$functions = $this->extension->getFunctions();
$this->assertNotEmpty($functions);
$this->assertContainsOnlyInstancesOf(TwigFunction::class, $functions);
}
public function testGetFiltersReturnsNonEmptyArray(): void
{
$filters = $this->extension->getFilters();
$this->assertNotEmpty($filters);
$this->assertContainsOnlyInstancesOf(TwigFilter::class, $filters);
}
public function testGetFunctionsContainsExpectedNames(): void
{
$names = array_map(
fn(TwigFunction $f) => $f->getName(),
$this->extension->getFunctions()
);
$expected = [
'do_action', 'apply_filters', 'wp_nonce_field', 'fn',
'wc_get_cart_url', 'wc_get_checkout_url', 'wc_get_template',
'wc_print_notices', 'woocommerce_form_field',
];
foreach ($expected as $name) {
$this->assertContains($name, $names, "Missing Twig function: {$name}");
}
}
public function testGetFiltersContainsExpectedNames(): void
{
$names = array_map(
fn(TwigFilter $f) => $f->getName(),
$this->extension->getFilters()
);
$expected = [
'esc_html', 'esc_attr', 'esc_url',
'sanitize_title', 'wpautop', 'wp_kses_post',
'wptexturize', 'do_shortcode',
];
foreach ($expected as $name) {
$this->assertContains($name, $names, "Missing Twig filter: {$name}");
}
}
// ── callFunction() whitelist ────────────────────────────────
public function testCallFunctionWithAllowedFunctionSucceeds(): void
{
// Define a stub for an allowed function.
Functions\when('_n')->alias(fn($single, $plural, $count) => $count === 1 ? $single : $plural);
$result = $this->extension->callFunction('_n', 'item', 'items', 1);
$this->assertSame('item', $result);
}
public function testCallFunctionWithDisallowedFunctionThrows(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('is not allowed');
$this->extension->callFunction('exec', 'whoami');
}
public function testCallFunctionWithNonExistentAllowedFunctionThrows(): void
{
// 'WC' is allowed but won't exist in the test environment.
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('does not exist');
$this->extension->callFunction('WC');
}
// ── doAction() ──────────────────────────────────────────────
public function testDoActionCapturesOutput(): void
{
Functions\when('do_action')->alias(function (string $tag): void {
echo "<div>{$tag}</div>";
});
$result = $this->extension->doAction('woocommerce_before_cart');
$this->assertSame('<div>woocommerce_before_cart</div>', $result);
}
public function testDoActionReturnsEmptyWhenNoOutput(): void
{
Functions\when('do_action')->justReturn(null);
$result = $this->extension->doAction('some_silent_hook');
$this->assertSame('', $result);
}
// ── applyFilters() ──────────────────────────────────────────
public function testApplyFiltersDelegatesToWordPress(): void
{
Functions\when('apply_filters')->alias(function (string $tag, ...$args) {
return strtoupper($args[0]);
});
$result = $this->extension->applyFilters('the_title', 'hello');
$this->assertSame('HELLO', $result);
}
// ── callUserFunc() ──────────────────────────────────────────
public function testCallUserFuncCapturesCallbackOutput(): void
{
$result = $this->extension->callUserFunc(function (): void {
echo 'tab-content';
});
$this->assertSame('tab-content', $result);
}
public function testCallUserFuncPassesArguments(): void
{
$result = $this->extension->callUserFunc(function (string $key): void {
echo "key={$key}";
}, 'description');
$this->assertSame('key=description', $result);
}
// ── setupProductData() ──────────────────────────────────────
public function testSetupProductDataSetsGlobalProduct(): void
{
$product = new \WC_Product(99);
Functions\when('get_post')->justReturn((object) ['ID' => 99]);
Functions\when('setup_postdata')->justReturn(true);
$result = $this->extension->setupProductData($product);
$this->assertSame('', $result);
$this->assertSame($product, $GLOBALS['product']);
unset($GLOBALS['product'], $GLOBALS['post']);
}
public function testSetupProductDataSkipsPostdataWhenPostNotFound(): void
{
$product = new \WC_Product(1);
Functions\when('get_post')->justReturn(null);
$result = $this->extension->setupProductData($product);
$this->assertSame('', $result);
$this->assertSame($product, $GLOBALS['product']);
unset($GLOBALS['product']);
}
// ── Output-capture wrappers ─────────────────────────────────
public function testWcPrintNoticesCapturesOutput(): void
{
Functions\when('wc_print_notices')->alias(function (): void {
echo '<div class="woocommerce-message">Notice</div>';
});
$result = $this->extension->wcPrintNotices();
$this->assertStringContainsString('woocommerce-message', $result);
}
public function testWcDisplayItemMetaReturnsString(): void
{
$item = new \stdClass();
Functions\when('wc_display_item_meta')->alias(function ($item, array $args): string {
return $args['echo'] === false ? '<span>meta</span>' : '';
});
$result = $this->extension->wcDisplayItemMeta($item, []);
$this->assertSame('<span>meta</span>', $result);
}
public function testWcQueryStringFormFieldsReturnsHtml(): void
{
Functions\when('wc_query_string_form_fields')->alias(function ($values, array $exclude, string $key, bool $return): string {
return $return ? '<input type="hidden">' : '';
});
$result = $this->extension->wcQueryStringFormFields(null, [], '');
$this->assertSame('<input type="hidden">', $result);
}
public function testWoocommerceFormFieldReturnsHtml(): void
{
Functions\when('woocommerce_form_field')->alias(function (string $key, array $args, $value): string {
return $args['return'] === true ? "<p class=\"form-row\">{$key}</p>" : '';
});
$result = $this->extension->woocommerceFormField('billing_first_name', [], null);
$this->assertStringContainsString('billing_first_name', $result);
}
public function testWcGetTemplateCapturesOutput(): void
{
Functions\when('wc_get_template')->alias(function (string $name): void {
echo "<div>{$name}</div>";
});
$result = $this->extension->wcGetTemplate('order/order-details.php', []);
$this->assertSame('<div>order/order-details.php</div>', $result);
}
}

14
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
/**
* PHPUnit bootstrap file.
*
* Loads Composer autoloader and class stubs required
* for unit testing outside of WordPress/WooCommerce.
*/
// Composer autoloader (loads WcBootstrap\* classes + Brain\Monkey).
require_once dirname(__DIR__) . '/vendor/autoload.php';
// Class stubs (WordPress / WooCommerce / parent theme).
require_once __DIR__ . '/Stubs/TwigService.php';
require_once __DIR__ . '/Stubs/WcProduct.php';

View File

@@ -0,0 +1,110 @@
<?php
/**
* Product Archive / Shop Page (Bootstrap 5 Layout)
*
* Renders the WooCommerce product archive content with a Bootstrap 5 layout
* featuring a responsive sidebar (offcanvas on mobile) and product card grid.
*
* This file is NOT included directly by WordPress. Instead, it is captured via
* output buffering by wc_bootstrap_render_product_archive() and injected into
* the parent theme's page shell (pages/page.html.twig). Therefore it does NOT
* call get_header()/get_footer() or render the wrapper hooks.
*
* @package WcBootstrap
* @since 0.1.0
*/
defined( 'ABSPATH' ) || exit;
// Render breadcrumbs. The woocommerce_before_main_content hook is not fired here
// (to avoid the content wrapper), so breadcrumbs must be called directly.
// TemplateOverride will intercept global/breadcrumb.php and render the Bootstrap
// breadcrumb Twig template.
woocommerce_breadcrumb();
// Fire structured data hook (normally on woocommerce_before_main_content at priority 30).
do_action( 'woocommerce_shop_loop_header' );
$has_sidebar = is_active_sidebar( 'shop-sidebar' );
if ( woocommerce_product_loop() ) {
/**
* Hook: woocommerce_before_shop_loop.
*
* @hooked woocommerce_output_all_notices - 10
* @hooked woocommerce_result_count - 20
* @hooked woocommerce_catalog_ordering - 30
*/
do_action( 'woocommerce_before_shop_loop' );
?>
<div class="row g-4">
<?php if ( $has_sidebar ) : ?>
<aside class="col-lg-3 mb-4 mb-lg-0">
<button class="btn btn-outline-secondary w-100 d-lg-none mb-3"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#shopSidebar"
aria-controls="shopSidebar">
<i class="bi bi-funnel me-1" aria-hidden="true"></i>
<?php esc_html_e( 'Filters', 'wc-bootstrap' ); ?>
</button>
<div class="offcanvas-lg offcanvas-start"
id="shopSidebar"
tabindex="-1"
aria-labelledby="shopSidebarLabel">
<div class="offcanvas-header d-lg-none">
<h5 class="offcanvas-title" id="shopSidebarLabel">
<?php esc_html_e( 'Filters', 'wc-bootstrap' ); ?>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="<?php esc_attr_e( 'Close', 'wc-bootstrap' ); ?>"></button>
</div>
<div class="offcanvas-body p-0">
<?php dynamic_sidebar( 'shop-sidebar' ); ?>
</div>
</div>
</aside>
<div class="col-lg-9">
<?php else : ?>
<div class="col-12">
<?php endif; ?>
<?php
woocommerce_product_loop_start();
if ( wc_get_loop_prop( 'total' ) ) {
while ( have_posts() ) {
the_post();
/**
* Hook: woocommerce_shop_loop.
*/
do_action( 'woocommerce_shop_loop' );
wc_get_template_part( 'content', 'product' );
}
}
woocommerce_product_loop_end();
/**
* Hook: woocommerce_after_shop_loop.
*
* @hooked woocommerce_pagination - 10
*/
do_action( 'woocommerce_after_shop_loop' );
?>
</div>
</div>
<?php
} else {
/**
* Hook: woocommerce_no_products_found.
*
* @hooked wc_no_products_found - 10
*/
do_action( 'woocommerce_no_products_found' );
}

View File

@@ -0,0 +1,33 @@
<?php
/**
* Product Content in Loop — PHP Bridge to Twig
*
* Bridge file that renders the Bootstrap 5 card template (content-product.html.twig)
* via the parent theme's TwigService instead of WooCommerce's default <li> markup.
*
* WooCommerce's wc_get_template_part('content', 'product') uses locate_template()
* which finds this file in the child theme before falling back to the plugin template.
* Unlike wc_get_template(), wc_get_template_part() does NOT fire the
* woocommerce_before_template_part / woocommerce_after_template_part hooks,
* so the TemplateOverride class cannot intercept it — this bridge file is needed.
*
* @package WcBootstrap
* @since 0.1.0
*/
defined( 'ABSPATH' ) || exit;
global $product;
// Ensure the product is valid and visible (same guard as WooCommerce's default template).
if ( ! is_a( $product, WC_Product::class ) || ! $product->is_visible() ) {
return;
}
if ( class_exists( '\WPBootstrap\Twig\TwigService' ) ) {
$twig = \WPBootstrap\Twig\TwigService::getInstance();
echo $twig->render( 'content-product.html.twig', [] );
} else {
// Fallback: include WooCommerce's default template directly.
include WC()->plugin_path() . '/templates/content-product.php';
}

View File

@@ -0,0 +1,46 @@
<?php
/**
* Single Product Content — PHP Bridge to Twig
*
* Bridge file that renders the Bootstrap 5 single product template
* (content-single-product.html.twig) via the parent theme's TwigService
* instead of WooCommerce's default flat layout.
*
* WooCommerce's wc_get_template_part('content', 'single-product') uses
* locate_template() which finds this file in the child theme before falling
* back to the plugin template. Unlike wc_get_template(), wc_get_template_part()
* does NOT fire the woocommerce_before_template_part / woocommerce_after_template_part
* hooks, so the TemplateOverride class cannot intercept it — this bridge file
* is needed.
*
* @package WcBootstrap
* @since 0.1.0
*/
defined( 'ABSPATH' ) || exit;
global $product;
/**
* Hook: woocommerce_before_single_product.
*
* @hooked woocommerce_output_all_notices - 10
*/
do_action( 'woocommerce_before_single_product' );
if ( post_password_required() ) {
echo get_the_password_form(); // WPCS: XSS ok.
return;
}
if ( class_exists( '\WPBootstrap\Twig\TwigService' ) ) {
$twig = \WPBootstrap\Twig\TwigService::getInstance();
echo $twig->render( 'content-single-product.html.twig', [
'product' => $product,
'product_id' => $product->get_id(),
'product_class' => implode( ' ', wc_get_product_class( '', $product ) ),
] );
} else {
// Fallback: include WooCommerce's default template directly.
include WC()->plugin_path() . '/templates/content-single-product.php';
}

View File

@@ -0,0 +1,35 @@
<?php
/**
* Single Product Page (Bootstrap 5 Layout)
*
* Renders the WooCommerce single product content. This file is NOT included
* directly by WordPress. Instead, it is captured via output buffering by
* wc_bootstrap_render_single_product() and injected into the parent theme's
* page shell (pages/page.html.twig). Therefore it does NOT call
* get_header()/get_footer() or render the wrapper hooks.
*
* @package WcBootstrap
* @since 0.1.0
*/
defined( 'ABSPATH' ) || exit;
/**
* Hook: woocommerce_before_main_content.
*
* @hooked woocommerce_output_content_wrapper - 10 (outputs opening divs for the content)
* @hooked woocommerce_breadcrumb - 20
*/
do_action( 'woocommerce_before_main_content' );
while ( have_posts() ) {
the_post();
wc_get_template_part( 'content', 'single-product' );
}
/**
* Hook: woocommerce_after_main_content.
*
* @hooked woocommerce_output_content_wrapper_end - 10 (outputs closing divs for the content)
*/
do_action( 'woocommerce_after_main_content' );