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>
This commit is contained in:
isUnknown 2026-01-16 12:03:20 +01:00
parent 957cf79e45
commit ad699f0365
22 changed files with 649 additions and 579 deletions

View file

@ -1,134 +1,21 @@
title:
en: Product
fr: Produit
title: Product
icon: cart
tabs:
content:
label:
en: Content
fr: Contenu
columns:
- width: 2/3
sections:
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
columns:
- width: 1/1
fields:
info:
type: info
text:
en: "Product data (title, description, images, price) is managed in Shopify Admin. This Kirby page only serves for routing."
fr: "Les données produit (titre, description, images, prix) sont gérées dans Shopify Admin. Cette page Kirby sert uniquement au routing."
- width: 1/3
sections:
images:
type: files
headline:
en: Product Images
fr: Images du produit
template: image
layout: cards
shopifyHandle:
label:
en: Shopify Handle
fr: Shopify Handle
type: text
help:
en: "Product handle from Shopify (e.g. tshirt-index-01). If empty, uses the page slug."
fr: "Handle du produit Shopify (ex: tshirt-index-01). Si vide, utilise le slug de la page Kirby."
placeholder: tshirt-index-01

View file

@ -27,6 +27,9 @@ return [
'addedToCart' => 'Added! ✓',
'errorAddToCart' => 'Error - Try again',
'closeCart' => 'Close cart',
'loading' => 'Loading...',
'productNotFound' => 'Product not found',
'selectVariant' => 'Select',
// Blueprints - Home
'home.title' => 'Home',

View file

@ -27,6 +27,9 @@ return [
'addedToCart' => 'Ajouté ! ✓',
'errorAddToCart' => 'Erreur - Réessayer',
'closeCart' => 'Fermer le panier',
'loading' => 'Chargement...',
'productNotFound' => 'Produit non trouvé',
'selectVariant' => 'Choisir',
// Blueprints - Home
'home.title' => 'Accueil',

View file

@ -0,0 +1,25 @@
<div class="product-purchase">
<div class="product-stock-info">
<p data-product-stock class="stock-status"></p>
</div>
<div class="product-variants" data-product-variants style="display: none;">
<label for="variant-select"><?= t('selectVariant') ?></label>
<select id="variant-select" data-variant-selector></select>
</div>
<button
class="btn-add-to-cart"
data-shopify-add-to-cart
data-product-id=""
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

@ -13,11 +13,16 @@
<script src="<?= url('assets/js/onload.js') ?>"></script>
<script src="<?= url('assets/js/shopify-cart.js') ?>"></script>
<script src="<?= url('assets/js/cart-drawer.js') ?>"></script>
<?php if(isset($scripts) && is_array($scripts)): ?>
<?php foreach($scripts as $script): ?>
<script src="<?= url($script) ?>"></script>
<?php endforeach ?>
<?php if ($scripts ?? null): ?>
<?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 ?>
<script src="<?= url('assets/js/cart-drawer.js') ?>"></script>
</body>
</html>

View file

@ -1,35 +1,24 @@
<?php snippet('header', ['title' => $site->title(), 'template' => 'store']) ?>
<main>
<p class="p__baseline-big">
<?= $page->baseline()->or('Bienvenue sur la boutique de soutien à Index') ?>
</p>
<main>
<p class="p__baseline-big">
<?= $page->baseline()->or('Bienvenue sur la boutique de soutien à Index') ?>
</p>
<section id="store__container">
<?php foreach($site->children()->listed() as $product): ?>
<article class="store__product">
<figure>
<?php if($cover = $product->files()->sortBy('sort', 'asc')->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>
<section id="store__container"
data-products-loader
data-language="<?= strtoupper($kirby->language()->code()) ?>">
<p class="p__baseline-big">
<?= t('supportText', 'Pour nous soutenir, vous&nbsp;pouvez&nbsp;aussi') ?>
<a href="https://soutenir.index.ngo" class="link-don"><?= t('makeDonation', 'faire un don') ?></a>
</p>
</main>
<div class="products-loading">
<p><?= t('loading') ?></p>
</div>
</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') ?>

View file

@ -1,60 +1,47 @@
<?php snippet('header', ['title' => $page->title(), 'template' => 'shop']) ?>
<?php
$shopifyHandle = $page->shopifyHandle()->or($page->slug());
<main>
<nav class="store__nav">
<a href="<?= $site->homePage()->url() ?>"><?= t('backToShop', 'Retour à la boutique') ?></a>
</nav>
snippet('header', ['title' => $page->title(), 'template' => 'shop']);
?>
<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="hero">
<h2 class="p__baseline-big"><?= $page->title()->html() ?></h2>
<p class="p__baseline-big"><?= $page->price() ?>€</p>
<h2 class="p__baseline-big" data-product-title></h2>
<p class="p__baseline-big" data-product-price></p>
</div>
<div class="details">
<?php if($page->details()->isNotEmpty()): ?>
<?= $page->details()->kt() ?>
<?php endif ?>
</div>
<div class="details" data-product-details></div>
<?php snippet('buy-button--t-shirt') ?>
<?php snippet('buy-button') ?>
</div>
<div class="product-gallery swiper">
<div class="swiper-wrapper">
<?php
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-wrapper" data-product-images></div>
<div class="swiper-button-prev"></div>
<div class="swiper-button-next"></div>
<!-- Pagination dots -->
<div class="swiper-pagination"></div>
</div>
</section>
</main>
</div>
<?php snippet('footer', ['scripts' => [
'assets/js/product-add-to-cart.js',
'assets/js/product-gallery.js'
]]) ?>
<div class="product-error" style="display: none;">
<p><?= t('productNotFound') ?></p>
</div>
</section>
</main>
<?php snippet('footer', ['scripts' => ['product']]) ?>