Compare commits

..

2 commits

Author SHA1 Message Date
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
21 changed files with 611 additions and 154 deletions

View file

@ -7,7 +7,10 @@
"Bash(find:*)",
"Bash(curl:*)",
"WebFetch(domain:snipcart.com)",
"Bash(grep:*)"
"Bash(grep:*)",
"Bash(npm run build:*)",
"Bash(php test-shopify.php:*)",
"WebFetch(domain:getkirby.com)"
]
}
}

View file

@ -1,5 +0,0 @@
Title: Éclairages : 12 entretiens et analyses sur les violences d'État
----
Uuid: gzshayl6xoefrnsz

View file

@ -1,9 +0,0 @@
Title: Éclairages : 12 entretiens et analyses sur les violences dÉtat
----
Shopifyhandle: eclairages-12-entretiens-et-analyses-sur-les-violences-d-etat
----
Uuid: gzshayl6xoefrnsz

View file

@ -1,5 +0,0 @@
Title: T-shirt Index
----
Uuid: qq27mjjpethsvnwp

View file

@ -1,9 +0,0 @@
Title: T-shirt Index
----
Shopifyhandle: t-shirt-index-01
----
Uuid: qq27mjjpethsvnwp

View file

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

View file

@ -1,10 +1,81 @@
<?php
require_once __DIR__ . '/shopify.php';
use Kirby\Cms\Page;
return [
'debug' => true,
'languages' => true,
'cache' => [
'shopify' => true
],
'routes' => [
// French product pages (default)
[
'pattern' => '(:any)',
'action' => function($slug) {
// Skip known pages
if (in_array($slug, ['home', 'error', 'thanks'])) {
return null;
}
$products = getShopifyProducts();
foreach ($products as $product) {
if ($product['handle'] === $slug) {
return Page::factory([
'slug' => $product['handle'],
'template' => 'product',
'parent' => site()->homePage(),
'content' => [
'title' => $product['title'],
'shopifyHandle' => $product['handle'],
'uuid' => $product['id']
]
]);
}
}
// Not a product, let Kirby handle normally
return null;
}
],
// English product pages
[
'pattern' => 'en/(:any)',
'action' => function($slug) {
// Skip known pages
if (in_array($slug, ['home', 'error', 'thanks'])) {
return null;
}
$products = getShopifyProducts();
foreach ($products as $product) {
if ($product['handle'] === $slug) {
return Page::factory([
'slug' => $product['handle'],
'template' => 'product',
'parent' => site()->homePage(),
'content' => [
'title' => $product['title'],
'shopifyHandle' => $product['handle'],
'uuid' => $product['id']
]
]);
}
}
// Not a product, let Kirby handle normally
return null;
}
]
],
'thumbs' => [
'quality' => 85,
'format' => 'webp',
@ -35,117 +106,4 @@ return [
],
],
],
'routes' => [
// SNIPCART ROUTES - Désactivées, voir assets/snipcart-archive/README.md pour restauration
/*
[
'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);
}
]
*/
]
];

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

@ -0,0 +1,81 @@
<?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_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,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
}
});