Compare commits

...
Sign in to create a new pull request.

26 commits

Author SHA1 Message Date
isUnknown
b78a6f822a EN > product > description : \n = only 1 br
All checks were successful
Deploy / Deploy to Production (push) Successful in 8s
2026-01-22 13:19:26 +01:00
isUnknown
f829024aae EN > product > description : fix line breaks
All checks were successful
Deploy / Deploy to Production (push) Successful in 7s
2026-01-22 13:18:03 +01:00
isUnknown
6d68ea0145 mobile > product > gallery : fix position
All checks were successful
Deploy / Deploy to Production (push) Successful in 6s
2026-01-21 11:25:22 +01:00
isUnknown
4b7062e0bd Fix thanks and error page routing for both languages
All checks were successful
Deploy / Deploy to Production (push) Successful in 7s
- Add dedicated routes for /thanks, /error, /en/thanks, and /en/error
- Create missing error.en.txt content file
- Add title to error.fr.txt

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-21 11:19:48 +01:00
isUnknown
7575e5adbc Fix English homepage routing
All checks were successful
Deploy / Deploy to Production (push) Successful in 6s
Add dedicated route for /en to display home page in English using Kirby's multilingual system.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-21 11:17:25 +01:00
isUnknown
9b4bd4b731 product > english > description : nl2br
All checks were successful
Deploy / Deploy to Production (push) Successful in 6s
2026-01-21 11:11:18 +01:00
isUnknown
aa873e117f Fix cart initialization and ShopifyCart loading
- Wait for ShopifyCart to be available before initializing structured data
- Add getCart() method to retrieve existing cart from Shopify API
- Load cart state on page load to display correct initial cart contents

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-21 11:06:28 +01:00
9396ae4e02 Fix Shopify API connection on local Windows dev server
Add SSL_VERIFYPEER option to cURL to fix HTTP 0 errors when running
with PHP built-in server on Windows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 12:03:06 +01:00
isUnknown
f3f302513e Improve Open Graph meta tags with default description
All checks were successful
Deploy / Deploy to Production (push) Successful in 7s
Add default descriptions for social sharing cards:
- FR: "Boutique de Index, ONG d'investigation indépendante"
- EN: "Index shop, independent investigative NGO"

Changes:
- Add language-specific default descriptions
- Fix description excerpt handling
- Add OG image generation helper (create-og-image.html)
- Use favicon as temporary OG image (TODO: create proper 1200x630 image)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 17:08:24 +01:00
isUnknown
9eb8d08bcc Add comprehensive SEO optimization
All checks were successful
Deploy / Deploy to Production (push) Successful in 6s
Implement complete SEO setup for virtual product pages with:
- Meta tags (title, description, canonical, hreflang)
- Open Graph protocol for social sharing
- Twitter Card tags
- Schema.org structured data (JSON-LD) for products
- XML sitemap including virtual pages
- Dynamic meta tag updates via JavaScript

Changes:
- Create SEO snippet with all meta tags
- Add structured data snippet for products
- Generate sitemap.xml with products and hreflang
- Update meta tags dynamically when Shopify data loads
- Remove noindex/nofollow (was blocking all indexing)
- Add product-specific OG tags (price, availability)

All pages now properly indexed with correct multilingual setup.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 17:02:27 +01:00
isUnknown
f69d990349 cart drawer : close button strokes to black
All checks were successful
Deploy / Deploy to Production (push) Successful in 7s
2026-01-16 16:41:46 +01:00
isUnknown
f4ecdcf947 Fix multilingual routing for virtual product pages
Use site()->visit() to properly set language context for virtual pages.
This ensures UI translations and language-specific content work correctly
on both /slug (French) and /en/slug (English) routes.

Changes:
- Add site()->visit($page, $lang) in routes to set page language
- Create product controller for language detection
- Fix add-to-cart button to update text in .txt div instead of button
- Remove broken hooks approach

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 16:40:10 +01:00
isUnknown
4489e705b8 Fix virtual pages with routes using Page::factory()
All checks were successful
Deploy / Deploy to Production (push) Successful in 6s
Replace page.children:after hook with proper routes implementation.
Product pages are now created dynamically via routes that match
Shopify handles for both French and English versions.

Changes:
- Add routes with Page::factory() for virtual product pages
- Remove hooks approach (not working)
- Clean up old Snipcart webhook routes
- Support FR and EN product URLs

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 12:53:19 +01:00
isUnknown
ade0ed1a67 Add virtual pages from Shopify and panel refresh button
This simplifies product management by eliminating manual Kirby page creation. Products are now automatically loaded as virtual pages from the Shopify API.

Changes:
- Add virtual pages via page.children:after hook
- Create shopify.php helper with caching (60min TTL)
- Add shopify-refresh panel plugin for cache management
- Remove manual product content files (now virtual)
- Update site.yml blueprint to show refresh button
- Fix cache implementation to use get/set pattern

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 12:44:28 +01:00
isUnknown
4987c4830f Add Shopify variant options selector with circular radio buttons
All checks were successful
Deploy / Deploy to Production (push) Successful in 7s
- Implement dynamic option rendering from Shopify variant data
- Generate circular radio buttons for product variants (sizes, colors, etc.)
- Disable add-to-cart button until option is selected
- Display "Choisissez une option" text when option required
- Update button text and enable on option selection
- Add is-selected class to chosen option
- Handle disabled state for out-of-stock variants
- Restore btn__default button style with icon and text structure
- Add chooseOption translation key in FR/EN

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 12:15:28 +01:00
isUnknown
0c8cc5000c improve styles and ignore claude settings
All checks were successful
Deploy / Deploy to Production (push) Successful in 7s
2026-01-16 12:04:15 +01:00
isUnknown
ad699f0365 Migrate product data from Kirby to Shopify Storefront API
- Add product loaders (product-loader.js, products-list-loader.js) to fetch data from Shopify
- Extend Shopify API client with getProductByHandle() and getAllProducts() methods
- Integrate Shopify metafields for multilingual support (custom.title_en, custom.description_en)
- Refactor product.php and home.php templates to load content dynamically
- Simplify product blueprint to minimal routing configuration
- Create generic buy-button.php snippet with variant selection
- Update footer.php with conditional script loading
- Refactor _section--product.scss for better Sass structure
- Add translations for loading states and product errors
- Clean up old Kirby product content files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 12:03:20 +01:00
isUnknown
957cf79e45 Add cart button to header with item count
Add a cart button in the header (right of language switcher) that displays the number of items in parentheses when cart is not empty. Button opens the cart drawer on click and count updates dynamically when items are added/removed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-14 12:06:26 +01:00
isUnknown
b3940bba08 Add i18n support and cart total to Shopify integration
Implement multilingual support for shop interface and add total calculation to cart drawer:
- Add FR/EN translations for all shop-related texts (cart, checkout, stock status)
- Update templates and JavaScript to use translation system
- Add cart total calculation with formatted currency display
- Refactor cart drawer styles to SASS with improved button styling (black borders on +/-)
- Fix English product content (replace JSON with proper HTML)
- Extract cart drawer to separate snippet for better organization

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-14 12:02:55 +01:00
isUnknown
28501fec7c Implement custom Shopify cart with drawer UI
Replace Shopify Buy Button iframe with custom implementation using Storefront API 2026-01. Create interactive cart drawer with full product management capabilities (add, remove, update quantities) and seamless checkout flow.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-14 11:26:14 +01:00
isUnknown
c08662caf8 Archive and disable Snipcart integration for Shopify migration
Disable Snipcart e-commerce integration to prepare for Shopify Buy Button. All Snipcart code is preserved in assets/snipcart-archive/ with complete restoration instructions.

Changes:
- Comment out Snipcart SCSS import
- Disable Snipcart routes (validate.json, webhook)
- Comment Snipcart attributes in product template
- Remove Snipcart scripts from footer
- Create archive with snipcart.js, product-size.js, _snipcart.scss
- Add comprehensive README.md with restoration guide

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-13 17:17:40 +01:00
isUnknown
84aa4cac17 fix cover image
All checks were successful
Deploy / Deploy to Production (push) Successful in 7s
2026-01-08 15:25:55 +01:00
isUnknown
6c8cdf21d2 fix home layout
All checks were successful
Deploy / Deploy to Production (push) Successful in 7s
2025-12-22 14:03:56 +01:00
isUnknown
b59d841d39 merge sliders
All checks were successful
Deploy / Deploy to Production (push) Successful in 6s
2025-12-22 10:22:36 +01:00
isUnknown
3ba37020ff Fix Snipcart v3 API compatibility for order completion redirect
Update event listener from deprecated v2 Snipcart.execute to v3 Snipcart.events.on API. Change event from 'order.completed' (webhook-only) to 'cart.confirmed' (client-side) and update parameter from order to cartState to match v3 structure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-22 10:21:28 +01:00
isUnknown
010c4f6c20 thanks : style
All checks were successful
Deploy / Deploy to Production (push) Successful in 10s
2025-12-22 10:07:03 +01:00
63 changed files with 3705 additions and 897 deletions

View file

@ -1,7 +1,17 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"WebSearch" "WebSearch",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(find:*)",
"Bash(curl:*)",
"WebFetch(domain:snipcart.com)",
"Bash(grep:*)",
"Bash(npm run build:*)",
"Bash(php test-shopify.php:*)",
"WebFetch(domain:getkirby.com)",
"WebFetch(domain:forum.getkirby.com)"
] ]
} }
} }

View file

@ -26,7 +26,7 @@ jobs:
set ftp:ssl-allow no set ftp:ssl-allow no
open -u $USERNAME,$PASSWORD $PRODUCTION_HOST open -u $USERNAME,$PASSWORD $PRODUCTION_HOST
mirror --reverse --verbose --ignore-time --parallel=10 -x local/ assets assets mirror --reverse --verbose --ignore-time --parallel=10 -x local/ assets assets
mirror --reverse --verbose --ignore-time --parallel=10 -x accounts/ -x cache/ -x sessions/ -x header.php site site mirror --reverse --verbose --ignore-time --parallel=10 -x accounts/ -x cache/ -x sessions/ site site
mirror --reverse --verbose --ignore-time --parallel=10 kirby kirby mirror --reverse --verbose --ignore-time --parallel=10 kirby kirby
mirror --reverse --verbose --ignore-time --parallel=10 vendor vendor mirror --reverse --verbose --ignore-time --parallel=10 vendor vendor
quit quit

5
.gitignore vendored
View file

@ -52,3 +52,8 @@ Icon
# Guide d'intégration (contient des informations sensibles) # Guide d'intégration (contient des informations sensibles)
# --------------- # ---------------
GUIDE-INTEGRATION-MONDIAL-RELAY.md GUIDE-INTEGRATION-MONDIAL-RELAY.md
# Claude settings
# ---------------
.claude
/.claude/*

View file

@ -0,0 +1,68 @@
.product-purchase {
margin-top: 2rem;
}
.product-stock-info {
margin-bottom: 1rem;
}
.stock-status {
font-size: 0.9rem;
font-weight: 600;
margin: 0;
}
.stock-status.in-stock {
color: #00cc00;
}
.stock-status.low-stock {
color: #ff9900;
}
.stock-status.out-of-stock {
color: #ff3333;
}
.btn-add-to-cart {
font-family: "Open Sans", sans-serif;
font-weight: bold;
font-size: 1rem;
color: #000000;
background-color: #00ff00;
border: none;
border-radius: 40px;
padding: 12px 34px;
cursor: pointer;
transition: background-color 0.3s ease;
width: 100%;
max-width: 300px;
}
.btn-add-to-cart:hover:not(:disabled) {
background-color: #00e600;
}
.btn-add-to-cart:focus {
outline: 2px solid #00e600;
outline-offset: 2px;
}
.btn-add-to-cart:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-add-to-cart.success {
background-color: #00cc00;
}
.btn-add-to-cart.error {
background-color: #ff3333;
color: #ffffff;
}
.btn-add-to-cart.out-of-stock {
background-color: #cccccc;
color: #666666;
}

View file

@ -0,0 +1,258 @@
/* Cart Drawer Styles */
.cart-drawer {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
color: #000;
&.is-open {
pointer-events: auto;
opacity: 1;
.cart-drawer__panel {
transform: translateX(0);
}
}
&__overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
cursor: pointer;
}
&__panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 100%;
max-width: 420px;
background-color: #ffffff;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.3s ease;
@media (max-width: 768px) {
max-width: 100%;
}
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
border-bottom: 1px solid #e0e0e0;
h3 {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
}
}
&__close {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s;
&:hover {
opacity: 0.7;
}
svg {
stroke: #000;
}
}
&__content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
&.is-loading {
opacity: 0.5;
pointer-events: none;
}
}
&__empty {
text-align: center;
padding: 3rem 1rem;
color: #666;
&.hidden {
display: none;
}
}
&__items {
display: flex;
flex-direction: column;
gap: 1rem;
&.hidden {
display: none;
}
}
&__footer {
border-top: 1px solid #e0e0e0;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
&__total {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.125rem;
font-weight: bold;
&-label {
color: #000;
}
&-amount {
color: #000;
font-size: 1.25rem;
}
}
&__checkout-btn {
width: 100%;
font-family: "Open Sans", sans-serif;
font-weight: bold;
font-size: 1rem;
color: #000000;
background-color: #00ff00;
border: none;
border-radius: 40px;
padding: 14px 34px;
cursor: pointer;
transition: background-color 0.3s ease;
&:hover:not(:disabled) {
background-color: #00e600;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
// Cart Item
.cart-item {
display: flex;
gap: 1rem;
padding: 1rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
&__image {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
&__details {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__title {
font-weight: 600;
margin: 0;
font-size: 1rem;
}
&__variant {
font-size: 0.875rem;
color: #666;
margin: 0;
}
&__price {
font-weight: bold;
color: #000;
}
&__quantity {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: auto;
}
&__qty-btn {
width: 28px;
height: 28px;
border: 1px solid #000;
background: #fff;
color: #000;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
font-weight: bold;
transition: all 0.2s;
&:hover:not(:disabled) {
background-color: #000;
color: #fff;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
&__qty-value {
min-width: 30px;
text-align: center;
font-weight: 600;
}
&__remove {
background: none;
border: none;
color: #ff3333;
cursor: pointer;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
text-decoration: underline;
align-self: flex-start;
&:hover {
color: #cc0000;
}
}
}

View file

@ -1,7 +1,7 @@
[data-template="subscription-newsletter"], [data-template="subscription-newsletter"],
[data-template="thanks"], [data-template="thanks"],
[data-template="support"], [data-template="support"],
[data-template="store"] { [data-template="home"] {
.p__baseline-big { .p__baseline-big {
font-family: var(--title); font-family: var(--title);
font-size: var(--fs-big); font-size: var(--fs-big);

View file

@ -43,14 +43,20 @@
} }
} }
.header-left, .header-left {
.header-right {
width: 90px; width: 90px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
} }
.header-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1rem;
}
.header-center { .header-center {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -75,4 +81,40 @@
color: var(--color-txt); color: var(--color-txt);
} }
} }
.header-cart-btn {
font-family: var(--font);
background: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
text-transform: uppercase;
color: var(--color-txt);
padding: 0;
line-height: 1;
display: flex;
align-items: center;
gap: 0.25rem;
transition: opacity 0.2s;
&:hover {
opacity: 0.7;
}
}
.header-cart-count {
font-weight: normal;
&:empty {
display: none;
}
&:not(:empty)::before {
content: "(";
}
&:not(:empty)::after {
content: ")";
}
}
} }

View file

@ -402,7 +402,7 @@ main {
[data-template=subscription-newsletter] .p__baseline-big, [data-template=subscription-newsletter] .p__baseline-big,
[data-template=thanks] .p__baseline-big, [data-template=thanks] .p__baseline-big,
[data-template=support] .p__baseline-big, [data-template=support] .p__baseline-big,
[data-template=store] .p__baseline-big { [data-template=home] .p__baseline-big {
font-family: var(--title); font-family: var(--title);
font-size: var(--fs-big); font-size: var(--fs-big);
font-weight: var(--fw-bold); font-weight: var(--fw-bold);
@ -413,14 +413,14 @@ main {
[data-template=subscription-newsletter] .p__baseline-big strong, [data-template=subscription-newsletter] .p__baseline-big strong,
[data-template=thanks] .p__baseline-big strong, [data-template=thanks] .p__baseline-big strong,
[data-template=support] .p__baseline-big strong, [data-template=support] .p__baseline-big strong,
[data-template=store] .p__baseline-big strong { [data-template=home] .p__baseline-big strong {
font-weight: var(--fw-bolf); font-weight: var(--fw-bolf);
color: var(--color-accent); color: var(--color-accent);
} }
[data-template=subscription-newsletter] .p__baseline-big .link-don, [data-template=subscription-newsletter] .p__baseline-big .link-don,
[data-template=thanks] .p__baseline-big .link-don, [data-template=thanks] .p__baseline-big .link-don,
[data-template=support] .p__baseline-big .link-don, [data-template=support] .p__baseline-big .link-don,
[data-template=store] .p__baseline-big .link-don { [data-template=home] .p__baseline-big .link-don {
display: block; display: block;
color: var(--color-accent); color: var(--color-accent);
text-decoration: none; text-decoration: none;
@ -428,7 +428,7 @@ main {
[data-template=subscription-newsletter] .p__baseline-big .link-don:hover, [data-template=subscription-newsletter] .p__baseline-big .link-don:hover,
[data-template=thanks] .p__baseline-big .link-don:hover, [data-template=thanks] .p__baseline-big .link-don:hover,
[data-template=support] .p__baseline-big .link-don:hover, [data-template=support] .p__baseline-big .link-don:hover,
[data-template=store] .p__baseline-big .link-don:hover { [data-template=home] .p__baseline-big .link-don:hover {
-webkit-text-decoration: underline 2px; -webkit-text-decoration: underline 2px;
text-decoration: underline 2px; text-decoration: underline 2px;
text-underline-offset: 4px; text-underline-offset: 4px;
@ -436,7 +436,7 @@ main {
[data-template=subscription-newsletter] .p__baseline, [data-template=subscription-newsletter] .p__baseline,
[data-template=thanks] .p__baseline, [data-template=thanks] .p__baseline,
[data-template=support] .p__baseline, [data-template=support] .p__baseline,
[data-template=store] .p__baseline { [data-template=home] .p__baseline {
font-size: var(--fs-medium); font-size: var(--fs-medium);
font-weight: var(--fw-medium); font-weight: var(--fw-medium);
line-height: 1.1; line-height: 1.1;
@ -447,7 +447,7 @@ main {
[data-template=subscription-newsletter] .p__baseline, [data-template=subscription-newsletter] .p__baseline,
[data-template=thanks] .p__baseline, [data-template=thanks] .p__baseline,
[data-template=support] .p__baseline, [data-template=support] .p__baseline,
[data-template=store] .p__baseline { [data-template=home] .p__baseline {
text-align: center; text-align: center;
margin: var(--spacing) 0; margin: var(--spacing) 0;
} }
@ -455,7 +455,7 @@ main {
[data-template=subscription-newsletter] .p__details, [data-template=subscription-newsletter] .p__details,
[data-template=thanks] .p__details, [data-template=thanks] .p__details,
[data-template=support] .p__details, [data-template=support] .p__details,
[data-template=store] .p__details { [data-template=home] .p__details {
font-size: var(--fs-small); font-size: var(--fs-small);
margin-bottom: 0.5em; margin-bottom: 0.5em;
color: var(--grey-400); color: var(--grey-400);
@ -463,7 +463,7 @@ main {
[data-template=subscription-newsletter] .section__heading, [data-template=subscription-newsletter] .section__heading,
[data-template=thanks] .section__heading, [data-template=thanks] .section__heading,
[data-template=support] .section__heading, [data-template=support] .section__heading,
[data-template=store] .section__heading { [data-template=home] .section__heading {
font-size: var(--fs-normal); font-size: var(--fs-normal);
font-weight: var(--fw-medium); font-weight: var(--fw-medium);
line-height: 1; line-height: 1;
@ -477,8 +477,8 @@ main {
[data-template=thanks] ol, [data-template=thanks] ol,
[data-template=support] ul, [data-template=support] ul,
[data-template=support] ol, [data-template=support] ol,
[data-template=store] ul, [data-template=home] ul,
[data-template=store] ol { [data-template=home] ol {
margin-left: 3ch; margin-left: 3ch;
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
@ -524,13 +524,18 @@ main {
#site-header.is-shrinked .site-title { #site-header.is-shrinked .site-title {
width: 80px !important; width: 80px !important;
} }
#site-header .header-left, #site-header .header-left {
#site-header .header-right {
width: 90px; width: 90px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
} }
#site-header .header-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1rem;
}
#site-header .header-center { #site-header .header-center {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -553,6 +558,36 @@ main {
#site-header #toggle-lang li.is-selected { #site-header #toggle-lang li.is-selected {
color: var(--color-txt); color: var(--color-txt);
} }
#site-header .header-cart-btn {
font-family: var(--font);
background: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
text-transform: uppercase;
color: var(--color-txt);
padding: 0;
line-height: 1;
display: flex;
align-items: center;
gap: 0.25rem;
transition: opacity 0.2s;
}
#site-header .header-cart-btn:hover {
opacity: 0.7;
}
#site-header .header-cart-count {
font-weight: normal;
}
#site-header .header-cart-count:empty {
display: none;
}
#site-header .header-cart-count:not(:empty)::before {
content: "(";
}
#site-header .header-cart-count:not(:empty)::after {
content: ")";
}
#site-footer { #site-footer {
background-color: black; background-color: black;
@ -940,36 +975,36 @@ body.is-fullscreen {
overflow: hidden; overflow: hidden;
} }
[data-template=store] .p__baseline-big { [data-template=home] .p__baseline-big {
margin-top: calc(var(--spacing) * 2); margin-top: calc(var(--spacing) * 2);
} }
[data-template=store] #store__container { [data-template=home] #store__container {
margin-top: calc(var(--spacing) * 2); margin-top: calc(var(--spacing) * 2);
margin-bottom: calc(var(--spacing) * 4); margin-bottom: calc(var(--spacing) * 4);
width: 100%; width: 100%;
max-width: 1000px; max-width: 1000px;
} }
[data-template=store] #store__container .store__product { [data-template=home] #store__container .store__product {
position: relative; position: relative;
} }
[data-template=store] #store__container .store__product figure { [data-template=home] #store__container .store__product figure {
aspect-ratio: 4/3; aspect-ratio: 4/3;
background-color: var(--color-bg); background-color: var(--color-bg);
background-color: var(--data-bg); background-color: var(--data-bg);
margin-bottom: calc(var(--spacing) * 0.5); margin-bottom: calc(var(--spacing) * 0.5);
overflow: hidden; overflow: hidden;
} }
[data-template=store] #store__container .store__product img { [data-template=home] #store__container .store__product img {
width: 100%; width: 100%;
height: 100%; height: 100%;
-o-object-fit: contain; -o-object-fit: contain;
object-fit: contain; object-fit: contain;
transition: var(--curve) 0.5s; transition: var(--curve) 0.5s;
} }
[data-template=store] #store__container .store__product a { [data-template=home] #store__container .store__product a {
text-decoration: none; text-decoration: none;
} }
[data-template=store] #store__container .store__product .link-block { [data-template=home] #store__container .store__product .link-block {
display: block; display: block;
height: 100%; height: 100%;
width: 100%; width: 100%;
@ -978,23 +1013,23 @@ body.is-fullscreen {
left: 0; left: 0;
cursor: pointer; cursor: pointer;
} }
[data-template=store] #store__container .store__product:hover figure { [data-template=home] #store__container .store__product:hover figure {
overflow: hidden; overflow: hidden;
} }
[data-template=store] #store__container .store__product:hover img { [data-template=home] #store__container .store__product:hover img {
transform: scale(1.05); transform: scale(1.05);
} }
[data-template=store] #store__container .store__product:hover .line-1 { [data-template=home] #store__container .store__product:hover .line-1 {
text-decoration: underline; text-decoration: underline;
} }
@media screen and (max-width: 720px) { @media screen and (max-width: 720px) {
[data-template=store] #store__container .store__product { [data-template=home] #store__container .store__product {
margin-top: calc(var(--spacing) * 1.5); margin-top: calc(var(--spacing) * 1.5);
margin-bottom: calc(var(--spacing) * 0.5); margin-bottom: calc(var(--spacing) * 0.5);
} }
} }
@media screen and (min-width: 720px) { @media screen and (min-width: 720px) {
[data-template=store] #store__container { [data-template=home] #store__container {
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(6, 1fr);
-moz-column-gap: calc(var(--padding-body) * 0.75); -moz-column-gap: calc(var(--padding-body) * 0.75);
@ -1003,11 +1038,11 @@ body.is-fullscreen {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
[data-template=store] #store__container .store__product { [data-template=home] #store__container .store__product {
grid-column: span 2; grid-column: span 2;
} }
[data-template=store] #store__container .store__product:nth-of-type(1), [data-template=home] #store__container .store__product:nth-of-type(1),
[data-template=store] #store__container .store__product:nth-of-type(2) { [data-template=home] #store__container .store__product:nth-of-type(2) {
grid-column: span 3; grid-column: span 3;
} }
} }
@ -1019,11 +1054,8 @@ body.is-fullscreen {
margin-right: auto; margin-right: auto;
} }
.section__product, .product-content {
.store__nav { display: contents;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
} }
.store__nav { .store__nav {
@ -1035,11 +1067,17 @@ body.is-fullscreen {
.store__nav a { .store__nav a {
text-decoration: none; text-decoration: none;
} }
.store__nav a::before {
content: "← ";
}
.store__nav a:hover { .store__nav a:hover {
text-decoration: underline; text-decoration: underline;
} }
.store__nav a::before { @media screen and (max-width: 720px) {
content: "← "; .store__nav a {
padding-top: 0;
font-size: var(--fs-small);
}
} }
.section__product .details ul { .section__product .details ul {
@ -1048,122 +1086,7 @@ body.is-fullscreen {
.section__product .details ul li { .section__product .details ul li {
padding-bottom: 0.2em; padding-bottom: 0.2em;
} }
.product-options__list {
list-style: none;
display: flex;
gap: 2ch;
}
.product-options__list li {
position: relative;
}
.product-options__list li input[type=radio] {
position: fixed;
opacity: 0;
pointer-events: none;
}
.product-options__list li label {
font-family: var(--title);
font-size: var(--fs-normal);
height: 4ch;
width: 4ch;
border-radius: 50%;
border: var(--border);
border-color: transparent;
display: flex;
align-items: center;
justify-content: center;
padding-top: 0px;
cursor: pointer;
}
.product-options__list li input[type=radio]:checked + label {
border-color: var(--color-txt);
}
.product-options__list li input[type=radio]:not(:checked) + label:hover {
border-color: var(--grey-600);
background-color: var(--grey-800);
}
.product-gallery {
position: relative;
aspect-ratio: 4/3;
}
.product-gallery .swiper-slide {
width: 100%;
}
.product-gallery .swiper-slide figure {
aspect-ratio: 4/3;
width: 100%;
height: 100%;
}
.product-gallery .swiper-slide figure img {
width: 100%;
height: 100%;
-o-object-fit: contain;
object-fit: contain;
}
.product-gallery .swiper-button-prev,
.product-gallery .swiper-button-next {
color: var(--color-txt);
width: 20px;
height: 20px;
}
.product-gallery .swiper-button-prev:after,
.product-gallery .swiper-button-next:after {
font-size: 20px;
font-weight: bold;
}
.product-gallery .swiper-button-prev:hover,
.product-gallery .swiper-button-next:hover {
opacity: 0.7;
}
.product-gallery .swiper-pagination {
position: relative;
margin-top: calc(var(--spacing) * 0.5);
bottom: 0;
}
.product-gallery .swiper-pagination .swiper-pagination-bullet {
width: 8px;
height: 8px;
background: var(--grey-600);
opacity: 0.5;
transition: opacity 0.3s;
}
.product-gallery .swiper-pagination .swiper-pagination-bullet:hover {
opacity: 0.7;
}
.product-gallery .swiper-pagination .swiper-pagination-bullet-active {
background: var(--color-txt);
opacity: 1;
}
.hero {
margin-bottom: calc(var(--spacing) * 1);
padding: calc(var(--spacing) * 0.5) 0;
border-top: var(--border-light);
border-bottom: var(--border-light);
}
.hero .p__baseline-big {
margin: 0;
text-align: left;
}
.add-to-cart {
margin: 0;
border-bottom: var(--border-light);
padding: calc(var(--spacing) * 0.5) 0;
}
.product-options {
border-bottom: var(--border-light);
padding: calc(var(--spacing) * 0.25) 0;
}
@media screen and (max-width: 720px) { @media screen and (max-width: 720px) {
.store__nav a {
padding-top: 0;
font-size: var(--fs-small);
}
.section__product { .section__product {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1202,7 +1125,7 @@ body.is-fullscreen {
} }
} }
@media screen and (min-width: 720px) { @media screen and (min-width: 720px) {
.section__product { .section__product .product-content {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: calc(var(--padding-body) * 2); gap: calc(var(--padding-body) * 2);
@ -1221,10 +1144,123 @@ body.is-fullscreen {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
}
.product-options {
border-bottom: var(--border-light);
padding: calc(var(--spacing) * 0.25) 0;
}
.product-options__list {
list-style: none;
display: flex;
gap: 2ch;
}
.product-options__list li {
position: relative;
}
.product-options__list li input[type=radio] {
position: fixed;
opacity: 0;
pointer-events: none;
}
.product-options__list li input[type=radio]:checked + label {
border-color: var(--color-txt);
}
.product-options__list li input[type=radio]:not(:checked) + label:hover {
border-color: var(--grey-600);
background-color: var(--grey-800);
}
.product-options__list li label {
font-family: var(--title);
font-size: var(--fs-normal);
height: 4ch;
width: 4ch;
border-radius: 50%;
border: var(--border);
border-color: transparent;
display: flex;
align-items: center;
justify-content: center;
padding-top: 0px;
cursor: pointer;
}
.product-gallery {
position: relative;
aspect-ratio: 4/3;
}
.product-gallery .swiper-slide {
width: 100%;
}
.product-gallery .swiper-slide figure {
aspect-ratio: 4/3;
width: 100%;
height: 100%;
}
.product-gallery .swiper-slide figure img {
width: 100%;
height: 100%;
-o-object-fit: contain;
object-fit: contain;
}
@media screen and (min-width: 720px) {
.product-gallery .swiper-slide figure { .product-gallery .swiper-slide figure {
width: calc(100% - 60px); width: calc(100% - 60px);
} }
} }
.product-gallery .swiper-button-prev,
.product-gallery .swiper-button-next {
color: var(--color-txt);
width: 20px;
height: 20px;
}
.product-gallery .swiper-button-prev:after,
.product-gallery .swiper-button-next:after {
font-size: 20px;
font-weight: bold;
}
.product-gallery .swiper-button-prev:hover,
.product-gallery .swiper-button-next:hover {
opacity: 0.7;
}
.product-gallery .swiper-pagination {
position: relative;
margin-top: calc(var(--spacing) * 0.5);
bottom: 0;
}
.product-gallery .swiper-pagination .swiper-pagination-bullet {
width: 8px;
height: 8px;
background: var(--grey-600);
opacity: 0.5;
transition: opacity 0.3s;
}
.product-gallery .swiper-pagination .swiper-pagination-bullet:hover {
opacity: 0.7;
}
.product-gallery .swiper-pagination .swiper-pagination-bullet.swiper-pagination-bullet-active {
background: var(--color-txt);
opacity: 1;
}
.hero {
margin-bottom: calc(var(--spacing) * 1);
padding: calc(var(--spacing) * 0.5) 0;
border-top: var(--border-light);
border-bottom: var(--border-light);
}
.hero .p__baseline-big {
margin: 0;
text-align: left;
}
.add-to-cart {
margin: 0;
border-bottom: var(--border-light);
padding: calc(var(--spacing) * 0.5) 0;
}
[data-template=thanks] .thanks-page { [data-template=thanks] .thanks-page {
min-height: 60vh; min-height: 60vh;
display: flex; display: flex;
@ -1232,32 +1268,325 @@ body.is-fullscreen {
justify-content: center; justify-content: center;
padding: calc(var(--spacing) * 4) var(--spacing); padding: calc(var(--spacing) * 4) var(--spacing);
} }
[data-template=thanks] .thanks-content { [data-template=thanks] .thanks-page .thanks-content {
text-align: center; text-align: center;
max-width: 600px; max-width: 600px;
display: flex;
flex-direction: column;
align-items: center;
} }
[data-template=thanks] .thanks-content h1 { [data-template=thanks] .thanks-page .thanks-content h1 {
font-size: var(--fs-x-big);
margin-bottom: calc(var(--spacing) * 2); margin-bottom: calc(var(--spacing) * 2);
} }
[data-template=thanks] .thanks-content .thanks-message { [data-template=thanks] .thanks-page .thanks-content .thanks-message {
font-size: var(--fs-big); font-size: var(--fs-medium);
margin-bottom: calc(var(--spacing) * 3); line-height: 1.1;
line-height: 1.6;
} }
[data-template=thanks] .thanks-content .thanks-message p { [data-template=thanks] .thanks-page .thanks-content .thanks-message p {
margin-bottom: var(--spacing); margin-bottom: var(--spacing);
} }
[data-template=thanks] .thanks-content .thanks-actions { [data-template=thanks] .thanks-page .thanks-content .thanks-actions {
margin-top: calc(var(--spacing) * 3); width: -moz-max-content;
width: max-content;
} }
[data-template=thanks] #site-footer { [data-template=thanks] #site-footer {
border-top: none; border-top: none;
margin-top: calc(var(--spacing) * 4); margin-top: calc(var(--spacing) * 4);
} }
.snipcart-modal__container { .product-purchase {
z-index: 1000; margin-top: 2rem;
}
.product-stock-info {
margin-bottom: 1rem;
}
.stock-status {
font-size: 0.9rem;
font-weight: 600;
margin: 0;
}
.stock-status.in-stock {
color: #00cc00;
}
.stock-status.low-stock {
color: #ff9900;
}
.stock-status.out-of-stock {
color: #ff3333;
}
.btn-add-to-cart {
font-family: "Open Sans", sans-serif;
font-weight: bold;
font-size: 1rem;
color: #000000;
background-color: #00ff00;
border: none;
border-radius: 40px;
padding: 12px 34px;
cursor: pointer;
transition: background-color 0.3s ease;
width: 100%;
max-width: 300px;
}
.btn-add-to-cart:hover:not(:disabled) {
background-color: #00e600;
}
.btn-add-to-cart:focus {
outline: 2px solid #00e600;
outline-offset: 2px;
}
.btn-add-to-cart:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-add-to-cart.success {
background-color: #00cc00;
}
.btn-add-to-cart.error {
background-color: #ff3333;
color: #ffffff;
}
.btn-add-to-cart.out-of-stock {
background-color: #cccccc;
color: #666666;
}
/* Cart Drawer Styles */
.cart-drawer {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
color: #000;
}
.cart-drawer.is-open {
pointer-events: auto;
opacity: 1;
}
.cart-drawer.is-open .cart-drawer__panel {
transform: translateX(0);
}
.cart-drawer__overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
cursor: pointer;
}
.cart-drawer__panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 100%;
max-width: 420px;
background-color: #ffffff;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.3s ease;
}
@media (max-width: 768px) {
.cart-drawer__panel {
max-width: 100%;
}
}
.cart-drawer__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
border-bottom: 1px solid #e0e0e0;
}
.cart-drawer__header h3 {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
}
.cart-drawer__close {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s;
}
.cart-drawer__close:hover {
opacity: 0.7;
}
.cart-drawer__close svg {
stroke: #000;
}
.cart-drawer__content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.cart-drawer__content.is-loading {
opacity: 0.5;
pointer-events: none;
}
.cart-drawer__empty {
text-align: center;
padding: 3rem 1rem;
color: #666;
}
.cart-drawer__empty.hidden {
display: none;
}
.cart-drawer__items {
display: flex;
flex-direction: column;
gap: 1rem;
}
.cart-drawer__items.hidden {
display: none;
}
.cart-drawer__footer {
border-top: 1px solid #e0e0e0;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.cart-drawer__total {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.125rem;
font-weight: bold;
}
.cart-drawer__total-label {
color: #000;
}
.cart-drawer__total-amount {
color: #000;
font-size: 1.25rem;
}
.cart-drawer__checkout-btn {
width: 100%;
font-family: "Open Sans", sans-serif;
font-weight: bold;
font-size: 1rem;
color: #000000;
background-color: #00ff00;
border: none;
border-radius: 40px;
padding: 14px 34px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.cart-drawer__checkout-btn:hover:not(:disabled) {
background-color: #00e600;
}
.cart-drawer__checkout-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cart-item {
display: flex;
gap: 1rem;
padding: 1rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.cart-item__image {
width: 80px;
height: 80px;
-o-object-fit: cover;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.cart-item__details {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cart-item__title {
font-weight: 600;
margin: 0;
font-size: 1rem;
}
.cart-item__variant {
font-size: 0.875rem;
color: #666;
margin: 0;
}
.cart-item__price {
font-weight: bold;
color: #000;
}
.cart-item__quantity {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: auto;
}
.cart-item__qty-btn {
width: 28px;
height: 28px;
border: 1px solid #000;
background: #fff;
color: #000;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
font-weight: bold;
transition: all 0.2s;
}
.cart-item__qty-btn:hover:not(:disabled) {
background-color: #000;
color: #fff;
}
.cart-item__qty-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.cart-item__qty-value {
min-width: 30px;
text-align: center;
font-weight: 600;
}
.cart-item__remove {
background: none;
border: none;
color: #ff3333;
cursor: pointer;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
text-decoration: underline;
align-self: flex-start;
}
.cart-item__remove:hover {
color: #cc0000;
} }
[data-template=subscription-newsletter] main { [data-template=subscription-newsletter] main {

File diff suppressed because one or more lines are too long

View file

@ -22,6 +22,7 @@
@import "template/shop/layout"; @import "template/shop/layout";
@import "template/shop/section--product"; @import "template/shop/section--product";
@import "template/shop/thanks"; @import "template/shop/thanks";
@import "template/shop/snipcart"; @import "components/shopify-buy-button.scss";
@import "components/shopify-cart-drawer.scss";
@import "template/subscription-newsletter/layout"; @import "template/subscription-newsletter/layout";

View file

@ -1,6 +1,4 @@
[data-template="store"] { [data-template="home"] {
.p__baseline-big { .p__baseline-big {
margin-top: calc(var(--spacing) * 2); margin-top: calc(var(--spacing) * 2);
} }

View file

@ -1,12 +1,3 @@
.section__product,
.store__nav{
max-width: 1200px;
margin-left: auto;
margin-right: auto;
}
.section__product, .section__product,
.store__nav { .store__nav {
max-width: 1200px; max-width: 1200px;
@ -14,9 +5,9 @@
margin-right: auto; margin-right: auto;
} }
.product-content {
display: contents;
}
.store__nav { .store__nav {
padding-top: calc(var(--spacing) * 1); padding-top: calc(var(--spacing) * 1);
@ -27,174 +18,35 @@
a { a {
text-decoration: none; text-decoration: none;
&::before {
content: "";
}
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
} }
a::before { @media #{$small} {
content: ""; a {
} padding-top: 0;
} font-size: var(--fs-small);
.section__product .details {
// margin-bottom: calc(var(--spacing) * 2);
ul{
margin-left: 2ch;
li{
padding-bottom: 0.2em;
} }
} }
} }
.section__product {
.product-options__list { .details {
list-style: none; ul {
display: flex; margin-left: 2ch;
gap: 2ch;
li { li {
position: relative; padding-bottom: 0.2em;
input[type="radio"] {
position: fixed;
opacity: 0;
pointer-events: none;
}
label {
font-family: var(--title);
font-size: var(--fs-normal);
height: 4ch;
width: 4ch;
border-radius: 50%;
border: var(--border);
border-color: transparent;
display: flex;
align-items: center;
justify-content: center;
padding-top: 0px;
cursor: pointer;
}
input[type="radio"]:checked + label {
border-color: var(--color-txt);
}
input[type="radio"]:not(:checked) + label:hover {
border-color: var(--grey-600);
background-color: var(--grey-800);
}
}
}
.product-gallery {
position: relative;
aspect-ratio: 4 / 3;
.swiper-slide {
width: 100%;
figure {
aspect-ratio: 4 / 3;
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
object-fit: contain;
} }
} }
} }
// Swiper navigation arrows @media #{$small} {
.swiper-button-prev,
.swiper-button-next {
color: var(--color-txt);
width: 20px;
height: 20px;
&:after {
font-size: 20px;
font-weight: bold;
}
&:hover {
opacity: 0.7;
}
}
// Swiper pagination dots
.swiper-pagination {
position: relative;
margin-top: calc(var(--spacing) * 0.5);
bottom: 0;
.swiper-pagination-bullet {
width: 8px;
height: 8px;
background: var(--grey-600);
opacity: 0.5;
transition: opacity 0.3s;
&:hover {
opacity: 0.7;
}
}
.swiper-pagination-bullet-active {
background: var(--color-txt);
opacity: 1;
}
}
}
.hero {
margin-bottom: calc(var(--spacing) * 1);
padding: calc(var(--spacing) * 0.5) 0;
border-top: var(--border-light);
border-bottom: var(--border-light);
.p__baseline-big {
margin: 0;
text-align: left;
}
}
.add-to-cart {
margin: 0;
border-bottom: var(--border-light);
padding: calc(var(--spacing) * 0.5) 0;
}
.product-options {
border-bottom: var(--border-light);
padding: calc(var(--spacing) * 0.25) 0;
}
@media #{$small} {
.store__nav a {
padding-top: 0;
font-size: var(--fs-small);
}
.section__product {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-bottom: 10vh; margin-bottom: 10vh;
@ -207,6 +59,7 @@
margin-top: calc(var(--spacing) * 0.5); margin-top: calc(var(--spacing) * 0.5);
order: 1; order: 1;
} }
figure { figure {
order: 2; order: 2;
margin-bottom: calc(var(--spacing) * 1); margin-bottom: calc(var(--spacing) * 1);
@ -226,25 +79,25 @@
order: 5; order: 5;
} }
.product-gallery{ .product-gallery {
width: 100vw; width: 100vw;
position: relative; position: relative;
left: calc(var(--padding-body)*-1); left: calc(var(--padding-body) * -1);
.swiper-button-prev, .swiper-button-prev,
.swiper-button-next{ display: none; } .swiper-button-next {
display: none;
}
} }
} }
}
@media #{$small-up} { @media #{$small-up} {
.product-content {
display: grid;
.section__product{ grid-template-columns: 1fr 1fr;
display: grid; gap: calc(var(--padding-body) * 2);
grid-template-columns: 1fr 1fr; margin-bottom: calc(var(--spacing) * 3);
gap: calc(var(--padding-body)*2); }
margin-bottom: calc(var(--spacing)*3);
.details { .details {
margin-bottom: calc(var(--spacing) * 2); margin-bottom: calc(var(--spacing) * 2);
@ -255,17 +108,140 @@
border-top: var(--border-light); border-top: var(--border-light);
} }
.col-left{ .col-left {
min-height: 100%; min-height: 100%;
padding-bottom: 40px; //dots padding-bottom: 40px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
}
.product-gallery .swiper-slide figure{
width: calc(100% - 60px);
} }
} }
.product-options {
border-bottom: var(--border-light);
padding: calc(var(--spacing) * 0.25) 0;
}
.product-options__list {
list-style: none;
display: flex;
gap: 2ch;
li {
position: relative;
input[type="radio"] {
position: fixed;
opacity: 0;
pointer-events: none;
&:checked + label {
border-color: var(--color-txt);
}
&:not(:checked) + label:hover {
border-color: var(--grey-600);
background-color: var(--grey-800);
}
}
label {
font-family: var(--title);
font-size: var(--fs-normal);
height: 4ch;
width: 4ch;
border-radius: 50%;
border: var(--border);
border-color: transparent;
display: flex;
align-items: center;
justify-content: center;
padding-top: 0px;
cursor: pointer;
}
}
}
.product-gallery {
position: relative;
aspect-ratio: 4 / 3;
.swiper-slide {
width: 100%;
figure {
aspect-ratio: 4 / 3;
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
@media #{$small-up} {
figure {
width: calc(100% - 60px);
}
}
}
.swiper-button-prev,
.swiper-button-next {
color: var(--color-txt);
width: 20px;
height: 20px;
&:after {
font-size: 20px;
font-weight: bold;
}
&:hover {
opacity: 0.7;
}
}
.swiper-pagination {
position: relative;
margin-top: calc(var(--spacing) * 0.5);
bottom: 0;
.swiper-pagination-bullet {
width: 8px;
height: 8px;
background: var(--grey-600);
opacity: 0.5;
transition: opacity 0.3s;
&:hover {
opacity: 0.7;
}
&.swiper-pagination-bullet-active {
background: var(--color-txt);
opacity: 1;
}
}
}
}
.hero {
margin-bottom: calc(var(--spacing) * 1);
padding: calc(var(--spacing) * 0.5) 0;
border-top: var(--border-light);
border-bottom: var(--border-light);
.p__baseline-big {
margin: 0;
text-align: left;
}
}
.add-to-cart {
margin: 0;
border-bottom: var(--border-light);
padding: calc(var(--spacing) * 0.5) 0;
}

View file

@ -5,29 +5,30 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: calc(var(--spacing) * 4) var(--spacing); padding: calc(var(--spacing) * 4) var(--spacing);
}
.thanks-content { .thanks-content {
text-align: center; text-align: center;
max-width: 600px; max-width: 600px;
display: flex;
flex-direction: column;
align-items: center;
h1 { h1 {
font-size: var(--fs-x-big); margin-bottom: calc(var(--spacing) * 2);
margin-bottom: calc(var(--spacing) * 2);
}
.thanks-message {
font-size: var(--fs-big);
margin-bottom: calc(var(--spacing) * 3);
line-height: 1.6;
p {
margin-bottom: var(--spacing);
} }
}
.thanks-actions { .thanks-message {
margin-top: calc(var(--spacing) * 3); font-size: var(--fs-medium);
line-height: 1.1;
p {
margin-bottom: var(--spacing);
}
}
.thanks-actions {
width: max-content;
}
} }
} }

290
assets/js/cart-drawer.js Normal file
View file

@ -0,0 +1,290 @@
/**
* Cart Drawer Component
* Manages the cart sidebar with add/remove/update functionality
*/
(function() {
const drawer = document.getElementById('cart-drawer');
const emptyState = document.querySelector('[data-cart-empty]');
const itemsContainer = document.querySelector('[data-cart-items]');
const checkoutBtn = document.querySelector('[data-cart-checkout]');
const closeButtons = document.querySelectorAll('[data-cart-close]');
const totalDisplay = document.querySelector('[data-cart-total]');
const headerCartBtn = document.querySelector('[data-cart-open]');
const headerCartCount = document.querySelector('[data-cart-count]');
// Get translated text
const removeText = drawer.dataset.textRemove || 'Remove';
let currentCart = null;
let cartInstance = null;
// Wait for ShopifyCart to be available
function initCartDrawer() {
if (typeof ShopifyCart === 'undefined') {
setTimeout(initCartDrawer, 100);
return;
}
cartInstance = new ShopifyCart({
domain: 'nv7cqv-bu.myshopify.com',
storefrontAccessToken: 'dec3d35a2554384d149c72927d1cfd1b'
});
// Initialize event listeners
setupEventListeners();
// Load initial cart state
loadCart();
}
function setupEventListeners() {
// Close drawer
closeButtons.forEach(btn => {
btn.addEventListener('click', closeDrawer);
});
// Open drawer from header button
if (headerCartBtn) {
headerCartBtn.addEventListener('click', openDrawer);
}
// Checkout button
checkoutBtn.addEventListener('click', () => {
if (currentCart?.checkoutUrl) {
window.location.href = currentCart.checkoutUrl;
}
});
// Listen for custom cart update events
document.addEventListener('cart:updated', (e) => {
currentCart = e.detail.cart;
renderCart();
openDrawer();
});
}
async function loadCart() {
if (!cartInstance) return;
try {
const cart = await cartInstance.getCart();
currentCart = cart;
renderCart();
} catch (error) {
console.error('Error loading cart:', error);
}
}
function openDrawer() {
drawer.classList.add('is-open');
document.body.style.overflow = 'hidden';
}
function closeDrawer() {
drawer.classList.remove('is-open');
document.body.style.overflow = '';
}
function calculateTotal() {
if (!currentCart || !currentCart.lines) return 0;
return currentCart.lines.edges.reduce((total, edge) => {
const item = edge.node;
const price = parseFloat(item.merchandise.price.amount);
const quantity = item.quantity;
return total + (price * quantity);
}, 0);
}
function formatPrice(amount, currency = 'EUR') {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: currency
}).format(amount);
}
function updateCartCount() {
if (!currentCart || !currentCart.lines || currentCart.lines.edges.length === 0) {
if (headerCartCount) {
headerCartCount.textContent = '';
}
return;
}
// Calculate total quantity
const totalQty = currentCart.lines.edges.reduce((sum, edge) => {
return sum + edge.node.quantity;
}, 0);
if (headerCartCount) {
headerCartCount.textContent = totalQty > 0 ? totalQty : '';
}
}
function renderCart() {
if (!currentCart || !currentCart.lines || currentCart.lines.edges.length === 0) {
emptyState.classList.remove('hidden');
itemsContainer.classList.add('hidden');
checkoutBtn.disabled = true;
if (totalDisplay) {
totalDisplay.textContent = '0,00 €';
}
updateCartCount();
return;
}
emptyState.classList.add('hidden');
itemsContainer.classList.remove('hidden');
checkoutBtn.disabled = false;
// Calculate and display total
const total = calculateTotal();
const currency = currentCart.lines.edges[0]?.node.merchandise.price.currencyCode || 'EUR';
if (totalDisplay) {
totalDisplay.textContent = formatPrice(total, currency);
}
// Update header cart count
updateCartCount();
// Render cart items
itemsContainer.innerHTML = currentCart.lines.edges.map(edge => {
const item = edge.node;
const merchandise = item.merchandise;
return `
<div class="cart-item" data-line-id="${item.id}">
<div class="cart-item__details">
<h4 class="cart-item__title">${merchandise.product.title}</h4>
${merchandise.title !== 'Default Title' ? `<p class="cart-item__variant">${merchandise.title}</p>` : ''}
<p class="cart-item__price">${formatPrice(parseFloat(merchandise.price.amount), merchandise.price.currencyCode)}</p>
<div class="cart-item__quantity">
<button class="cart-item__qty-btn" data-action="decrease" data-line-id="${item.id}"></button>
<span class="cart-item__qty-value">${item.quantity}</span>
<button class="cart-item__qty-btn" data-action="increase" data-line-id="${item.id}">+</button>
</div>
<button class="cart-item__remove" data-action="remove" data-line-id="${item.id}">
${removeText}
</button>
</div>
</div>
`;
}).join('');
// Attach event listeners to quantity buttons
attachQuantityListeners();
}
function attachQuantityListeners() {
const buttons = itemsContainer.querySelectorAll('[data-action]');
buttons.forEach(btn => {
btn.addEventListener('click', async (e) => {
const action = e.target.dataset.action;
const lineId = e.target.dataset.lineId;
await handleQuantityChange(action, lineId);
});
});
}
async function handleQuantityChange(action, lineId) {
if (!cartInstance || !currentCart) return;
// Find the line item
const line = currentCart.lines.edges.find(edge => edge.node.id === lineId);
if (!line) return;
const currentQty = line.node.quantity;
let newQty = currentQty;
if (action === 'increase') {
newQty = currentQty + 1;
} else if (action === 'decrease') {
newQty = Math.max(0, currentQty - 1);
} else if (action === 'remove') {
newQty = 0;
}
// Update cart via API
try {
itemsContainer.classList.add('is-loading');
const query = `
mutation cartLinesUpdate($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
id
checkoutUrl
lines(first: 10) {
edges {
node {
id
quantity
merchandise {
... on ProductVariant {
id
title
price {
amount
currencyCode
}
product {
title
}
}
}
}
}
}
}
userErrors {
field
message
}
}
}
`;
const data = await cartInstance.query(query, {
cartId: currentCart.id,
lines: [{
id: lineId,
quantity: newQty
}]
});
if (data.cartLinesUpdate.userErrors.length > 0) {
throw new Error(data.cartLinesUpdate.userErrors[0].message);
}
currentCart = data.cartLinesUpdate.cart;
renderCart();
} catch (error) {
console.error('Error updating cart:', error);
alert('Erreur lors de la mise à jour du panier');
} finally {
itemsContainer.classList.remove('is-loading');
}
}
// Public API
window.CartDrawer = {
open: openDrawer,
close: closeDrawer,
updateCart: (cart) => {
currentCart = cart;
renderCart();
}
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCartDrawer);
} else {
initCartDrawer();
}
})();

View file

@ -0,0 +1,70 @@
(function() {
const cart = new ShopifyCart({
domain: 'nv7cqv-bu.myshopify.com',
storefrontAccessToken: 'dec3d35a2554384d149c72927d1cfd1b'
});
const addToCartBtn = document.querySelector('[data-shopify-add-to-cart]');
if (!addToCartBtn) {
return;
}
const buttonTextDiv = addToCartBtn.querySelector('.txt[data-button-text]');
if (!buttonTextDiv) {
console.error('Button text div not found');
return;
}
const texts = {
add: addToCartBtn.dataset.textAdd || 'Add to cart',
adding: addToCartBtn.dataset.textAdding || 'Adding...',
added: addToCartBtn.dataset.textAdded || 'Added! ✓',
error: addToCartBtn.dataset.textError || 'Error - Try again'
};
addToCartBtn.addEventListener('click', async function(e) {
e.preventDefault();
const variantId = this.dataset.variantId;
if (!variantId) {
console.error('No variant ID found');
return;
}
addToCartBtn.disabled = true;
const originalText = buttonTextDiv.textContent;
buttonTextDiv.textContent = texts.adding;
try {
const cartResult = await cart.addToCart(variantId, 1);
buttonTextDiv.textContent = texts.added;
addToCartBtn.classList.add('success');
document.dispatchEvent(new CustomEvent('cart:updated', {
detail: { cart: cartResult }
}));
setTimeout(() => {
addToCartBtn.disabled = false;
buttonTextDiv.textContent = originalText;
addToCartBtn.classList.remove('success');
}, 1500);
} catch (error) {
console.error('Error adding to cart:', error);
buttonTextDiv.textContent = texts.error;
addToCartBtn.classList.add('error');
setTimeout(() => {
addToCartBtn.disabled = false;
buttonTextDiv.textContent = originalText;
addToCartBtn.classList.remove('error');
}, 2000);
}
});
})();

306
assets/js/product-loader.js Normal file
View file

@ -0,0 +1,306 @@
(async function () {
const container = document.querySelector("[data-product-loader]");
if (!container) return;
const handle = container.dataset.shopifyHandle;
const language = container.dataset.language || "fr";
const isEnglish = language === "en";
const loadingState = container.querySelector(".product-loading");
const contentState = container.querySelector(".product-content");
const errorState = container.querySelector(".product-error");
try {
const cart = new ShopifyCart({
domain: "nv7cqv-bu.myshopify.com",
storefrontAccessToken: "dec3d35a2554384d149c72927d1cfd1b",
});
const product = await cart.getProductByHandle(handle);
if (!product) {
throw new Error("Product not found");
}
renderProduct(product, isEnglish);
updateMetaTags(product, isEnglish);
loadingState.style.display = "none";
contentState.removeAttribute("style");
setTimeout(() => {
if (typeof Swiper !== "undefined" && product.images.edges.length > 0) {
new Swiper(".product-gallery", {
loop: product.images.edges.length > 1,
navigation: {
nextEl: ".swiper-button-next",
prevEl: ".swiper-button-prev",
},
pagination: {
el: ".swiper-pagination",
clickable: true,
},
keyboard: {
enabled: true,
},
});
}
}, 100);
} catch (error) {
console.error("Error loading product:", error);
loadingState.style.display = "none";
errorState.style.display = "block";
}
function renderProduct(product, isEnglish) {
renderTitle(product, isEnglish);
renderPrice(product);
renderDetails(product, isEnglish);
renderImages(product, isEnglish);
renderOptions(product);
setupAddToCart(product);
}
function renderTitle(product, isEnglish) {
const titleEl = document.querySelector("[data-product-title]");
if (titleEl) {
const title =
isEnglish && product.titleEn?.value
? product.titleEn.value
: product.title;
titleEl.textContent = title;
}
}
function renderPrice(product) {
const priceEl = document.querySelector("[data-product-price]");
if (priceEl) {
const price = parseFloat(product.priceRange.minVariantPrice.amount);
priceEl.textContent = price.toFixed(2) + "€";
}
}
function renderDetails(product, isEnglish) {
const detailsEl = document.querySelector("[data-product-details]");
if (detailsEl) {
const description =
isEnglish && product.descriptionEn?.value
? product.descriptionEn.value.replaceAll("\n", "<br>")
: product.descriptionHtml || "";
detailsEl.innerHTML = description;
}
}
function renderImages(product, isEnglish) {
const imagesContainer = document.querySelector("[data-product-images]");
if (imagesContainer && product.images.edges.length > 0) {
const productTitle =
isEnglish && product.titleEn?.value
? product.titleEn.value
: product.title;
imagesContainer.innerHTML = product.images.edges
.map((edge) => {
const img = edge.node;
return `
<div class="swiper-slide">
<figure>
<img src="${img.url}"
alt="${img.altText || productTitle}"
loading="lazy" />
</figure>
</div>
`;
})
.join("");
}
}
function renderOptions(product) {
if (product.variants.edges.length <= 1) return;
const firstVariant = product.variants.edges[0].node;
if (
!firstVariant.selectedOptions ||
firstVariant.selectedOptions.length === 0
)
return;
const mainOption = firstVariant.selectedOptions[0];
const optionValues = new Set();
product.variants.edges.forEach((edge) => {
const variant = edge.node;
if (variant.selectedOptions && variant.selectedOptions[0]) {
optionValues.add(variant.selectedOptions[0].value);
}
});
if (optionValues.size <= 1) return;
const optionsContainer = document.querySelector("[data-product-options]");
const optionsList = document.querySelector("[data-product-options-list]");
if (!optionsContainer || !optionsList) return;
const optionName = mainOption.name;
const optionSlug = optionName.toLowerCase().replace(/\s+/g, "-");
optionsList.innerHTML = Array.from(optionValues)
.map((value) => {
const uniqueId = `${optionSlug}-${value
.toLowerCase()
.replace(/\s+/g, "-")}`;
const variant = product.variants.edges.find(
(e) =>
e.node.selectedOptions && e.node.selectedOptions[0]?.value === value
)?.node;
const isAvailable = variant?.availableForSale || false;
return `
<li>
<input
type="radio"
id="${uniqueId}"
name="${optionSlug}"
value="${value}"
data-variant-id="${
variant
? variant.id.replace("gid://shopify/ProductVariant/", "")
: ""
}"
${!isAvailable ? "disabled" : ""}
/>
<label for="${uniqueId}">${value}</label>
</li>
`;
})
.join("");
optionsContainer.style.display = "block";
const radios = optionsList.querySelectorAll('input[type="radio"]');
const addToCartBtn = document.querySelector("[data-shopify-add-to-cart]");
const buttonText = addToCartBtn?.querySelector("[data-button-text]");
radios.forEach((radio) => {
radio.addEventListener("change", function () {
const variantId = this.dataset.variantId;
if (addToCartBtn) {
addToCartBtn.dataset.variantId = variantId;
addToCartBtn.removeAttribute("disabled");
}
if (buttonText) {
buttonText.textContent =
addToCartBtn.dataset.defaultText || "Ajouter au panier";
}
const allLi = optionsList.querySelectorAll("li");
allLi.forEach((li) => li.classList.remove("is-selected"));
this.closest("li").classList.add("is-selected");
});
});
}
function setupAddToCart(product) {
const addToCartBtn = document.querySelector("[data-shopify-add-to-cart]");
if (!addToCartBtn) return;
const productId = product.id.replace("gid://shopify/Product/", "");
addToCartBtn.dataset.productId = productId;
const hasMultipleVariants = product.variants.edges.length > 1;
const firstVariant = product.variants.edges[0]?.node;
const hasOptions =
firstVariant?.selectedOptions && firstVariant.selectedOptions.length > 0;
const uniqueOptions = new Set();
product.variants.edges.forEach((edge) => {
if (edge.node.selectedOptions && edge.node.selectedOptions[0]) {
uniqueOptions.add(edge.node.selectedOptions[0].value);
}
});
const hasMultipleOptions = uniqueOptions.size > 1;
if (hasMultipleVariants && hasOptions && hasMultipleOptions) {
addToCartBtn.setAttribute("disabled", "disabled");
const buttonText = addToCartBtn.querySelector("[data-button-text]");
if (buttonText) {
buttonText.textContent =
addToCartBtn.dataset.textChooseOption || "Choisissez une option";
}
} else {
const firstAvailableVariant = product.variants.edges.find(
(e) => e.node.availableForSale
);
if (firstAvailableVariant) {
const variantId = firstAvailableVariant.node.id.replace(
"gid://shopify/ProductVariant/",
""
);
addToCartBtn.dataset.variantId = variantId;
}
}
}
function updateMetaTags(product, isEnglish) {
// Update title and description
const title =
isEnglish && product.titleEn?.value
? product.titleEn.value
: product.title;
const description =
isEnglish && product.descriptionEn?.value
? product.descriptionEn.value
: product.description;
// Update Open Graph title
const ogTitle = document.getElementById("og-title");
if (ogTitle) {
ogTitle.setAttribute("content", title);
}
// Update Open Graph description
const ogDescription = document.getElementById("og-description");
if (ogDescription && description) {
const excerpt = description.substring(0, 160);
ogDescription.setAttribute("content", excerpt);
}
// Update Open Graph image
const ogImage = document.getElementById("og-image");
if (ogImage && product.images.edges.length > 0) {
ogImage.setAttribute("content", product.images.edges[0].node.url);
}
// Update product price
const ogPrice = document.getElementById("og-price");
if (ogPrice) {
const price = parseFloat(
product.priceRange.minVariantPrice.amount
).toFixed(2);
ogPrice.setAttribute("content", price);
}
// Update availability
const ogAvailability = document.getElementById("og-availability");
if (ogAvailability) {
const availability = product.availableForSale
? "in stock"
: "out of stock";
ogAvailability.setAttribute("content", availability);
}
// Update page title
document.title = `${title} | Index.ngo`;
// Update meta description
let metaDescription = document.querySelector('meta[name="description"]');
if (metaDescription && description) {
const excerpt = description.substring(0, 160);
metaDescription.setAttribute("content", excerpt);
}
}
})();

View file

@ -0,0 +1,47 @@
(async function() {
const container = document.querySelector('[data-products-loader]');
if (!container) return;
const language = (container.dataset.language || 'FR').toLowerCase();
const isEnglish = language === 'en';
try {
const cart = new ShopifyCart({
domain: 'nv7cqv-bu.myshopify.com',
storefrontAccessToken: 'dec3d35a2554384d149c72927d1cfd1b'
});
const productsData = await cart.getAllProducts();
const products = productsData.edges;
const productsHtml = products.map(edge => {
const product = edge.node;
const image = product.images.edges[0]?.node;
const price = parseFloat(product.priceRange.minVariantPrice.amount).toFixed(2);
const slug = product.handle;
const productUrl = isEnglish ? `/en/${slug}` : `/${slug}`;
const productTitle = isEnglish && product.titleEn?.value
? product.titleEn.value
: product.title;
return `
<article class="store__product">
<figure>
${image ? `<img src="${image.url}" alt="${image.altText || productTitle}" loading="lazy" />` : ''}
</figure>
<p class="line-1">
<a href="${productUrl}">${productTitle}</a>
</p>
<p class="price">${price}</p>
<a href="${productUrl}" class="link-block" aria-hidden="true"></a>
</article>
`;
}).join('');
container.innerHTML = productsHtml;
} catch (error) {
console.error('Error loading products:', error);
container.innerHTML = '<p>Erreur lors du chargement des produits</p>';
}
})();

395
assets/js/shopify-cart.js Normal file
View file

@ -0,0 +1,395 @@
/**
* Shopify Storefront API Client
* Custom implementation using Cart API (2026-01)
*/
class ShopifyCart {
constructor(config) {
this.domain = config.domain;
this.storefrontAccessToken = config.storefrontAccessToken;
this.apiVersion = '2026-01';
this.endpoint = `https://${this.domain}/api/${this.apiVersion}/graphql.json`;
this.cartId = null;
this.cartItems = [];
// Load existing cart from localStorage
this.loadCart();
}
/**
* Make GraphQL request to Shopify Storefront API
*/
async query(query, variables = {}) {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': this.storefrontAccessToken,
},
body: JSON.stringify({ query, variables }),
});
const result = await response.json();
if (result.errors) {
console.error('Shopify API Error:', result.errors);
throw new Error(result.errors[0].message);
}
return result.data;
}
/**
* Get product information by ID
*/
async getProduct(productId) {
const query = `
query getProduct($id: ID!) {
product(id: $id) {
id
title
description
availableForSale
variants(first: 10) {
edges {
node {
id
title
price {
amount
currencyCode
}
availableForSale
}
}
}
}
}
`;
const data = await this.query(query, {
id: `gid://shopify/Product/${productId}`
});
return data.product;
}
/**
* Get product by handle
* @param {string} handle - Product handle (slug)
*/
async getProductByHandle(handle) {
const query = `
query getProductByHandle($handle: String!) {
product(handle: $handle) {
id
handle
title
description
descriptionHtml
availableForSale
tags
titleEn: metafield(namespace: "custom", key: "title_en") {
value
}
descriptionEn: metafield(namespace: "custom", key: "description_en") {
value
}
priceRange {
minVariantPrice {
amount
currencyCode
}
}
images(first: 10) {
edges {
node {
id
url
altText
width
height
}
}
}
variants(first: 20) {
edges {
node {
id
title
sku
availableForSale
price {
amount
currencyCode
}
selectedOptions {
name
value
}
}
}
}
}
}
`;
const data = await this.query(query, { handle });
return data.product || null;
}
/**
* Get all products for listing page
* @param {number} first - Number of products to fetch
*/
async getAllProducts(first = 20) {
const query = `
query getAllProducts($first: Int!) {
products(first: $first, sortKey: TITLE) {
edges {
node {
id
handle
title
description
availableForSale
titleEn: metafield(namespace: "custom", key: "title_en") {
value
}
priceRange {
minVariantPrice {
amount
currencyCode
}
}
images(first: 1) {
edges {
node {
id
url
altText
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
const data = await this.query(query, { first });
return data.products;
}
/**
* Create a new cart
*/
async createCart(lines = []) {
const query = `
mutation cartCreate($input: CartInput!) {
cartCreate(input: $input) {
cart {
id
checkoutUrl
lines(first: 10) {
edges {
node {
id
quantity
merchandise {
... on ProductVariant {
id
title
price {
amount
currencyCode
}
product {
title
}
}
}
}
}
}
}
userErrors {
field
message
}
}
}
`;
const data = await this.query(query, {
input: { lines }
});
if (data.cartCreate.userErrors.length > 0) {
throw new Error(data.cartCreate.userErrors[0].message);
}
this.cartId = data.cartCreate.cart.id;
this.saveCart();
return data.cartCreate.cart;
}
/**
* Add item to cart
*/
async addToCart(variantId, quantity = 1) {
const lines = [{
merchandiseId: `gid://shopify/ProductVariant/${variantId}`,
quantity: quantity
}];
let cart;
if (this.cartId) {
// Add to existing cart
const query = `
mutation cartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
id
checkoutUrl
lines(first: 10) {
edges {
node {
id
quantity
merchandise {
... on ProductVariant {
id
title
price {
amount
currencyCode
}
product {
title
}
}
}
}
}
}
}
userErrors {
field
message
}
}
}
`;
const data = await this.query(query, {
cartId: this.cartId,
lines
});
if (data.cartLinesAdd.userErrors.length > 0) {
throw new Error(data.cartLinesAdd.userErrors[0].message);
}
cart = data.cartLinesAdd.cart;
} else {
// Create new cart
cart = await this.createCart(lines);
}
this.cartItems = cart.lines.edges;
return cart;
}
/**
* Get existing cart by ID
*/
async getCart() {
if (!this.cartId) {
return null;
}
const query = `
query getCart($cartId: ID!) {
cart(id: $cartId) {
id
checkoutUrl
lines(first: 10) {
edges {
node {
id
quantity
merchandise {
... on ProductVariant {
id
title
price {
amount
currencyCode
}
product {
title
}
}
}
}
}
}
}
}
`;
try {
const data = await this.query(query, {
cartId: this.cartId
});
return data.cart;
} catch (error) {
// Cart might be expired or invalid
console.error('Error fetching cart:', error);
this.clearCart();
return null;
}
}
/**
* Get checkout URL to redirect user
*/
getCheckoutUrl(cart) {
return cart?.checkoutUrl || null;
}
/**
* Save cart ID to localStorage
*/
saveCart() {
if (this.cartId) {
localStorage.setItem('shopify_cart_id', this.cartId);
}
}
/**
* Load cart ID from localStorage
*/
loadCart() {
this.cartId = localStorage.getItem('shopify_cart_id');
}
/**
* Clear cart
*/
clearCart() {
this.cartId = null;
this.cartItems = [];
localStorage.removeItem('shopify_cart_id');
}
}
// Export for use in other scripts
window.ShopifyCart = ShopifyCart;

View file

@ -6,13 +6,13 @@ window.SnipcartSettings = {
// Redirection après paiement réussi // Redirection après paiement réussi
document.addEventListener('snipcart.ready', function() { document.addEventListener('snipcart.ready', function() {
Snipcart.execute('bind', 'order.completed', function(order) { Snipcart.events.on('cart.confirmed', function(cartState) {
// Détecter la langue actuelle depuis l'URL // Détecter la langue actuelle depuis l'URL
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
const langMatch = currentPath.match(/^\/([a-z]{2})(\/|$)/); const langMatch = currentPath.match(/^\/([a-z]{2})(\/|$)/);
const langPrefix = langMatch ? '/' + langMatch[1] : ''; const langPrefix = langMatch ? '/' + langMatch[1] : '';
window.location.href = langPrefix + '/thanks?order=' + order.token; window.location.href = langPrefix + '/thanks?order=' + cartState.token;
}); });
}); });

View file

@ -0,0 +1,164 @@
# Archive Snipcart
Cette archive contient tous les fichiers et le code nécessaires pour réactiver l'intégration Snipcart si besoin.
## Date d'archivage
13 janvier 2026
## Raison de l'archivage
Remplacement par l'intégration Shopify Buy Button
---
## Fichiers archivés
### Fichiers JavaScript
- `snipcart.js` - Loader Snipcart avec configuration de la clé API
- `product-size.js` - Gestion des options produit (tailles, etc.) pour Snipcart
### Fichiers SCSS
- `_snipcart.scss` - Styles pour le modal Snipcart
---
## Comment restaurer l'intégration Snipcart
### 1. Restaurer les fichiers JavaScript
#### Fichier: `assets/js/snipcart.js`
Copier le fichier depuis l'archive:
```bash
cp assets/snipcart-archive/snipcart.js assets/js/
```
#### Fichier: `assets/js/product-size.js`
Copier le fichier depuis l'archive:
```bash
cp assets/snipcart-archive/product-size.js assets/js/
```
### 2. Restaurer les styles SCSS
#### Fichier: `assets/css/template/shop/_snipcart.scss`
Copier le fichier depuis l'archive:
```bash
cp assets/snipcart-archive/_snipcart.scss assets/css/template/shop/
```
Puis décommenter l'import dans `assets/css/style.scss`:
```scss
// Décommenter cette ligne:
@import 'template/shop/snipcart';
```
### 3. Restaurer le template produit
#### Fichier: `site/templates/product.php`
1. **Restaurer le bouton "Ajouter au panier" avec les attributs Snipcart** (ligne ~40-78):
- Décommenter tous les attributs `data-item-*` du bouton
- Ajouter la classe `snipcart-add-item` au bouton
2. **Restaurer les scripts dans le footer** (ligne 114):
```php
<?php snippet('footer', ['scripts' => ['assets/js/product-size.js', 'assets/js/snipcart.js', 'assets/js/product-gallery.js']]) ?>
```
### 4. Restaurer les routes et webhooks
#### Fichier: `site/config/config.php`
Décommenter les routes Snipcart (lignes 39-147):
1. **Route de validation produit** (`validate.json`):
- Permet à Snipcart de valider les prix et stock
- Route: `(:any)/validate.json`
2. **Webhook Snipcart**:
- Gère les événements de commande (décrémente le stock)
- Route: `snipcart-webhook`
### 5. Configuration Snipcart
#### Clé API publique
La clé API publique Snipcart est dans `snipcart.js`:
```javascript
publicApiKey: 'NGU4ODQ3MjAtY2MzMC00MWEyLWI2YTMtNjBmNGYzMTBlOTZkNjM4OTY1NDY4OTE5MTQyMTI3'
```
#### Configuration du webhook
Pour que le webhook fonctionne, il faut:
1. Configurer l'URL du webhook dans le dashboard Snipcart
2. URL: `https://votre-domaine.com/snipcart-webhook`
3. Événements à écouter: `order.completed`
### 6. Vérifications après restauration
- [ ] Les fichiers JS sont présents dans `assets/js/`
- [ ] Le fichier SCSS est présent et importé
- [ ] Les boutons "Ajouter au panier" ont la classe `snipcart-add-item`
- [ ] Les attributs `data-item-*` sont présents sur les boutons
- [ ] Les routes sont décommentées dans `config.php`
- [ ] Le CSS de Snipcart est compilé
- [ ] Le webhook est configuré dans le dashboard Snipcart
- [ ] Les traductions sont restaurées dans `site/languages/en.php` et `fr.php`
---
## Fonctionnalités Snipcart implémentées
### Gestion des produits
- Affichage du prix
- Options de produit (tailles, couleurs, etc.)
- Validation des options obligatoires
- Images produit
### Gestion du panier
- Ajout au panier
- Gestion du stock
- Calcul des frais de port (basé sur poids/dimensions)
- Validation des prix côté serveur
### Gestion des commandes
- Webhook pour décrémenter le stock automatiquement
- Redirection vers page de remerciement après paiement
- Token de commande dans l'URL
### Multi-langue
- Support FR/EN
- Redirection post-paiement avec détection de la langue
---
## Dépendances externes
### CDN Snipcart
Snipcart est chargé depuis le CDN officiel:
- Version: 3.0
- JS: `https://cdn.snipcart.com/themes/v3.0/default/snipcart.js`
- CSS: `https://cdn.snipcart.com/themes/v3.0/default/snipcart.css`
### Stratégie de chargement
- `loadStrategy: 'on-user-interaction'`
- Chargement différé pour optimiser les performances
- Timeout: 2750ms
---
## Notes importantes
1. **Sécurité**: Le webhook devrait valider la signature Snipcart en production (voir commentaire dans `config.php`)
2. **Stock**: Le système décrémente automatiquement le stock via le webhook `order.completed`
3. **Validation**: Chaque produit expose une route `validate.json` pour que Snipcart puisse vérifier les prix
4. **Multi-langue**: La redirection post-paiement détecte automatiquement la langue depuis l'URL
---
## Support
Pour toute question sur Snipcart:
- Documentation: https://docs.snipcart.com/
- Support: https://snipcart.com/support

View file

@ -0,0 +1,66 @@
/**
* Gestion de la sélection des options produit
* Met à jour les attributs Snipcart et gère les classes CSS
*/
(function() {
'use strict';
/**
* Initialise la gestion des options
*/
function initOptionSelector() {
const optionsContainer = document.querySelector('.product-options');
const addToCartButton = document.querySelector('.snipcart-add-item');
if (!addToCartButton) {
return;
}
// Si pas d'options, le bouton est déjà actif
if (!optionsContainer) {
return;
}
const radios = optionsContainer.querySelectorAll('input[type="radio"]');
// Réinitialiser toutes les options (important pour le cache navigateur)
radios.forEach(radio => {
radio.checked = false;
});
// Retirer la classe is-selected de tous les li
const allLi = optionsContainer.querySelectorAll('li');
allLi.forEach(li => li.classList.remove('is-selected'));
// S'assurer que le bouton est désactivé au départ
addToCartButton.setAttribute('disabled', 'disabled');
// Écouter les changements de sélection
radios.forEach(radio => {
radio.addEventListener('change', function() {
// Mettre à jour l'attribut Snipcart
addToCartButton.setAttribute('data-item-custom1-value', this.value);
// Activer le bouton
addToCartButton.removeAttribute('disabled');
// Changer le texte du bouton
const buttonText = addToCartButton.querySelector('.txt');
if (buttonText) {
buttonText.textContent = buttonText.getAttribute('data-default-text') || 'Ajouter au panier';
}
// Gérer la classe is-selected sur les li parents
allLi.forEach(li => li.classList.remove('is-selected'));
this.closest('li').classList.add('is-selected');
});
});
}
/**
* Initialisation au chargement de la page
*/
document.addEventListener('DOMContentLoaded', initOptionSelector);
})();

View file

@ -0,0 +1,94 @@
window.SnipcartSettings = {
publicApiKey:
'NGU4ODQ3MjAtY2MzMC00MWEyLWI2YTMtNjBmNGYzMTBlOTZkNjM4OTY1NDY4OTE5MTQyMTI3',
loadStrategy: 'on-user-interaction',
};
// Redirection après paiement réussi
document.addEventListener('snipcart.ready', function() {
Snipcart.events.on('cart.confirmed', function(cartState) {
// Détecter la langue actuelle depuis l'URL
const currentPath = window.location.pathname;
const langMatch = currentPath.match(/^\/([a-z]{2})(\/|$)/);
const langPrefix = langMatch ? '/' + langMatch[1] : '';
window.location.href = langPrefix + '/thanks?order=' + cartState.token;
});
});
(() => {
var c, d;
(d = (c = window.SnipcartSettings).version) != null || (c.version = '3.0');
var s, S;
(S = (s = window.SnipcartSettings).timeoutDuration) != null ||
(s.timeoutDuration = 2750);
var l, p;
(p = (l = window.SnipcartSettings).domain) != null ||
(l.domain = 'cdn.snipcart.com');
var w, u;
(u = (w = window.SnipcartSettings).protocol) != null ||
(w.protocol = 'https');
var f =
window.SnipcartSettings.version.includes('v3.0.0-ci') ||
(window.SnipcartSettings.version != '3.0' &&
window.SnipcartSettings.version.localeCompare('3.4.0', void 0, {
numeric: !0,
sensitivity: 'base',
}) === -1),
m = ['focus', 'mouseover', 'touchmove', 'scroll', 'keydown'];
window.LoadSnipcart = o;
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', r)
: r();
function r() {
window.SnipcartSettings.loadStrategy
? window.SnipcartSettings.loadStrategy === 'on-user-interaction' &&
(m.forEach((t) => document.addEventListener(t, o)),
setTimeout(o, window.SnipcartSettings.timeoutDuration))
: o();
}
var a = !1;
function o() {
if (a) return;
a = !0;
let t = document.getElementsByTagName('head')[0],
e = document.querySelector('#snipcart'),
i = document.querySelector(
`src[src^="${window.SnipcartSettings.protocol}://${window.SnipcartSettings.domain}"][src$="snipcart.js"]`
),
n = document.querySelector(
`link[href^="${window.SnipcartSettings.protocol}://${window.SnipcartSettings.domain}"][href$="snipcart.css"]`
);
e ||
((e = document.createElement('div')),
(e.id = 'snipcart'),
e.setAttribute('hidden', 'true'),
document.body.appendChild(e)),
v(e),
i ||
((i = document.createElement('script')),
(i.src = `${window.SnipcartSettings.protocol}://${window.SnipcartSettings.domain}/themes/v${window.SnipcartSettings.version}/default/snipcart.js`),
(i.async = !0),
t.appendChild(i)),
n ||
((n = document.createElement('link')),
(n.rel = 'stylesheet'),
(n.type = 'text/css'),
(n.href = `${window.SnipcartSettings.protocol}://${window.SnipcartSettings.domain}/themes/v${window.SnipcartSettings.version}/default/snipcart.css`),
t.prepend(n)),
m.forEach((g) => document.removeEventListener(g, o));
}
function v(t) {
!f ||
((t.dataset.apiKey = window.SnipcartSettings.publicApiKey),
window.SnipcartSettings.addProductBehavior &&
(t.dataset.configAddProductBehavior =
window.SnipcartSettings.addProductBehavior),
window.SnipcartSettings.modalStyle &&
(t.dataset.configModalStyle = window.SnipcartSettings.modalStyle),
window.SnipcartSettings.currency &&
(t.dataset.currency = window.SnipcartSettings.currency),
window.SnipcartSettings.templatesUrl &&
(t.dataset.templatesUrl = window.SnipcartSettings.templatesUrl));
}
})();

View file

@ -1,52 +0,0 @@
Title: T-shirt Index 01
----
Price: 35
----
Description: <p>T-shirt de soutien à Index, 100% coton</p>
----
Details:
[
{
"content": {
"text": "<p>100% cotton</p>"
},
"id": "detail1",
"isHidden": false,
"type": "text"
},
{
"content": {
"text": "<p>Lorem ipsum dolor sit amet</p>"
},
"id": "detail2",
"isHidden": false,
"type": "text"
}
]
----
Options:
-
label: Size
values: XS, S, M, L, XL
----
Snipcartid: tshirt-01
----
Backgroundcolor: #ffffff
----
Template: product

View file

@ -1,56 +0,0 @@
Title: T-shirt Index 01
----
Price: 35
----
Stock: 10
----
Description: T-shirt de soutien à Index, 100% coton
----
Details: <p>T-shirt en coton organique avec impression sérigraphique.<br>Marquage sur la face avant : logo « INDEX » de 10 cm de large.</p><ul><li><p>100 % coton biologique</p></li><li><p>Grammage : 180 g/m²</p></li><li><p>Jersey simple au toucher très doux</p></li><li><p>Excellente tenue dans le temps</p></li><li><p>Bande de propreté intérieure au col</p></li><li><p>Surpiqûres doubles en bas de manches et en bas de corps</p></li></ul><p>Envoi uniquement via Mondial Relay vers la France, la Belgique et la Suisse.</p>
----
Hasoptions: true
----
Optionlabel: Taille
----
Optionvalues: XS, S, M, L, XL
----
Options:
-
label: Taille
values: XS, S, M, L, XL
-
label: Couleur
values: Rouge, Vert
----
Snipcartid: tshirt-01
----
Backgroundcolor: #ffffff
----
Template: product
----
Uuid: udrrfizhayqixfoo

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

View file

@ -1,9 +0,0 @@
Sort: 1
----
Uuid: deupkqq83jvloz0r
----
Template: image

View file

@ -0,0 +1,5 @@
Title: Error
----
Template: default

View file

@ -1 +1,5 @@
Title: Erreur
----
Uuid: kcrqkszqasludg2h Uuid: kcrqkszqasludg2h

View file

@ -0,0 +1,13 @@
Title: Thank you for your order
----
Text:
Your order has been confirmed! You will receive a confirmation email shortly.
Thank you for your purchase.
----
Template: thanks

View file

@ -7,3 +7,7 @@ Text:
Votre commande a été confirmée ! Vous allez recevoir un email de confirmation sous peu. Votre commande a été confirmée ! Vous allez recevoir un email de confirmation sous peu.
Merci pour votre achat. Merci pour votre achat.
----
Template: thanks

View file

@ -1,9 +1 @@
Title: Thank you for your order Title: Thanks
----
Text:
Your order has been confirmed! You will receive a confirmation email shortly.
Thank you for your purchase.

73
create-og-image.html Normal file
View file

@ -0,0 +1,73 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Generate OG Image</title>
<style>
#canvas-container {
display: none;
}
canvas {
border: 1px solid #ccc;
}
#preview {
margin-top: 20px;
}
#preview img {
max-width: 600px;
border: 1px solid #ccc;
}
</style>
</head>
<body>
<h1>Générer l'image Open Graph</h1>
<p>Cette page génère une image 1200x630px avec le logo Index pour les partages sociaux.</p>
<button onclick="generateImage()">Générer l'image</button>
<div id="canvas-container">
<canvas id="canvas" width="1200" height="630"></canvas>
</div>
<div id="preview"></div>
<script>
function generateImage() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Fond blanc
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, 1200, 630);
// Charger et dessiner le logo SVG
const img = new Image();
img.onload = function() {
// Centrer le logo
const logoWidth = 400;
const logoHeight = (this.height / this.width) * logoWidth;
const x = (1200 - logoWidth) / 2;
const y = (630 - logoHeight) / 2 - 50;
ctx.drawImage(img, x, y, logoWidth, logoHeight);
// Ajouter le texte en dessous
ctx.fillStyle = '#000000';
ctx.font = '24px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Boutique de Index, ONG d\'investigation indépendante', 600, 500);
// Convertir en PNG et afficher
canvas.toBlob(function(blob) {
const url = URL.createObjectURL(blob);
const preview = document.getElementById('preview');
preview.innerHTML = `
<h2>Aperçu</h2>
<img src="${url}" alt="OG Image Preview">
<p><a href="${url}" download="og-logo.png">Télécharger og-logo.png</a></p>
<p>Placez ce fichier dans <code>/assets/og-logo.png</code></p>
`;
});
};
img.src = 'assets/index-logo.svg';
}
</script>
</body>
</html>

View file

@ -1,134 +1,21 @@
title: title: Product
en: Product
fr: Produit
icon: cart icon: cart
tabs: columns:
content: - width: 1/1
label: fields:
en: Content info:
fr: Contenu type: info
columns: text:
- width: 2/3 en: "Product data (title, description, images, price) is managed in Shopify Admin. This Kirby page only serves for routing."
sections: fr: "Les données produit (titre, description, images, prix) sont gérées dans Shopify Admin. Cette page Kirby sert uniquement au routing."
main:
type: fields
fields:
price:
label:
en: Price (€)
fr: Prix (€)
type: number
min: 0
step: 0.01
required: true
translate: false
width: 1/4
stock:
label: Stock
type: number
min: 0
default: 0
help:
en: Edit through french version
fr: Partagé entre les versions FR et EN
translate: false
width: 1/4
space:
type: gap
width: 2/4
weight:
label:
en: Weight (g)
fr: Poids (g)
type: number
min: 0
default: 0
help:
en: Weight in grams for shipping calculation
fr: Poids en grammes pour le calcul de la livraison
translate: false
width: 1/4
length:
label:
en: Length (cm)
fr: Longueur (cm)
type: number
min: 0
default: 0
help:
en: Package length in centimeters
fr: Longueur du colis en centimètres
translate: false
width: 1/4
width:
label:
en: Width (cm)
fr: Largeur (cm)
type: number
min: 0
default: 0
help:
en: Package width in centimeters
fr: Largeur du colis en centimètres
translate: false
width: 1/4
height:
label:
en: Height (cm)
fr: Hauteur (cm)
type: number
min: 0
default: 0
help:
en: Package height in centimeters
fr: Hauteur du colis en centimètres
translate: false
width: 1/4
description:
label: Description panier
type: writer
help: Visible dans le panier seulement.
details:
label:
en: Details
fr: Détails
type: writer
hasOptions:
label:
en: Options
fr: Options
type: toggle
default: false
translate: false
width: 1/7
optionLabel:
label:
en: Option label
fr: Libellé de l'option
type: text
width: 3/7
when:
hasOptions: true
optionValues:
label:
en: Option values
fr: Valeurs de l'option
type: tags
help:
en: "Comma-separated values (e.g.: XS, S, M, L, XL)"
fr: "Valeurs séparées par des virgules (ex: XS, S, M, L, XL)"
translate: false
when:
hasOptions: true
width: 3/7
- width: 1/3 shopifyHandle:
sections: label:
images: en: Shopify Handle
type: files fr: Shopify Handle
headline: type: text
en: Product Images help:
fr: Images du produit en: "Product handle from Shopify (e.g. tshirt-index-01). If empty, uses the page slug."
template: image fr: "Handle du produit Shopify (ex: tshirt-index-01). Si vide, utilise le slug de la page Kirby."
layout: cards placeholder: tshirt-index-01

View file

@ -1,15 +1,11 @@
title: Site title: Site
sections: sections:
pages: shopify:
type: pages type: fields
headline: fields:
en: Products shopifyRefreshButton:
fr: Produits type: shopify-refresh
template: product label:
sortBy: title asc en: Shopify Products
info: "{{ page.stock }} en stock" fr: Produits Shopify
layout: cardlets
image:
query: page.files.first
cover: true

View file

@ -1,10 +1,142 @@
<?php <?php
require_once __DIR__ . '/shopify.php';
use Kirby\Cms\Page;
return [ return [
'debug' => true, 'debug' => true,
'languages' => true, 'languages' => true,
'cache' => [
'shopify' => true
],
'routes' => [
// Sitemap
[
'pattern' => 'sitemap.xml',
'action' => function() {
$sitemap = page('home');
return new Kirby\Cms\Response(
snippet('sitemap', ['page' => $sitemap], true),
'application/xml'
);
}
],
// English homepage
[
'pattern' => 'en',
'action' => function() {
$home = page('home');
if ($home) {
site()->visit($home, 'en');
return $home;
}
return null;
}
],
// English thanks page
[
'pattern' => 'en/thanks',
'action' => function() {
$thanks = page('thanks');
if ($thanks) {
site()->visit($thanks, 'en');
return $thanks;
}
return null;
}
],
// English error page
[
'pattern' => 'en/error',
'action' => function() {
$error = page('error');
if ($error) {
site()->visit($error, 'en');
return $error;
}
return null;
}
],
// French thanks page
[
'pattern' => 'thanks',
'action' => function() {
return page('thanks');
}
],
// French error page
[
'pattern' => 'error',
'action' => function() {
return page('error');
}
],
// French products (default)
[
'pattern' => '(:any)',
'action' => function($slug) {
if (in_array($slug, ['home', 'error', 'thanks'])) {
return null;
}
$products = getShopifyProducts();
foreach ($products as $product) {
if ($product['handle'] === $slug) {
$page = Page::factory([
'slug' => $product['handle'],
'template' => 'product',
'content' => [
'title' => $product['title'],
'shopifyHandle' => $product['handle'],
'uuid' => $product['id']
]
]);
site()->visit($page, 'fr');
return $page;
}
}
return null;
}
],
// English products
[
'pattern' => 'en/(:any)',
'action' => function($slug) {
if (in_array($slug, ['home', 'error', 'thanks'])) {
return null;
}
$products = getShopifyProducts();
foreach ($products as $product) {
if ($product['handle'] === $slug) {
$page = Page::factory([
'slug' => $product['handle'],
'template' => 'product',
'content' => [
'title' => $product['title'],
'shopifyHandle' => $product['handle'],
'uuid' => $product['id']
]
]);
site()->visit($page, 'en');
return $page;
}
}
return null;
}
]
],
'thumbs' => [ 'thumbs' => [
'quality' => 85, 'quality' => 85,
'format' => 'webp', 'format' => 'webp',
@ -35,114 +167,4 @@ return [
], ],
], ],
], ],
'routes' => [
[
'pattern' => '(:any)/validate.json',
'method' => 'GET',
'action' => function ($slug) {
$page = page($slug);
if (!$page || $page->intendedTemplate() !== 'product') {
header('Content-Type: application/json');
http_response_code(404);
echo json_encode(['error' => 'Product not found']);
return;
}
// Récupérer le stock actuel
$stock = (int) $page->stock()->value();
// Préparer la réponse JSON pour Snipcart
$response = [
'id' => $page->slug(),
'price' => (float) $page->price()->value(),
'url' => $page->url() . '/validate.json',
'name' => $page->title()->value(),
'description' => $page->description()->value(),
'image' => $page->images()->first() ? $page->images()->first()->url() : '',
'inventory' => $stock,
'stock' => $stock
];
// Ajouter les options si disponibles
if ($page->hasOptions()->toBool() && $page->optionValues()->isNotEmpty()) {
$values = $page->optionValues()->split(',');
$trimmedValues = array_map('trim', $values);
$snipcartOptions = implode('|', $trimmedValues);
$response['customFields'] = [
[
'name' => $page->optionLabel()->value(),
'options' => $snipcartOptions,
'required' => true
]
];
}
header('Content-Type: application/json');
echo json_encode($response);
}
],
[
'pattern' => 'snipcart-webhook',
'method' => 'POST',
'action' => function () {
// Webhook handler pour Snipcart
// Vérifie la signature et décrémente le stock
$requestBody = file_get_contents('php://input');
$event = json_decode($requestBody, true);
// Vérifier la signature Snipcart (à implémenter avec la clé secrète)
// $signature = $_SERVER['HTTP_X_SNIPCART_REQUESTTOKEN'] ?? '';
if (!$event || !isset($event['eventName'])) {
return Response::json(['error' => 'Invalid request'], 400);
}
// Gérer l'événement order.completed
if ($event['eventName'] === 'order.completed') {
$order = $event['content'] ?? null;
if ($order && isset($order['items'])) {
// Impersonate pour avoir les permissions d'écriture
kirby()->impersonate('kirby');
foreach ($order['items'] as $item) {
$productId = $item['id'] ?? null;
$quantity = $item['quantity'] ?? 0;
if ($productId && $quantity > 0) {
// Trouver le produit par son slug
$products = site()->index()->filterBy('intendedTemplate', 'product');
foreach ($products as $product) {
if ($product->slug() === $productId) {
// Décrémenter le stock
$currentStock = (int) $product->stock()->value();
$newStock = max(0, $currentStock - $quantity);
// Mettre à jour le stock
try {
$product->update([
'stock' => $newStock
]);
} catch (Exception $e) {
// Log l'erreur mais continue le traitement
error_log('Webhook stock update error: ' . $e->getMessage());
}
break;
}
}
}
}
}
}
return Response::json(['status' => 'success'], 200);
}
]
]
]; ];

82
site/config/shopify.php Normal file
View file

@ -0,0 +1,82 @@
<?php
/**
* Récupère les produits Shopify avec cache (TTL 1h)
*/
function getShopifyProducts(): array
{
$cache = kirby()->cache('shopify');
$products = $cache->get('products');
if ($products === null) {
$products = fetchShopifyProducts();
$cache->set('products', $products, 60); // Cache 60 minutes
}
return $products;
}
/**
* Appel direct à l'API Shopify Storefront
*/
function fetchShopifyProducts(): array
{
$domain = 'nv7cqv-bu.myshopify.com';
$token = 'dec3d35a2554384d149c72927d1cfd1b';
$apiVersion = '2026-01';
$endpoint = "https://{$domain}/api/{$apiVersion}/graphql.json";
$query = '
query getAllProducts {
products(first: 250, sortKey: TITLE) {
edges {
node {
id
handle
title
}
}
}
}
';
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Nécessaire pour dev local sur Windows
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'X-Shopify-Storefront-Access-Token: ' . $token
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'query' => $query
]));
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
error_log("Shopify API error: HTTP {$httpCode}");
return [];
}
$data = json_decode($response, true);
if (isset($data['errors'])) {
error_log("Shopify API GraphQL errors: " . json_encode($data['errors']));
return [];
}
$products = [];
foreach ($data['data']['products']['edges'] as $edge) {
$node = $edge['node'];
$products[] = [
'id' => $node['id'],
'handle' => $node['handle'],
'title' => $node['title']
];
}
return $products;
}

View file

@ -0,0 +1,11 @@
<?php
return function ($page, $kirby) {
$shopifyHandle = $page->shopifyHandle()->or($page->slug())->value();
$language = $kirby->language()->code();
return [
'shopifyHandle' => $shopifyHandle,
'language' => $language
];
};

View file

@ -13,7 +13,24 @@ return [
'backToShop' => 'Back to shop', 'backToShop' => 'Back to shop',
'supportText' => 'To support us, you&nbsp;can&nbsp;also', 'supportText' => 'To support us, you&nbsp;can&nbsp;also',
'makeDonation' => 'make a donation', 'makeDonation' => 'make a donation',
// Shop / Cart
'addToCart' => 'Add to cart', 'addToCart' => 'Add to cart',
'cart' => 'Cart',
'cartEmpty' => 'Your cart is empty',
'total' => 'Total',
'checkout' => 'Checkout',
'remove' => 'Remove',
'inStock' => 'In stock',
'outOfStock' => 'Out of stock',
'addingToCart' => 'Adding...',
'addedToCart' => 'Added! ✓',
'errorAddToCart' => 'Error - Try again',
'closeCart' => 'Close cart',
'loading' => 'Loading...',
'productNotFound' => 'Product not found',
'selectVariant' => 'Select',
'chooseOption' => 'Choose an option',
// Blueprints - Home // Blueprints - Home
'home.title' => 'Home', 'home.title' => 'Home',

View file

@ -13,7 +13,24 @@ return [
'backToShop' => 'Retour à la boutique', 'backToShop' => 'Retour à la boutique',
'supportText' => 'Pour nous soutenir, vous&nbsp;pouvez&nbsp;aussi', 'supportText' => 'Pour nous soutenir, vous&nbsp;pouvez&nbsp;aussi',
'makeDonation' => 'faire un don', 'makeDonation' => 'faire un don',
// Shop / Cart
'addToCart' => 'Ajouter au panier', 'addToCart' => 'Ajouter au panier',
'cart' => 'Panier',
'cartEmpty' => 'Votre panier est vide',
'total' => 'Total',
'checkout' => 'Passer commande',
'remove' => 'Retirer',
'inStock' => 'En stock',
'outOfStock' => 'Rupture de stock',
'addingToCart' => 'Ajout en cours...',
'addedToCart' => 'Ajouté ! ✓',
'errorAddToCart' => 'Erreur - Réessayer',
'closeCart' => 'Fermer le panier',
'loading' => 'Chargement...',
'productNotFound' => 'Produit non trouvé',
'selectVariant' => 'Choisir',
'chooseOption' => 'Choisissez une option',
// Blueprints - Home // Blueprints - Home
'home.title' => 'Accueil', 'home.title' => 'Accueil',

View file

@ -0,0 +1,20 @@
# This file is for unifying the coding style for different editors and IDEs
# editorconfig.org
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.php]
indent_size = 4
[*.md,*.txt]
trim_trailing_whitespace = false
insert_final_newline = false
[composer.json]
indent_size = 4

View file

@ -0,0 +1,11 @@
# Note: You need to uncomment the lines you want to use; the other lines can be deleted
# Git
# .gitattributes export-ignore
# .gitignore export-ignore
# Tests
# /.coveralls.yml export-ignore
# /.travis.yml export-ignore
# /phpunit.xml.dist export-ignore
# /tests/ export-ignore

View file

@ -0,0 +1,14 @@
# OS files
.DS_Store
# npm modules
/node_modules
# Parcel cache folder
.cache
# Composer files
/vendor
# kirbyup temp development entry
/index.dev.mjs

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) <Year> <Your Name>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,117 @@
# Kirby Pluginkit: Example plugin for Kirby
> Variant "Panel plugin setup"
This is a boilerplate for a Kirby Panel plugin that can be installed via all three [supported installation methods](https://getkirby.com/docs/guide/plugins/plugin-setup-basic#the-three-plugin-installation-methods).
You can find a list of Pluginkit variants on the [`master` branch](https://github.com/getkirby/pluginkit/tree/master).
****
## How to use the Pluginkit
1. Fork this repository
2. Change the plugin name and description in the `composer.json`
3. Change the plugin name in the `index.php` and `src/index.js`
4. Change the license if you don't want to publish under MIT
5. Add your plugin code to the `index.php` and `src/index.js`
6. Update this `README` with instructions for your plugin
### Install the development and build setup
We use [kirbyup](https://github.com/johannschopplich/kirbyup) for the development and build setup.
You can start developing directly. kirbyup will be fetched remotely with your first `npm run` command, which may take a short amount of time.
### Development
You can start the dev process with:
```bash
npm run dev
```
This will automatically update the `index.js` and `index.css` of your plugin as soon as you make changes.
Reload the Panel to see your code changes reflected.
With kirbyup 2.0.0+ and Kirby 3.7.4+ you can alternatively use hot module reloading (HMR):
```bash
npm run serve
```
This will start a development server that updates the page as soon as you make changes. Some updates are instant, like CSS or Vue template changes, others require a reload of the page, which happens automatically.
> [!NOTE]
> The live reload functionality requires top level await, [which is only supported in modern browsers](https://caniuse.com/mdn-javascript_operators_await_top_level). If you're developing in older browsers, use `npm run dev` and reload the page manually to see changes.
### Production
As soon as you are happy with your plugin, you should build the final version with:
```bash
npm run build
```
This will automatically create a minified and optimized version of your `index.js` and `index.css`
which you can ship with your plugin.
We have a tutorial on how to build your own plugin based on the Pluginkit [in the Kirby documentation](https://getkirby.com/docs/guide/plugins/plugin-setup-basic).
### Build reproducibility
While kirbyup will stay backwards compatible, exact build reproducibility may be of importance to you. If so, we recommend to target a specific package version, rather than using npx:
```json
{
"scripts": {
"dev": "kirbyup src/index.js --watch",
"build": "kirbyup src/index.js"
},
"devDependencies": {
"kirbyup": "^3.1.0"
}
}
```
What follows is an example README for your plugin.
****
## Installation
### Download
Download and copy this repository to `/site/plugins/{{ plugin-name }}`.
### Git submodule
```bash
git submodule add https://github.com/{{ your-name }}/{{ plugin-name }}.git site/plugins/{{ plugin-name }}
```
### Composer
```bash
composer require {{ your-name }}/{{ plugin-name }}
```
## Setup
*Additional instructions on how to configure the plugin (e.g. blueprint setup, config options, etc.)*
## Options
*Document the options and APIs that this plugin offers*
## Development
*Add instructions on how to help working on the plugin (e.g. npm setup, Composer dev dependencies, etc.)*
## License
MIT
## Credits
- [Your Name](https://github.com/ghost)

View file

@ -0,0 +1,18 @@
# Security Policy
## Supported Versions
*Use this section to tell people about which versions of your project are currently being supported with security updates.*
| Version | Supported |
| ------- | ------------------ |
| 5.1.x | :white_check_mark: |
| 5.0.x | :x: |
| 4.0.x | :white_check_mark: |
| < 4.0 | :x: |
## Reporting a Vulnerability
*Use this section to tell people how to report a vulnerability.*
*Tell them where to go, how often they can expect to get an update on a reported vulnerability, what to expect if the vulnerability is accepted or declined, etc.*

View file

@ -0,0 +1,21 @@
{
"name": "getkirby/pluginkit",
"description": "Kirby Example Plugin",
"license": "MIT",
"type": "kirby-plugin",
"version": "1.0.0",
"authors": [
{
"name": "Your Name",
"email": "you@example.com"
}
],
"require": {
"getkirby/composer-installer": "^1.1"
},
"config": {
"allow-plugins": {
"getkirby/composer-installer": true
}
}
}

View file

@ -0,0 +1,66 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "37a8e61308b9b6f49cb9835f477f0c64",
"packages": [
{
"name": "getkirby/composer-installer",
"version": "1.2.1",
"source": {
"type": "git",
"url": "https://github.com/getkirby/composer-installer.git",
"reference": "c98ece30bfba45be7ce457e1102d1b169d922f3d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getkirby/composer-installer/zipball/c98ece30bfba45be7ce457e1102d1b169d922f3d",
"reference": "c98ece30bfba45be7ce457e1102d1b169d922f3d",
"shasum": ""
},
"require": {
"composer-plugin-api": "^1.0 || ^2.0"
},
"require-dev": {
"composer/composer": "^1.8 || ^2.0"
},
"type": "composer-plugin",
"extra": {
"class": "Kirby\\ComposerInstaller\\Plugin"
},
"autoload": {
"psr-4": {
"Kirby\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Kirby's custom Composer installer for the Kirby CMS and for Kirby plugins",
"homepage": "https://getkirby.com",
"support": {
"issues": "https://github.com/getkirby/composer-installer/issues",
"source": "https://github.com/getkirby/composer-installer/tree/1.2.1"
},
"funding": [
{
"url": "https://getkirby.com/buy",
"type": "custom"
}
],
"time": "2020-12-28T12:54:39+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.6.0"
}

View file

@ -0,0 +1,2 @@
(function(){"use strict";function l(n,r,t,e,o,a,s,f){var i=typeof n=="function"?n.options:n;return r&&(i.render=r,i.staticRenderFns=t,i._compiled=!0),{exports:n,options:i}}const p={__name:"ShopifyRefreshButton",props:{products:{type:Array,default:()=>[]}},setup(n){const r=n,t=Vue.ref("Synchroniser depuis Shopify"),e=Vue.ref("refresh"),o=Vue.ref("aqua-icon"),a=Vue.ref(!1),s=Vue.ref([]);Vue.onMounted(()=>{s.value=r.products||[]});const f=Vue.computed(()=>{const u=s.value.length;return`${u} produit${u>1?"s":""} Shopify en cache`}),i=Vue.computed(()=>s.value.length===0?"Aucun produit trouvé. Cliquez sur 'Rafraîchir Shopify' pour récupérer les produits.":s.value.map(u=>`${u.title}<br/>`).join(`
`));async function y(){a.value=!0,e.value="loader",o.value="orange-icon",t.value="En cours…";try{const c=await(await fetch("/shopify/refresh-cache.json",{method:"POST",headers:{"Content-Type":"application/json"}})).json();if(c.status==="error")throw new Error(c.message);s.value=c.products||[],t.value=`Terminé - ${c.count} produit${c.count>1?"s":""}`,e.value="check",o.value="green-icon",setTimeout(()=>{t.value="Synchroniser depuis Shopify",e.value="refresh",o.value="aqua-icon",a.value=!1},3e3)}catch(u){console.error(u),t.value="Erreur",e.value="alert",o.value="red-icon",setTimeout(()=>{t.value="Synchroniser depuis Shopify",e.value="refresh",o.value="aqua-icon",a.value=!1},3e3)}}return{__sfc:!0,props:r,text:t,icon:e,theme:o,isProcessing:a,currentProducts:s,infoLabel:f,infoText:i,refreshCache:y}}};var h=function(){var r=this,t=r._self._c,e=r._self._setupProxy;return t("div",[t("k-info-field",{attrs:{label:e.infoLabel,text:e.infoText,theme:"info"}}),t("k-button",{staticStyle:{"margin-top":"1rem"},attrs:{theme:e.theme,variant:"dimmed",icon:e.icon,title:"Synchroniser le cache des produits Shopify",disabled:e.isProcessing},on:{click:function(o){return e.refreshCache()}}},[r._v(" "+r._s(e.text)+" ")])],1)},d=[],v=l(p,h,d);const m=v.exports;window.panel.plugin("index/shopify-refresh-button",{fields:{"shopify-refresh":m}})})();

View file

@ -0,0 +1,46 @@
<?php
Kirby::plugin('index/shopify-refresh-button', [
'fields' => [
'shopify-refresh' => [
'props' => [
'products' => function() {
return getShopifyProducts();
}
],
]
],
'routes' => [
[
'pattern' => 'shopify/refresh-cache.json',
'method' => 'POST',
'action' => function() {
if (!kirby()->user()) {
return [
'status' => 'error',
'message' => 'Unauthorized'
];
}
try {
kirby()->cache('shopify')->flush();
$products = fetchShopifyProducts();
return [
'status' => 'success',
'message' => 'Cache Shopify rafraîchi avec succès',
'count' => count($products),
'products' => $products
];
} catch (\Throwable $e) {
return [
'status' => 'error',
'message' => 'Erreur lors du rafraîchissement du cache',
'details' => $e->getMessage()
];
}
}
]
]
]);

View file

@ -0,0 +1,7 @@
{
"scripts": {
"dev": "npx -y kirbyup src/index.js --watch",
"serve": "npx -y kirbyup serve src/index.js",
"build": "npx -y kirbyup src/index.js"
}
}

View file

@ -0,0 +1,97 @@
<template>
<div>
<k-info-field :label="infoLabel" :text="infoText" theme="info" />
<k-button
style="margin-top: 1rem"
:theme="theme"
variant="dimmed"
:icon="icon"
title="Synchroniser le cache des produits Shopify"
@click="refreshCache()"
:disabled="isProcessing"
>
{{ text }}
</k-button>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
const props = defineProps({
products: {
type: Array,
default: () => [],
},
});
const text = ref("Synchroniser depuis Shopify");
const icon = ref("refresh");
const theme = ref("aqua-icon");
const isProcessing = ref(false);
const currentProducts = ref([]);
onMounted(() => {
currentProducts.value = props.products || [];
});
const infoLabel = computed(() => {
const count = currentProducts.value.length;
return `${count} produit${count > 1 ? "s" : ""} Shopify en cache`;
});
const infoText = computed(() => {
if (currentProducts.value.length === 0) {
return "Aucun produit trouvé. Cliquez sur 'Rafraîchir Shopify' pour récupérer les produits.";
}
return currentProducts.value.map((p) => `${p.title}<br/>`).join("\n");
});
async function refreshCache() {
isProcessing.value = true;
icon.value = "loader";
theme.value = "orange-icon";
text.value = "En cours…";
try {
const res = await fetch("/shopify/refresh-cache.json", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (json.status === "error") {
throw new Error(json.message);
}
currentProducts.value = json.products || [];
text.value = `Terminé - ${json.count} produit${json.count > 1 ? "s" : ""}`;
icon.value = "check";
theme.value = "green-icon";
setTimeout(() => {
text.value = "Synchroniser depuis Shopify";
icon.value = "refresh";
theme.value = "aqua-icon";
isProcessing.value = false;
}, 3000);
} catch (error) {
console.error(error);
text.value = "Erreur";
icon.value = "alert";
theme.value = "red-icon";
setTimeout(() => {
text.value = "Synchroniser depuis Shopify";
icon.value = "refresh";
theme.value = "aqua-icon";
isProcessing.value = false;
}, 3000);
}
}
</script>

View file

@ -0,0 +1,7 @@
import ShopifyRefreshButton from "./components/ShopifyRefreshButton.vue";
window.panel.plugin("index/shopify-refresh-button", {
fields: {
"shopify-refresh": ShopifyRefreshButton
}
});

View file

@ -0,0 +1,20 @@
<div class="product-purchase">
<div class="product-stock-info">
<p data-product-stock class="stock-status"></p>
</div>
<button
class="btn-add-to-cart"
data-shopify-add-to-cart
data-product-id="15689076179317"
data-variant-id=""
data-text-add="<?= t('addToCart') ?>"
data-text-adding="<?= t('addingToCart') ?>"
data-text-added="<?= t('addedToCart') ?>"
data-text-error="<?= t('errorAddToCart') ?>"
data-text-out-of-stock="<?= t('outOfStock') ?>"
data-text-in-stock="<?= t('inStock') ?>"
>
<?= t('addToCart') ?>
</button>
</div>

View file

@ -0,0 +1,20 @@
<div class="add-to-cart">
<button
class="btn__default"
data-shopify-add-to-cart
data-product-id=""
data-variant-id=""
data-default-text="<?= t('addToCart') ?>"
data-text-choose-option="<?= t('chooseOption') ?>"
data-text-adding="<?= t('addingToCart') ?>"
data-text-added="<?= t('addedToCart') ?>"
data-text-error="<?= t('errorAddToCart') ?>"
>
<span class="icon">
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m14.523 18.787s4.501-4.505 6.255-6.26c.146-.146.219-.338.219-.53s-.073-.383-.219-.53c-1.753-1.754-6.255-6.258-6.255-6.258-.144-.145-.334-.217-.524-.217-.193 0-.385.074-.532.221-.293.292-.295.766-.004 1.056l4.978 4.978h-14.692c-.414 0-.75.336-.75.75s.336.75.75.75h14.692l-4.979 4.979c-.289.289-.286.762.006 1.054.148.148.341.222.533.222.19 0 .378-.072.522-.215z" fill-rule="nonzero" />
</svg>
</span>
<div class="txt" data-button-text><?= t('addToCart') ?></div>
</button>
</div>

View file

@ -0,0 +1,32 @@
<div id="cart-drawer" class="cart-drawer" data-text-remove="<?= t('remove') ?>">
<div class="cart-drawer__overlay" data-cart-close></div>
<div class="cart-drawer__panel">
<div class="cart-drawer__header">
<h3><?= t('cart') ?></h3>
<button class="cart-drawer__close" data-cart-close aria-label="<?= t('closeCart') ?>">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="cart-drawer__content">
<div class="cart-drawer__empty" data-cart-empty>
<p><?= t('cartEmpty') ?></p>
</div>
<div class="cart-drawer__items" data-cart-items></div>
</div>
<div class="cart-drawer__footer">
<div class="cart-drawer__total">
<span class="cart-drawer__total-label"><?= t('total') ?></span>
<span class="cart-drawer__total-amount" data-cart-total>0,00 </span>
</div>
<button class="cart-drawer__checkout-btn" data-cart-checkout>
<?= t('checkout') ?>
</button>
</div>
</div>
</div>

View file

@ -1,3 +1,5 @@
<?php snippet('cart-drawer') ?>
<footer id="site-footer"> <footer id="site-footer">
<div class="site-footer__container"> <div class="site-footer__container">
<div class="footer__mentions"> <div class="footer__mentions">
@ -10,10 +12,17 @@
</footer> </footer>
<script src="<?= url('assets/js/onload.js') ?>"></script> <script src="<?= url('assets/js/onload.js') ?>"></script>
<?php if(isset($scripts) && is_array($scripts)): ?> <script src="<?= url('assets/js/shopify-cart.js') ?>"></script>
<?php foreach($scripts as $script): ?>
<script src="<?= url($script) ?>"></script> <?php if ($scripts ?? null): ?>
<?php endforeach ?> <?php if (in_array('product', $scripts)): ?>
<script src="<?= url('assets/js/product-loader.js') ?>"></script>
<script src="<?= url('assets/js/product-add-to-cart.js') ?>"></script>
<?php endif ?>
<?php else: ?>
<script src="<?= url('assets/js/products-list-loader.js') ?>"></script>
<?php endif ?> <?php endif ?>
<script src="<?= url('assets/js/cart-drawer.js') ?>"></script>
</body> </body>
</html> </html>

View file

@ -1,19 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="<?= $kirby->language()->code() ?>"> <html lang="<?= $kirby->language()->code() ?>">
<head> <head>
<meta charset="UTF-8"> <?php snippet('seo') ?>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title><?= $title ?? $page->title() ?> | Index.ngo</title>
<link rel="icon" type="image/png" href="<?= url('assets/favicon.png') ?>"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.css" />
<script src="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.js"></script>
<link rel="stylesheet" type="text/css" href="<?= url('assets/fonts/stylesheet.css') ?>?version-cache-prevent<?= rand(0, 1000)?>" /> <link rel="stylesheet" type="text/css" href="<?= url('assets/fonts/stylesheet.css') ?>?version-cache-prevent<?= rand(0, 1000)?>" />
<link rel="stylesheet" type="text/css" href="<?= url('assets/css/style.css') ?>" /> <link rel="stylesheet" type="text/css" href="<?= url('assets/css/style.css') ?>" />
<link rel="stylesheet" type="text/css" href="<?= url('assets/css/cart-drawer.css') ?>" />
</head> </head>
<body data-template="<?= $template ?? 'default' ?>"> <body data-template="<?= $page->template() ?>">
<header id="site-header"> <header id="site-header">
<div class="header-left"></div> <div class="header-left"></div>
@ -46,5 +43,9 @@
</li> </li>
<?php endforeach ?> <?php endforeach ?>
</ul> </ul>
<button class="header-cart-btn" data-cart-open aria-label="<?= t('cart') ?>">
<?= t('cart') ?> <span class="header-cart-count" data-cart-count></span>
</button>
</div> </div>
</header> </header>

79
site/snippets/seo.php Normal file
View file

@ -0,0 +1,79 @@
<?php
/**
* SEO meta tags
*/
// Language
$lang = $kirby->language()->code();
// Basic meta
$title = $page->customTitle()->or($page->title())->value();
$siteName = 'Index.ngo';
$fullTitle = $title . ' | ' . $siteName;
// Default descriptions by language
$defaultDescriptionFr = 'Boutique de Index, ONG d\'investigation indépendante';
$defaultDescriptionEn = 'Index shop, independent investigative NGO';
$defaultDescription = $lang == 'en' ? $defaultDescriptionEn : $defaultDescriptionFr;
$description = $page->metaDescription()->or($page->description())->value();
if ($description) {
$description = excerpt($description, 160);
} else {
$description = $defaultDescription;
}
$url = $page->url();
// Use product image if available, otherwise use default OG image
// TODO: Create assets/og-logo.png (1200x630px) with Index logo + description
$image = $page->image() ? $page->image()->url() : url('assets/favicon.png');
?>
<!-- Basic Meta Tags -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $fullTitle ?></title>
<?php if ($description): ?>
<meta name="description" content="<?= $description ?>">
<?php endif ?>
<!-- Canonical & Alternate Languages -->
<link rel="canonical" href="<?= $url ?>">
<?php foreach($kirby->languages() as $language): ?>
<link rel="alternate" hreflang="<?= $language->code() ?>" href="<?= $page->url($language->code()) ?>">
<?php endforeach ?>
<link rel="alternate" hreflang="x-default" href="<?= $page->url('fr') ?>">
<!-- Open Graph -->
<meta property="og:type" content="<?= $page->template() == 'product' ? 'product' : 'website' ?>">
<meta property="og:site_name" content="<?= $siteName ?>">
<meta property="og:title" content="<?= $title ?>" id="og-title">
<meta property="og:description" content="<?= $description ?>" id="og-description">
<meta property="og:url" content="<?= $url ?>">
<meta property="og:image" content="<?= $image ?>" id="og-image">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:locale" content="<?= $lang == 'fr' ? 'fr_FR' : 'en_US' ?>">
<?php foreach($kirby->languages() as $language): ?>
<?php if ($language->code() != $lang): ?>
<meta property="og:locale:alternate" content="<?= $language->code() == 'fr' ? 'fr_FR' : 'en_US' ?>">
<?php endif ?>
<?php endforeach ?>
<?php if ($page->template() == 'product'): ?>
<!-- Product-specific OG tags (will be updated by JS) -->
<meta property="product:price:amount" content="0" id="og-price">
<meta property="product:price:currency" content="EUR">
<meta property="product:availability" content="in stock" id="og-availability">
<meta property="product:brand" content="Index.ngo">
<?php endif ?>
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<?= $title ?>">
<?php if ($description): ?>
<meta name="twitter:description" content="<?= $description ?>">
<?php endif ?>
<meta name="twitter:image" content="<?= $image ?>">
<!-- Favicon -->
<link rel="icon" type="image/png" href="<?= url('assets/favicon.png') ?>">

54
site/snippets/sitemap.php Normal file
View file

@ -0,0 +1,54 @@
<?= '<?xml version="1.0" encoding="UTF-8"?>' ?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<?php
$site = site();
$kirby = kirby();
// Static pages
$pages = $site->index()->filterBy('template', 'in', ['home', 'error', 'thanks']);
foreach ($pages as $p) {
foreach ($kirby->languages() as $lang) {
$url = $p->url($lang->code());
?>
<url>
<loc><?= $url ?></loc>
<lastmod><?= $p->modified('c', 'date') ?></lastmod>
<changefreq>monthly</changefreq>
<priority><?= $p->isHomePage() ? '1.0' : '0.8' ?></priority>
<?php foreach ($kirby->languages() as $altLang): ?>
<xhtml:link rel="alternate" hreflang="<?= $altLang->code() ?>" href="<?= $p->url($altLang->code()) ?>" />
<?php endforeach ?>
</url>
<?php
}
}
// Virtual product pages from Shopify
$products = getShopifyProducts();
foreach ($products as $product) {
foreach ($kirby->languages() as $lang) {
$url = $lang->code() == 'fr'
? $site->url() . '/' . $product['handle']
: $site->url() . '/en/' . $product['handle'];
?>
<url>
<loc><?= $url ?></loc>
<lastmod><?= date('c') ?></lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
<?php foreach ($kirby->languages() as $altLang): ?>
<?php
$altUrl = $altLang->code() == 'fr'
? $site->url() . '/' . $product['handle']
: $site->url() . '/en/' . $product['handle'];
?>
<xhtml:link rel="alternate" hreflang="<?= $altLang->code() ?>" href="<?= $altUrl ?>" />
<?php endforeach ?>
</url>
<?php
}
}
?>
</urlset>

View file

@ -0,0 +1,94 @@
<?php
/**
* Structured Data (JSON-LD) for Product pages
* This will be populated by JavaScript after Shopify data is loaded
*/
?>
<script type="application/ld+json" id="product-schema">
{
"@context": "https://schema.org/",
"@type": "Product",
"name": "<?= $page->title() ?>",
"description": "",
"image": [],
"offers": {
"@type": "Offer",
"url": "<?= $page->url() ?>",
"priceCurrency": "EUR",
"price": "0",
"availability": "https://schema.org/InStock",
"seller": {
"@type": "Organization",
"name": "Index.ngo"
}
},
"brand": {
"@type": "Organization",
"name": "Index.ngo"
}
}
</script>
<script>
// Update structured data when product loads
(function() {
const container = document.querySelector('[data-product-loader]');
if (!container) return;
const handle = container.dataset.shopifyHandle;
const language = container.dataset.language || 'fr';
const isEnglish = language === 'en';
function initStructuredData() {
if (typeof ShopifyCart === 'undefined') {
setTimeout(initStructuredData, 100);
return;
}
const cart = new ShopifyCart({
domain: 'nv7cqv-bu.myshopify.com',
storefrontAccessToken: 'dec3d35a2554384d149c72927d1cfd1b'
});
cart.getProductByHandle(handle).then(product => {
if (!product) return;
const title = isEnglish && product.titleEn?.value ? product.titleEn.value : product.title;
const description = isEnglish && product.descriptionEn?.value ? product.descriptionEn.value : product.description;
const price = parseFloat(product.priceRange.minVariantPrice.amount);
const images = product.images.edges.map(edge => edge.node.url);
const availability = product.availableForSale ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock';
const schema = {
"@context": "https://schema.org/",
"@type": "Product",
"name": title,
"description": description,
"image": images,
"offers": {
"@type": "Offer",
"url": window.location.href,
"priceCurrency": "EUR",
"price": price.toFixed(2),
"availability": availability,
"seller": {
"@type": "Organization",
"name": "Index.ngo"
}
},
"brand": {
"@type": "Organization",
"name": "Index.ngo"
}
};
const schemaScript = document.getElementById('product-schema');
if (schemaScript) {
schemaScript.textContent = JSON.stringify(schema, null, 2);
}
});
}
// Initialize when ShopifyCart is available
initStructuredData();
})();
</script>

View file

@ -1,35 +1,24 @@
<?php snippet('header', ['title' => $site->title(), 'template' => 'store']) ?> <?php snippet('header', ['title' => $site->title(), 'template' => 'store']) ?>
<main> <main>
<p class="p__baseline-big"> <p class="p__baseline-big">
<?= $page->baseline()->or('Bienvenue sur la boutique de soutien à Index') ?> <?= $page->baseline()->or('Bienvenue sur la boutique de soutien à Index') ?>
</p> </p>
<section id="store__container"> <section id="store__container"
<?php foreach($site->children()->listed() as $product): ?> data-products-loader
<article class="store__product"> data-language="<?= strtoupper($kirby->language()->code()) ?>">
<figure>
<?php if($cover = $product->files()->sort()->first()): ?>
<?php snippet('picture', [
'file' => $cover,
'alt' => $product->title()->html(),
'preset' => 'product-card',
'size' => 25,
'lazy' => true
]) ?>
<?php endif ?>
</figure>
<p class="line-1"><a href="<?= $product->url() ?>"><?= $product->title()->html() ?></a></p>
<p class="price"><?= $product->price() ?>€</p>
<a href="<?= $product->url() ?>" class="link-block" aria-hidden="true"></a>
</article>
<?php endforeach ?>
</section>
<p class="p__baseline-big"> <div class="products-loading">
<?= t('supportText', 'Pour nous soutenir, vous&nbsp;pouvez&nbsp;aussi') ?> <p><?= t('loading') ?></p>
<a href="https://soutenir.index.ngo" class="link-don"><?= t('makeDonation', 'faire un don') ?></a> </div>
</p>
</main> </section>
<p class="p__baseline-big">
<?= t('supportText') ?>
<a href="https://soutenir.index.ngo" class="link-don"><?= t('makeDonation') ?></a>
</p>
</main>
<?php snippet('footer') ?> <?php snippet('footer') ?>

View file

@ -1,115 +1,52 @@
<?php snippet('header', ['title' => $page->title(), 'template' => 'shop']) ?> <?php
$shopifyHandle = $page->shopifyHandle()->or($page->slug());
<main> snippet('header', ['title' => $page->title(), 'template' => 'shop']);
<nav class="store__nav"> ?>
<a href="<?= $site->homePage()->url() ?>"><?= t('backToShop', 'Retour à la boutique') ?></a>
</nav>
<section class="section__product"> <main>
<nav class="store__nav">
<a href="<?= $site->homePage()->url() ?>"><?= t('backToShop') ?></a>
</nav>
<section class="section__product"
data-product-loader
data-shopify-handle="<?= $shopifyHandle ?>"
data-language="<?= $kirby->language()->code() ?>">
<div class="product-loading">
<p><?= t('loading') ?></p>
</div>
<div class="product-content" style="display: none;">
<div class="col-left"> <div class="col-left">
<div class="hero"> <div class="hero">
<h2 class="p__baseline-big"><?= $page->title()->html() ?></h2> <h2 class="p__baseline-big" data-product-title></h2>
<p class="p__baseline-big"><?= $page->price() ?>€</p> <p class="p__baseline-big" data-product-price></p>
</div> </div>
<div class="details"> <div class="details" data-product-details></div>
<?php if($page->details()->isNotEmpty()): ?>
<?= $page->details()->kt() ?> <div class="product-options" data-product-options style="display: none;">
<?php endif ?> <ul class="product-options__list" data-product-options-list></ul>
</div> </div>
<?php if($page->hasOptions()->toBool() && $page->optionValues()->isNotEmpty()): ?> <?php snippet('buy-button') ?>
<div class="product-options">
<ul class="product-options__list">
<?php
$values = $page->optionValues()->split(',');
$optionSlug = $page->optionLabel()->slug();
foreach($values as $index => $value):
$value = trim($value);
$uniqueId = $optionSlug . '-' . Str::slug(strtolower($value));
?>
<li>
<input type="radio" id="<?= $uniqueId ?>" name="<?= $optionSlug ?>" value="<?= $value ?>" />
<label for="<?= $uniqueId ?>"><?= $value ?></label>
</li>
<?php endforeach ?>
</ul>
</div>
<?php endif ?>
<div class="add-to-cart">
<button
class="btn__default snipcart-add-item"
data-item-id="<?= $page->slug() ?>"
data-item-price="<?= $page->price() ?>"
data-item-description="<?= $page->description()->excerpt(100) ?>"
data-item-image="<?= $page->images()->first() ? $page->images()->first()->url() : '' ?>"
data-item-name="<?= $page->title()->html() ?>"
data-item-shippable="true"
data-item-weight="<?= $page->weight()->or(0) ?>"
data-item-length="<?= $page->length()->or(0) ?>"
data-item-width="<?= $page->width()->or(0) ?>"
data-item-height="<?= $page->height()->or(0) ?>"
<?php
if($page->hasOptions()->toBool() && $page->optionValues()->isNotEmpty()):
$values = $page->optionValues()->split(',');
$trimmedValues = array_map('trim', $values);
$snipcartOptions = implode('|', $trimmedValues);
?>
data-item-custom1-name="<?= $page->optionLabel()->html() ?>"
data-item-custom1-options="<?= $snipcartOptions ?>"
data-item-custom1-required="true"
disabled
<?php endif; ?>
>
<span class="icon">
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m14.523 18.787s4.501-4.505 6.255-6.26c.146-.146.219-.338.219-.53s-.073-.383-.219-.530c-1.753-1.754-6.255-6.258-6.255-6.258-.144-.145-.334-.217-.524-.217-.193 0-.385.074-.532.221-.293.292-.295.766-.004 1.056l4.978 4.978h-14.692c-.414 0-.75.336-.75.75s.336.75.75.75h14.692l-4.979 4.979c-.289.289-.286.762.006 1.054.148.148.341.222.533.222.19 0 .378-.072.522-.215z" fill-rule="nonzero" />
</svg>
</span>
<div class="txt" data-default-text="<?= t('addToCart', 'Ajouter au panier') ?>">
<?php if($page->hasOptions()->toBool() && $page->optionValues()->isNotEmpty()): ?>
<?= t('chooseOption', 'Choisissez une option') ?>
<?php else: ?>
<?= t('addToCart', 'Ajouter au panier') ?>
<?php endif ?>
</div>
</button>
</div>
</div> </div>
<div class="product-gallery swiper"> <div class="product-gallery swiper">
<div class="swiper-wrapper"> <div class="swiper-wrapper" data-product-images></div>
<?php
// Afficher uniquement les vraies images du produit
if ($page->hasFiles()):
foreach($page->files()->sortBy('sort', 'asc') as $image):
?>
<div class="swiper-slide">
<figure>
<?php snippet('picture', [
'file' => $image,
'alt' => $page->title()->html(),
'preset' => 'product-detail',
'size' => 50,
'lazy' => false
]) ?>
</figure>
</div>
<?php
endforeach;
endif;
?>
</div>
<!-- Navigation arrows -->
<div class="swiper-button-prev"></div> <div class="swiper-button-prev"></div>
<div class="swiper-button-next"></div> <div class="swiper-button-next"></div>
<!-- Pagination dots -->
<div class="swiper-pagination"></div> <div class="swiper-pagination"></div>
</div> </div>
</section> </div>
</main>
<?php snippet('footer', ['scripts' => ['assets/js/product-size.js', 'assets/js/snipcart.js', 'assets/js/product-gallery.js']]) ?> <div class="product-error" style="display: none;">
<p><?= t('productNotFound') ?></p>
</div>
</section>
</main>
<?php snippet('structured-data-product') ?>
<?php snippet('footer', ['scripts' => ['product']]) ?>