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:
parent
957cf79e45
commit
ad699f0365
22 changed files with 649 additions and 579 deletions
|
|
@ -1,12 +1,3 @@
|
|||
.section__product,
|
||||
.store__nav{
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.section__product,
|
||||
.store__nav {
|
||||
max-width: 1200px;
|
||||
|
|
@ -14,10 +5,6 @@
|
|||
margin-right: auto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.store__nav {
|
||||
padding-top: calc(var(--spacing) * 1);
|
||||
padding-bottom: calc(var(--spacing) * 0.5);
|
||||
|
|
@ -27,174 +14,35 @@
|
|||
a {
|
||||
text-decoration: none;
|
||||
|
||||
&::before {
|
||||
content: "← ";
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
a::before {
|
||||
content: "← ";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.section__product .details {
|
||||
// margin-bottom: calc(var(--spacing) * 2);
|
||||
|
||||
ul{
|
||||
margin-left: 2ch;
|
||||
li{
|
||||
padding-bottom: 0.2em;
|
||||
@media #{$small} {
|
||||
a {
|
||||
padding-top: 0;
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section__product {
|
||||
.details {
|
||||
ul {
|
||||
margin-left: 2ch;
|
||||
|
||||
.product-options__list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: 2ch;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
|
||||
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;
|
||||
padding-bottom: 0.2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Swiper navigation arrows
|
||||
.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 {
|
||||
@media #{$small} {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 10vh;
|
||||
|
|
@ -207,6 +55,7 @@
|
|||
margin-top: calc(var(--spacing) * 0.5);
|
||||
order: 1;
|
||||
}
|
||||
|
||||
figure {
|
||||
order: 2;
|
||||
margin-bottom: calc(var(--spacing) * 1);
|
||||
|
|
@ -226,25 +75,25 @@
|
|||
order: 5;
|
||||
}
|
||||
|
||||
.product-gallery{
|
||||
.product-gallery {
|
||||
width: 100vw;
|
||||
position: relative;
|
||||
left: calc(var(--padding-body)*-1);
|
||||
left: calc(var(--padding-body) * -1);
|
||||
|
||||
.swiper-button-prev,
|
||||
.swiper-button-next{ display: none; }
|
||||
.swiper-button-next {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media #{$small-up} {
|
||||
|
||||
|
||||
.section__product{
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: calc(var(--padding-body)*2);
|
||||
margin-bottom: calc(var(--spacing)*3);
|
||||
@media #{$small-up} {
|
||||
.product-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: calc(var(--padding-body) * 2);
|
||||
margin-bottom: calc(var(--spacing) * 3);
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-bottom: calc(var(--spacing) * 2);
|
||||
|
|
@ -255,17 +104,140 @@
|
|||
border-top: var(--border-light);
|
||||
}
|
||||
|
||||
.col-left{
|
||||
.col-left {
|
||||
min-height: 100%;
|
||||
padding-bottom: 40px; //dots
|
||||
|
||||
padding-bottom: 40px;
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,109 +1,46 @@
|
|||
/**
|
||||
* Product Add to Cart functionality
|
||||
* Handles the add to cart button interaction with Shopify
|
||||
*/
|
||||
(function() {
|
||||
// Initialize Shopify Cart
|
||||
const cart = new ShopifyCart({
|
||||
domain: 'nv7cqv-bu.myshopify.com',
|
||||
storefrontAccessToken: 'dec3d35a2554384d149c72927d1cfd1b'
|
||||
});
|
||||
|
||||
// Get product ID from data attribute
|
||||
const addToCartBtn = document.querySelector('[data-shopify-add-to-cart]');
|
||||
|
||||
if (!addToCartBtn) {
|
||||
console.warn('Add to cart button not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const productId = addToCartBtn.dataset.productId;
|
||||
const variantId = addToCartBtn.dataset.variantId;
|
||||
const stockDisplay = document.querySelector('[data-product-stock]');
|
||||
|
||||
// Get translated texts
|
||||
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',
|
||||
outOfStock: addToCartBtn.dataset.textOutOfStock || 'Out of stock',
|
||||
inStock: addToCartBtn.dataset.textInStock || 'In stock'
|
||||
error: addToCartBtn.dataset.textError || 'Error - Try again'
|
||||
};
|
||||
|
||||
// Load product data to check availability
|
||||
async function loadProductData() {
|
||||
try {
|
||||
const product = await cart.getProduct(productId);
|
||||
|
||||
if (!product) {
|
||||
console.error('Product not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the specific variant or use the first one
|
||||
let variant;
|
||||
if (variantId) {
|
||||
variant = product.variants.edges.find(
|
||||
edge => edge.node.id === `gid://shopify/ProductVariant/${variantId}`
|
||||
)?.node;
|
||||
} else {
|
||||
variant = product.variants.edges[0]?.node;
|
||||
}
|
||||
|
||||
if (!variant) {
|
||||
console.error('Variant not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update button based on availability
|
||||
if (!variant.availableForSale) {
|
||||
addToCartBtn.disabled = true;
|
||||
addToCartBtn.textContent = texts.outOfStock;
|
||||
addToCartBtn.classList.add('out-of-stock');
|
||||
if (stockDisplay) {
|
||||
stockDisplay.textContent = texts.outOfStock;
|
||||
stockDisplay.classList.add('out-of-stock');
|
||||
}
|
||||
} else {
|
||||
// Show in stock
|
||||
if (stockDisplay) {
|
||||
stockDisplay.textContent = texts.inStock;
|
||||
stockDisplay.classList.add('in-stock');
|
||||
}
|
||||
}
|
||||
|
||||
// Store variant ID for later use
|
||||
addToCartBtn.dataset.variantId = variant.id.replace('gid://shopify/ProductVariant/', '');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading product:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle add to cart click
|
||||
addToCartBtn.addEventListener('click', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Disable button during request
|
||||
const variantId = this.dataset.variantId;
|
||||
|
||||
if (!variantId) {
|
||||
console.error('No variant ID found');
|
||||
return;
|
||||
}
|
||||
|
||||
addToCartBtn.disabled = true;
|
||||
const originalText = addToCartBtn.textContent;
|
||||
addToCartBtn.textContent = texts.adding;
|
||||
|
||||
try {
|
||||
const variantId = this.dataset.variantId;
|
||||
const cartResult = await cart.addToCart(variantId, 1);
|
||||
|
||||
// Show success feedback
|
||||
addToCartBtn.textContent = texts.added;
|
||||
addToCartBtn.classList.add('success');
|
||||
|
||||
// Dispatch event to open cart drawer
|
||||
document.dispatchEvent(new CustomEvent('cart:updated', {
|
||||
detail: { cart: cartResult }
|
||||
}));
|
||||
|
||||
// Reset button after short delay
|
||||
setTimeout(() => {
|
||||
addToCartBtn.disabled = false;
|
||||
addToCartBtn.textContent = originalText;
|
||||
|
|
@ -113,11 +50,9 @@
|
|||
} catch (error) {
|
||||
console.error('Error adding to cart:', error);
|
||||
|
||||
// Show error feedback
|
||||
addToCartBtn.textContent = texts.error;
|
||||
addToCartBtn.classList.add('error');
|
||||
|
||||
// Re-enable button after delay
|
||||
setTimeout(() => {
|
||||
addToCartBtn.disabled = false;
|
||||
addToCartBtn.textContent = originalText;
|
||||
|
|
@ -125,7 +60,4 @@
|
|||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
// Load product data on page load
|
||||
loadProductData();
|
||||
})();
|
||||
|
|
|
|||
188
assets/js/product-loader.js
Normal file
188
assets/js/product-loader.js
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
(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);
|
||||
|
||||
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);
|
||||
renderVariants(product);
|
||||
setupAddToCart(product);
|
||||
renderStock(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
|
||||
: 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 renderVariants(product) {
|
||||
if (product.variants.edges.length <= 1) return;
|
||||
|
||||
const variantsContainer = document.querySelector("[data-product-variants]");
|
||||
const variantSelector = document.querySelector("[data-variant-selector]");
|
||||
|
||||
if (!variantsContainer || !variantSelector) return;
|
||||
|
||||
variantsContainer.style.display = "block";
|
||||
|
||||
variantSelector.innerHTML = product.variants.edges
|
||||
.map((edge) => {
|
||||
const variant = edge.node;
|
||||
const variantId = variant.id.replace(
|
||||
"gid://shopify/ProductVariant/",
|
||||
""
|
||||
);
|
||||
const price = parseFloat(variant.price.amount).toFixed(2) + "€";
|
||||
const availability = variant.availableForSale
|
||||
? ""
|
||||
: " (Rupture de stock)";
|
||||
|
||||
return `<option value="${variantId}" ${
|
||||
!variant.availableForSale ? "disabled" : ""
|
||||
}>
|
||||
${variant.title} - ${price}${availability}
|
||||
</option>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
variantSelector.addEventListener("change", (e) => {
|
||||
const addToCartBtn = document.querySelector("[data-shopify-add-to-cart]");
|
||||
if (addToCartBtn) {
|
||||
addToCartBtn.dataset.variantId = e.target.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 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 renderStock(product) {
|
||||
const stockEl = document.querySelector("[data-product-stock]");
|
||||
if (!stockEl) return;
|
||||
|
||||
const addToCartBtn = document.querySelector("[data-shopify-add-to-cart]");
|
||||
|
||||
if (product.availableForSale) {
|
||||
stockEl.textContent = addToCartBtn?.dataset.textInStock || "En stock";
|
||||
stockEl.classList.add("in-stock");
|
||||
} else {
|
||||
stockEl.textContent =
|
||||
addToCartBtn?.dataset.textOutOfStock || "Rupture de stock";
|
||||
stockEl.classList.add("out-of-stock");
|
||||
}
|
||||
}
|
||||
})();
|
||||
47
assets/js/products-list-loader.js
Normal file
47
assets/js/products-list-loader.js
Normal 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>';
|
||||
}
|
||||
})();
|
||||
|
|
@ -73,6 +73,117 @@ class ShopifyCart {
|
|||
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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
Title: Éclairages : 12 entretiens et analyses sur les violences d'État
|
||||
|
||||
----
|
||||
|
||||
Uuid: gzshayl6xoefrnsz
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
Title: Éclairages : 12 entretiens et analyses sur les violences d’État
|
||||
|
||||
----
|
||||
|
||||
Shopifyhandle: eclairages-12-entretiens-et-analyses-sur-les-violences-d-etat
|
||||
|
||||
----
|
||||
|
||||
Uuid: gzshayl6xoefrnsz
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
Title: T-shirt Index 01
|
||||
|
||||
----
|
||||
|
||||
Price: 35
|
||||
|
||||
----
|
||||
|
||||
Description: <p>T-shirt de soutien à Index, 100% coton</p>
|
||||
|
||||
----
|
||||
|
||||
Details: <p>Organic cotton t-shirt with screen printing.<br>Print on the front: "INDEX" logo, 10 cm wide.</p><ul><li><p>100% organic cotton</p></li><li><p>Weight: 180 g/m²</p></li><li><p>Single jersey with very soft feel</p></li><li><p>Excellent durability over time</p></li><li><p>Internal neckband</p></li><li><p>Double stitching on sleeves and bottom hem</p></li></ul><p>Shipping only via Mondial Relay to France, Belgium and Switzerland.</p>
|
||||
|
||||
----
|
||||
|
||||
Options:
|
||||
|
||||
-
|
||||
label: Size
|
||||
values: XS, S, M, L, XL
|
||||
|
||||
----
|
||||
|
||||
Snipcartid: tshirt-01
|
||||
|
||||
----
|
||||
|
||||
Backgroundcolor: #ffffff
|
||||
|
||||
----
|
||||
|
||||
Template: product
|
||||
|
|
@ -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: 262 KiB |
|
|
@ -1,9 +0,0 @@
|
|||
Sort: 1
|
||||
|
||||
----
|
||||
|
||||
Uuid: elxkhcta8dkjhr60
|
||||
|
||||
----
|
||||
|
||||
Template: image
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 MiB |
|
|
@ -1,9 +0,0 @@
|
|||
Sort: 2
|
||||
|
||||
----
|
||||
|
||||
Uuid: deupkqq83jvloz0r
|
||||
|
||||
----
|
||||
|
||||
Template: image
|
||||
5
content/2_t-shirt-index/product.en.txt
Normal file
5
content/2_t-shirt-index/product.en.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Title: T-shirt Index
|
||||
|
||||
----
|
||||
|
||||
Uuid: qq27mjjpethsvnwp
|
||||
9
content/2_t-shirt-index/product.fr.txt
Normal file
9
content/2_t-shirt-index/product.fr.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
Title: T-shirt Index
|
||||
|
||||
----
|
||||
|
||||
Shopifyhandle: t-shirt-index-01
|
||||
|
||||
----
|
||||
|
||||
Uuid: qq27mjjpethsvnwp
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
25
site/snippets/buy-button.php
Normal file
25
site/snippets/buy-button.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 pouvez 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') ?>
|
||||
|
|
|
|||
|
|
@ -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']]) ?>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue