Init PercoVitrine — app de gestion de fiches produits
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Options -Indexes
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Bloquer accès au dossier data
|
||||||
|
RewriteRule ^data/ - [F,L]
|
||||||
|
|
||||||
|
# Router tout vers le bon fichier PHP
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^(.*)$ index.php [QSA,L]
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
FROM php:8.3-apache
|
||||||
|
|
||||||
|
RUN docker-php-ext-install pdo pdo_sqlite && \
|
||||||
|
a2enmod rewrite
|
||||||
|
|
||||||
|
COPY . /var/www/html/
|
||||||
|
|
||||||
|
RUN chown -R www-data:www-data /var/www/html && \
|
||||||
|
echo "ServerName localhost" >> /etc/apache2/apache2.conf
|
||||||
|
|
||||||
|
COPY docker/apache.conf /etc/apache2/sites-available/000-default.conf
|
||||||
|
|
||||||
|
VOLUME /data
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/db.php';
|
||||||
|
require __DIR__ . '/includes/helpers.php';
|
||||||
|
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
/* ─── DELETE product ─────────────────────────────────────── */
|
||||||
|
if ($action === 'product' && $method === 'DELETE') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if (!$id) err('id manquant');
|
||||||
|
$medias = $pdo->prepare("SELECT filename FROM product_media WHERE product_id=?")->execute([$id]) ? $pdo->query("SELECT filename FROM product_media WHERE product_id=$id")->fetchAll() : [];
|
||||||
|
foreach ($medias as $m) { if ($m['filename']) @unlink($UPLOAD_DIR . $m['filename']); }
|
||||||
|
$pdo->prepare("DELETE FROM products WHERE id=?")->execute([$id]);
|
||||||
|
ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── DELETE media ───────────────────────────────────────── */
|
||||||
|
if ($action === 'media' && $method === 'DELETE') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if (!$id) err('id manquant');
|
||||||
|
$m = $pdo->prepare("SELECT filename FROM product_media WHERE id=?")->execute([$id])
|
||||||
|
? $pdo->query("SELECT filename FROM product_media WHERE id=$id")->fetch()
|
||||||
|
: null;
|
||||||
|
if ($m && $m['filename']) @unlink($UPLOAD_DIR . $m['filename']);
|
||||||
|
$pdo->prepare("DELETE FROM product_media WHERE id=?")->execute([$id]);
|
||||||
|
ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── PATCH media sort ───────────────────────────────────── */
|
||||||
|
if ($action === 'media_sort' && $method === 'POST') {
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$ids = $body['ids'] ?? [];
|
||||||
|
$stmt = $pdo->prepare("UPDATE product_media SET sort_order=? WHERE id=?");
|
||||||
|
foreach ($ids as $i => $id) $stmt->execute([$i, (int)$id]);
|
||||||
|
ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── GET product JSON ───────────────────────────────────── */
|
||||||
|
if ($action === 'product' && $method === 'GET') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if (!$id) err('id manquant');
|
||||||
|
$p = $pdo->prepare("SELECT * FROM products WHERE id=?")->execute([$id])
|
||||||
|
? $pdo->query("SELECT * FROM products WHERE id=$id")->fetch()
|
||||||
|
: null;
|
||||||
|
if (!$p) err('introuvable', 404);
|
||||||
|
$p['media'] = $pdo->query("SELECT * FROM product_media WHERE product_id={$id} ORDER BY sort_order")->fetchAll();
|
||||||
|
ok($p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── POST upload media ──────────────────────────────────── */
|
||||||
|
if ($action === 'upload' && $method === 'POST') {
|
||||||
|
$pid = (int)($_POST['product_id'] ?? 0);
|
||||||
|
if (!$pid) err('product_id manquant');
|
||||||
|
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
|
||||||
|
$orig = $_FILES['file']['name'];
|
||||||
|
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
|
||||||
|
$allowed = array_merge(imgExts(), vidExts());
|
||||||
|
if (!in_array($ext, $allowed)) err('format non supporté');
|
||||||
|
$fname = uniqid('m', true) . '.' . $ext;
|
||||||
|
if (!move_uploaded_file($_FILES['file']['tmp_name'], $UPLOAD_DIR . $fname)) err('échec upload');
|
||||||
|
$type = isImage($fname) ? 'image' : 'video';
|
||||||
|
$order = (int)$pdo->query("SELECT COALESCE(MAX(sort_order),0)+1 FROM product_media WHERE product_id=$pid")->fetchColumn();
|
||||||
|
$pdo->prepare("INSERT INTO product_media (product_id,type,filename,original_name,sort_order) VALUES(?,?,?,?,?)")
|
||||||
|
->execute([$pid, $type, $fname, $orig, $order]);
|
||||||
|
$newId = $pdo->lastInsertId();
|
||||||
|
ok(['id'=>$newId,'type'=>$type,'filename'=>$fname,'original_name'=>$orig,'sort_order'=>$order]);
|
||||||
|
}
|
||||||
|
// Video URL
|
||||||
|
if (!empty($_POST['video_url'])) {
|
||||||
|
$url = filter_var($_POST['video_url'], FILTER_SANITIZE_URL);
|
||||||
|
$order = (int)$pdo->query("SELECT COALESCE(MAX(sort_order),0)+1 FROM product_media WHERE product_id=$pid")->fetchColumn();
|
||||||
|
$pdo->prepare("INSERT INTO product_media (product_id,type,video_url,sort_order) VALUES(?,?,?,?)")
|
||||||
|
->execute([$pid, 'video', $url, $order]);
|
||||||
|
ok(['id'=>$pdo->lastInsertId(),'type'=>'video','video_url'=>$url,'sort_order'=>$order]);
|
||||||
|
}
|
||||||
|
err('aucun fichier');
|
||||||
|
}
|
||||||
|
|
||||||
|
err('action inconnue', 404);
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
:root{
|
||||||
|
--bg:#0d1117;--bg-card:#161b22;--bg-input:#0d1321;
|
||||||
|
--border:#30363d;--text:#f1f5f9;--muted:#8b949e;
|
||||||
|
--primary:#2DA3F2;--primary-dark:#1a8dd9;
|
||||||
|
--green:#22c55e;--red:#ef4444;--volt:#facc15;
|
||||||
|
--shadow:0 4px 24px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
body{font-family:"Outfit",system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;font-size:15px}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.app-wrap{display:flex;min-height:100vh}
|
||||||
|
.sidebar{width:220px;background:var(--bg-card);border-right:1px solid var(--border);display:flex;flex-direction:column;position:fixed;top:0;left:0;bottom:0;z-index:100;overflow-y:auto}
|
||||||
|
.sidebar-brand{padding:1.25rem 1rem 1rem;font-size:1.1rem;font-weight:800;background:linear-gradient(135deg,var(--primary),var(--volt));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;border-bottom:1px solid var(--border);text-decoration:none;display:block}
|
||||||
|
.sidebar-nav{flex:1;padding:.5rem}
|
||||||
|
.sidebar-link{display:flex;align-items:center;gap:.6rem;padding:.5rem .75rem;color:var(--muted);text-decoration:none;border-radius:8px;font-size:.875rem;font-weight:500;margin:.1rem 0;transition:all .2s}
|
||||||
|
.sidebar-link i{width:16px;text-align:center;font-size:.8rem}
|
||||||
|
.sidebar-link:hover{background:rgba(45,163,242,.08);color:var(--primary)}
|
||||||
|
.sidebar-link.active{background:rgba(45,163,242,.14);color:var(--primary);border:1px solid rgba(45,163,242,.2)}
|
||||||
|
.sidebar-section{font-size:.62rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:.75rem .75rem .25rem;font-weight:700}
|
||||||
|
.main{margin-left:220px;flex:1;padding:2rem}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:1.5rem}
|
||||||
|
.page-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:1.75rem;flex-wrap:wrap;gap:.75rem}
|
||||||
|
.page-title{font-size:1.4rem;font-weight:800}
|
||||||
|
.page-sub{font-size:.82rem;color:var(--muted);margin-top:.2rem}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge{display:inline-flex;align-items:center;padding:.2rem .65rem;border-radius:20px;font-size:.72rem;font-weight:700;gap:.3rem}
|
||||||
|
.badge-muted{background:rgba(139,148,158,.1);color:var(--muted);border:1px solid rgba(139,148,158,.2)}
|
||||||
|
.badge-volt{background:rgba(250,204,21,.1);color:var(--volt);border:1px solid rgba(250,204,21,.2)}
|
||||||
|
.badge-green{background:rgba(34,197,94,.1);color:var(--green);border:1px solid rgba(34,197,94,.2)}
|
||||||
|
.badge-red{background:rgba(239,68,68,.1);color:var(--red);border:1px solid rgba(239,68,68,.2)}
|
||||||
|
.badge-blue{background:rgba(45,163,242,.1);color:var(--primary);border:1px solid rgba(45,163,242,.2)}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn{display:inline-flex;align-items:center;gap:.4rem;padding:.45rem 1rem;border-radius:8px;font-size:.875rem;font-weight:600;cursor:pointer;border:none;text-decoration:none;transition:all .2s;font-family:inherit}
|
||||||
|
.btn-primary{background:linear-gradient(135deg,var(--primary),var(--primary-dark));color:#fff}
|
||||||
|
.btn-primary:hover{opacity:.9;box-shadow:0 0 20px rgba(45,163,242,.3)}
|
||||||
|
.btn-secondary{background:transparent;color:var(--muted);border:1px solid var(--border)}
|
||||||
|
.btn-secondary:hover{color:var(--text);border-color:var(--primary)}
|
||||||
|
.btn-danger{background:rgba(239,68,68,.1);color:var(--red);border:1px solid rgba(239,68,68,.25)}
|
||||||
|
.btn-danger:hover{background:rgba(239,68,68,.2)}
|
||||||
|
.btn-success{background:rgba(34,197,94,.12);color:var(--green);border:1px solid rgba(34,197,94,.25)}
|
||||||
|
.btn-success:hover{background:rgba(34,197,94,.22)}
|
||||||
|
.btn-sm{padding:.3rem .7rem;font-size:.8rem}
|
||||||
|
.btn-icon{width:34px;height:34px;padding:0;justify-content:center;border-radius:8px}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group{margin-bottom:1.1rem}
|
||||||
|
label{display:block;font-size:.78rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem}
|
||||||
|
input,select,textarea{background:var(--bg-input);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:.5rem .75rem;font-family:inherit;font-size:.875rem;width:100%;transition:border-color .2s,box-shadow .2s}
|
||||||
|
input:focus,select:focus,textarea:focus{border-color:var(--primary);outline:none;box-shadow:0 0 0 3px rgba(45,163,242,.1)}
|
||||||
|
input::placeholder,textarea::placeholder{color:var(--muted)}
|
||||||
|
select option{background:var(--bg-card)}
|
||||||
|
textarea{resize:vertical;min-height:140px;line-height:1.6}
|
||||||
|
.input-group{display:flex;gap:.5rem;align-items:center}
|
||||||
|
.input-prefix{background:var(--bg-card);border:1px solid var(--border);border-right:none;color:var(--muted);padding:.5rem .75rem;border-radius:8px 0 0 8px;font-size:.875rem;white-space:nowrap}
|
||||||
|
.input-group input{border-radius:0 8px 8px 0}
|
||||||
|
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
|
||||||
|
.form-row-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem}
|
||||||
|
.form-hint{font-size:.74rem;color:var(--muted);margin-top:.3rem}
|
||||||
|
|
||||||
|
/* Product grid */
|
||||||
|
.products-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem;margin-top:1rem}
|
||||||
|
.product-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;overflow:hidden;text-decoration:none;color:inherit;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s,transform .15s}
|
||||||
|
.product-card:hover{border-color:rgba(45,163,242,.3);box-shadow:0 4px 24px rgba(45,163,242,.1);transform:translateY(-2px)}
|
||||||
|
.product-thumb{width:100%;aspect-ratio:1;object-fit:cover;background:var(--bg);display:block}
|
||||||
|
.product-thumb-placeholder{width:100%;aspect-ratio:1;background:var(--bg);display:flex;align-items:center;justify-content:center;color:var(--border);font-size:3rem}
|
||||||
|
.product-body{padding:1rem;flex:1;display:flex;flex-direction:column;gap:.4rem}
|
||||||
|
.product-title{font-weight:700;font-size:.95rem;color:var(--text)}
|
||||||
|
.product-meta{display:flex;justify-content:space-between;align-items:center;margin-top:auto;padding-top:.5rem}
|
||||||
|
.product-price{font-size:1rem;font-weight:800;color:var(--primary)}
|
||||||
|
.product-sku{font-size:.72rem;color:var(--muted)}
|
||||||
|
.product-cat{font-size:.72rem;color:var(--muted)}
|
||||||
|
.product-actions{display:flex;gap:.4rem;padding:.75rem 1rem;border-top:1px solid var(--border);background:rgba(0,0,0,.15)}
|
||||||
|
|
||||||
|
/* Media gallery */
|
||||||
|
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:.6rem;margin-top:.6rem}
|
||||||
|
.media-item{position:relative;border-radius:8px;overflow:hidden;aspect-ratio:1;background:var(--bg);border:1px solid var(--border)}
|
||||||
|
.media-item img,.media-item video{width:100%;height:100%;object-fit:cover}
|
||||||
|
.media-item .del-btn{position:absolute;top:4px;right:4px;background:rgba(0,0,0,.7);color:#fff;border:none;border-radius:50%;width:22px;height:22px;font-size:.7rem;cursor:pointer;display:none;align-items:center;justify-content:center}
|
||||||
|
.media-item:hover .del-btn{display:flex}
|
||||||
|
.media-item .sort-handle{position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.7);color:#fff;border-radius:4px;padding:2px 5px;font-size:.65rem;cursor:grab}
|
||||||
|
.media-item.video-item::after{content:'▶';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:1.5rem;color:#fff;pointer-events:none;text-shadow:0 0 8px rgba(0,0,0,.8)}
|
||||||
|
.drop-zone{border:2px dashed var(--border);border-radius:10px;padding:2rem;text-align:center;color:var(--muted);cursor:pointer;transition:all .2s;position:relative}
|
||||||
|
.drop-zone.drag-over{border-color:var(--primary);background:rgba(45,163,242,.05);color:var(--primary)}
|
||||||
|
.drop-zone input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%}
|
||||||
|
.preview-strip{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.6rem}
|
||||||
|
.preview-item{width:72px;height:72px;border-radius:6px;overflow:hidden;border:1px solid var(--border);position:relative;flex-shrink:0}
|
||||||
|
.preview-item img,.preview-item video{width:100%;height:100%;object-fit:cover}
|
||||||
|
.preview-item .rm{position:absolute;top:-5px;right:-5px;background:var(--red);color:#fff;border:none;border-radius:50%;width:16px;height:16px;font-size:.6rem;cursor:pointer;display:flex;align-items:center;justify-content:center}
|
||||||
|
|
||||||
|
/* Detail view */
|
||||||
|
.product-detail-grid{display:grid;grid-template-columns:1fr 340px;gap:1.5rem;align-items:start}
|
||||||
|
.media-main img,.media-main video{width:100%;border-radius:10px;aspect-ratio:1;object-fit:cover}
|
||||||
|
.media-thumbs{display:flex;gap:.5rem;margin-top:.6rem;flex-wrap:wrap}
|
||||||
|
.media-thumb{width:64px;height:64px;border-radius:6px;overflow:hidden;border:2px solid transparent;cursor:pointer;flex-shrink:0}
|
||||||
|
.media-thumb.active{border-color:var(--primary)}
|
||||||
|
.media-thumb img,.media-thumb video{width:100%;height:100%;object-fit:cover}
|
||||||
|
.product-info-card{display:flex;flex-direction:column;gap:1rem}
|
||||||
|
.price-block{font-size:2rem;font-weight:800;color:var(--primary)}
|
||||||
|
.shipping-block{font-size:.85rem;color:var(--muted)}
|
||||||
|
.info-row{display:flex;justify-content:space-between;padding:.6rem 0;border-bottom:1px solid var(--border);font-size:.875rem}
|
||||||
|
.info-row:last-child{border:none}
|
||||||
|
.info-label{color:var(--muted);font-weight:600}
|
||||||
|
.desc-block{white-space:pre-wrap;line-height:1.7;font-size:.9rem;color:var(--text)}
|
||||||
|
|
||||||
|
/* Etsy panel */
|
||||||
|
.etsy-panel{background:linear-gradient(135deg,rgba(241,90,34,.05),rgba(241,90,34,.02));border:1px solid rgba(241,90,34,.2);border-radius:12px;padding:1.25rem}
|
||||||
|
.etsy-logo{font-size:1.1rem;font-weight:800;color:#f15a22}
|
||||||
|
.etsy-status-ok{color:var(--green);font-size:.85rem}
|
||||||
|
.etsy-status-ko{color:var(--muted);font-size:.85rem}
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.filters{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem;align-items:center}
|
||||||
|
.filter-chip{padding:.3rem .75rem;border-radius:20px;font-size:.78rem;font-weight:600;cursor:pointer;border:1px solid var(--border);background:transparent;color:var(--muted);text-decoration:none;transition:all .2s}
|
||||||
|
.filter-chip:hover,.filter-chip.active{background:rgba(45,163,242,.12);color:var(--primary);border-color:rgba(45,163,242,.3)}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table{width:100%;border-collapse:collapse}
|
||||||
|
.table th{text-align:left;padding:.6rem .75rem;font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);border-bottom:1px solid var(--border)}
|
||||||
|
.table td{padding:.75rem;border-bottom:1px solid rgba(48,54,61,.5);font-size:.875rem}
|
||||||
|
.table tr:last-child td{border:none}
|
||||||
|
.table tr:hover td{background:rgba(255,255,255,.02)}
|
||||||
|
|
||||||
|
/* Utils */
|
||||||
|
.flex{display:flex}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}
|
||||||
|
.items-center{align-items:center}.justify-between{justify-content:space-between}
|
||||||
|
.flex-wrap{flex-wrap:wrap}.flex-1{flex:1}.flex-col{flex-direction:column}
|
||||||
|
.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}
|
||||||
|
.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}
|
||||||
|
.text-muted{color:var(--muted)}.text-sm{font-size:.875rem}.text-xs{font-size:.75rem}
|
||||||
|
.font-bold{font-weight:700}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.empty-state{text-align:center;padding:4rem 2rem;color:var(--muted)}
|
||||||
|
.empty-state i{font-size:3rem;opacity:.2;margin-bottom:1rem;display:block}
|
||||||
|
.toast-wrap{position:fixed;bottom:1.5rem;right:1.5rem;z-index:9999;display:flex;flex-direction:column;gap:.5rem}
|
||||||
|
.toast{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:.7rem 1rem;font-size:.875rem;min-width:200px;color:var(--text);box-shadow:var(--shadow);border-left:4px solid var(--primary);animation:slideIn .2s ease}
|
||||||
|
.toast.error{border-left-color:var(--red)}
|
||||||
|
@keyframes slideIn{from{opacity:0;transform:translateX(16px)}to{opacity:1;transform:none}}
|
||||||
|
.sep{border:none;border-top:1px solid var(--border);margin:1.25rem 0}
|
||||||
|
@media(max-width:768px){.sidebar{width:56px}.sidebar .sidebar-brand span,.sidebar .sidebar-section,.sidebar .sidebar-link span{display:none}.main{margin-left:56px;padding:1rem}.form-row,.form-row-3{grid-template-columns:1fr}.product-detail-grid{grid-template-columns:1fr}}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/db.php';
|
||||||
|
require __DIR__ . '/includes/helpers.php';
|
||||||
|
require __DIR__ . '/includes/layout.php';
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$action = $_POST['_action'] ?? '';
|
||||||
|
if ($action === 'add') {
|
||||||
|
$name = trim($_POST['name'] ?? '');
|
||||||
|
if (!$name) { $error = 'Le nom est obligatoire.'; }
|
||||||
|
else {
|
||||||
|
try { $pdo->prepare("INSERT INTO categories (name) VALUES(?)")->execute([$name]); }
|
||||||
|
catch (PDOException) { $error = 'Ce nom existe déjà.'; }
|
||||||
|
}
|
||||||
|
} elseif ($action === 'rename') {
|
||||||
|
$cid = (int)($_POST['id'] ?? 0);
|
||||||
|
$name = trim($_POST['name'] ?? '');
|
||||||
|
if ($cid && $name) {
|
||||||
|
try { $pdo->prepare("UPDATE categories SET name=? WHERE id=?")->execute([$name,$cid]); }
|
||||||
|
catch (PDOException) { $error = 'Ce nom existe déjà.'; }
|
||||||
|
}
|
||||||
|
} elseif ($action === 'delete') {
|
||||||
|
$cid = (int)($_POST['id'] ?? 0);
|
||||||
|
if ($cid) {
|
||||||
|
$pdo->prepare("UPDATE products SET category_id=NULL WHERE category_id=?")->execute([$cid]);
|
||||||
|
$pdo->prepare("DELETE FROM categories WHERE id=?")->execute([$cid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$error) { header('Location: /categories.php'); exit; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$cats = $pdo->query("SELECT c.*, COUNT(p.id) as nb FROM categories c LEFT JOIN products p ON p.category_id=c.id GROUP BY c.id ORDER BY c.name")->fetchAll();
|
||||||
|
|
||||||
|
layout_head('Catégories');
|
||||||
|
layout_sidebar('categories');
|
||||||
|
layout_main_open();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title"><i class="fas fa-tags" style="color:var(--primary);margin-right:.4rem"></i>Catégories</div>
|
||||||
|
<button onclick="document.getElementById('addModal').style.display='flex'" class="btn btn-primary"><i class="fas fa-plus"></i> Ajouter</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($error): ?><div style="background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;color:var(--red)"><?= h($error) ?></div><?php endif ?>
|
||||||
|
|
||||||
|
<?php if (!$cats): ?>
|
||||||
|
<div class="empty-state"><i class="fas fa-tags"></i><p>Aucune catégorie.<br>Crée-en une pour organiser tes produits.</p></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="card" style="padding:0">
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>Nom</th><th style="width:100px;text-align:center">Produits</th><th style="width:120px"></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($cats as $c): ?>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?= h($c['name']) ?></strong></td>
|
||||||
|
<td style="text-align:center"><span class="badge badge-muted"><?= $c['nb'] ?></span></td>
|
||||||
|
<td style="text-align:right">
|
||||||
|
<div style="display:flex;gap:.4rem;justify-content:flex-end">
|
||||||
|
<button onclick="openRename(<?= $c['id'] ?>,'<?= h(addslashes($c['name'])) ?>')" class="btn btn-secondary btn-sm" title="Renommer"><i class="fas fa-pen"></i></button>
|
||||||
|
<?php if ($c['nb'] == 0): ?>
|
||||||
|
<form method="post" style="display:inline" onsubmit="return confirm('Supprimer cette catégorie ?')">
|
||||||
|
<input type="hidden" name="_action" value="delete">
|
||||||
|
<input type="hidden" name="id" value="<?= $c['id'] ?>">
|
||||||
|
<button class="btn btn-danger btn-sm" title="Supprimer"><i class="fas fa-trash"></i></button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<button class="btn btn-danger btn-sm" disabled title="Catégorie utilisée" style="opacity:.35"><i class="fas fa-trash"></i></button>
|
||||||
|
<?php endif ?>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<!-- Modal ajouter -->
|
||||||
|
<div id="addModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:999;align-items:center;justify-content:center">
|
||||||
|
<div class="card" style="width:360px">
|
||||||
|
<div style="font-weight:700;margin-bottom:1rem">Nouvelle catégorie</div>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="_action" value="add">
|
||||||
|
<div class="form-group"><label>Nom</label><input type="text" name="name" autofocus required></div>
|
||||||
|
<div style="display:flex;gap:.5rem">
|
||||||
|
<button type="submit" class="btn btn-primary" style="flex:1"><i class="fas fa-plus"></i> Ajouter</button>
|
||||||
|
<button type="button" onclick="document.getElementById('addModal').style.display='none'" class="btn btn-secondary">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal renommer -->
|
||||||
|
<div id="renameModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:999;align-items:center;justify-content:center">
|
||||||
|
<div class="card" style="width:360px">
|
||||||
|
<div style="font-weight:700;margin-bottom:1rem">Renommer la catégorie</div>
|
||||||
|
<form method="post" id="renameForm">
|
||||||
|
<input type="hidden" name="_action" value="rename">
|
||||||
|
<input type="hidden" name="id" id="renameId">
|
||||||
|
<div class="form-group"><label>Nouveau nom</label><input type="text" name="name" id="renameName" required></div>
|
||||||
|
<div style="display:flex;gap:.5rem">
|
||||||
|
<button type="submit" class="btn btn-primary" style="flex:1"><i class="fas fa-check"></i> Enregistrer</button>
|
||||||
|
<button type="button" onclick="document.getElementById('renameModal').style.display='none'" class="btn btn-secondary">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openRename(id, name) {
|
||||||
|
document.getElementById('renameId').value = id;
|
||||||
|
document.getElementById('renameName').value = name;
|
||||||
|
document.getElementById('renameModal').style.display = 'flex';
|
||||||
|
setTimeout(()=>document.getElementById('renameName').focus(),50);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<?php layout_foot(); ?>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
percovitrine:
|
||||||
|
build: .
|
||||||
|
container_name: percovitrine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DB_PATH: /data/vitrine.db
|
||||||
|
UPLOAD_DIR: /data/uploads/
|
||||||
|
volumes:
|
||||||
|
- percovitrine_data:/data
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.percovitrine.rule=Host(`vitrine.perco.home`)"
|
||||||
|
- "traefik.http.routers.percovitrine.entrypoints=web"
|
||||||
|
- "traefik.http.services.percovitrine.loadbalancer.server.port=80"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
percovitrine_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<VirtualHost *:80>
|
||||||
|
DocumentRoot /var/www/html
|
||||||
|
DirectoryIndex index.php
|
||||||
|
|
||||||
|
<Directory /var/www/html>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
<Directory /var/www/html/data>
|
||||||
|
Require all denied
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
ErrorLog ${APACHE_LOG_DIR}/error.log
|
||||||
|
CustomLog ${APACHE_LOG_DIR}/access.log combined
|
||||||
|
</VirtualHost>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/db.php';
|
||||||
|
|
||||||
|
$f = basename($_GET['f'] ?? '');
|
||||||
|
if (!$f) { http_response_code(400); exit; }
|
||||||
|
|
||||||
|
$path = $UPLOAD_DIR . $f;
|
||||||
|
if (!is_file($path)) { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
$ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
|
||||||
|
$mime = match($ext) {
|
||||||
|
'jpg','jpeg' => 'image/jpeg',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'gif' => 'image/gif',
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
'avif' => 'image/avif',
|
||||||
|
'mp4' => 'video/mp4',
|
||||||
|
'webm' => 'video/webm',
|
||||||
|
'mov' => 'video/quicktime',
|
||||||
|
default => 'application/octet-stream',
|
||||||
|
};
|
||||||
|
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Cache-Control: public, max-age=86400');
|
||||||
|
header('Content-Length: ' . filesize($path));
|
||||||
|
readfile($path);
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/db.php';
|
||||||
|
require __DIR__ . '/includes/helpers.php';
|
||||||
|
require __DIR__ . '/includes/layout.php';
|
||||||
|
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$product = null;
|
||||||
|
$medias = [];
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
$product = $pdo->prepare("SELECT * FROM products WHERE id=?")->execute([$id])
|
||||||
|
? $pdo->query("SELECT * FROM products WHERE id=$id")->fetch()
|
||||||
|
: null;
|
||||||
|
if (!$product) { header('Location: /'); exit; }
|
||||||
|
$medias = $pdo->query("SELECT * FROM product_media WHERE product_id=$id ORDER BY sort_order")->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Sauvegarde ─────────────────────────────────────────── */
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$title = trim($_POST['title'] ?? '');
|
||||||
|
$desc = trim($_POST['desc'] ?? '');
|
||||||
|
$price = (float)str_replace(',','.',($_POST['price'] ?? 0));
|
||||||
|
$shipping = (float)str_replace(',','.',($_POST['shipping'] ?? 0));
|
||||||
|
$sku = trim($_POST['sku'] ?? '');
|
||||||
|
$cat_id = (int)($_POST['category_id'] ?? 0) ?: null;
|
||||||
|
$status = $_POST['status'] ?? 'brouillon';
|
||||||
|
$tags = trim($_POST['tags'] ?? '');
|
||||||
|
|
||||||
|
if (!$title) { $error = 'Le titre est obligatoire.'; goto show; }
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
$pdo->prepare("UPDATE products SET title=?,description=?,price=?,shipping=?,sku=?,category_id=?,status=?,tags=?,updated_at=CURRENT_TIMESTAMP WHERE id=?")
|
||||||
|
->execute([$title,$desc,$price,$shipping,$sku,$cat_id,$status,$tags,$id]);
|
||||||
|
} else {
|
||||||
|
$pdo->prepare("INSERT INTO products (title,description,price,shipping,sku,category_id,status,tags) VALUES(?,?,?,?,?,?,?,?)")
|
||||||
|
->execute([$title,$desc,$price,$shipping,$sku,$cat_id,$status,$tags]);
|
||||||
|
$id = (int)$pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uploads médias
|
||||||
|
if (!empty($_FILES['medias']['name'][0])) {
|
||||||
|
$count = count($_FILES['medias']['name']);
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
if ($_FILES['medias']['error'][$i] !== UPLOAD_ERR_OK) continue;
|
||||||
|
$orig = $_FILES['medias']['name'][$i];
|
||||||
|
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, array_merge(imgExts(), vidExts()))) continue;
|
||||||
|
$fname = uniqid('m', true) . '.' . $ext;
|
||||||
|
if (!move_uploaded_file($_FILES['medias']['tmp_name'][$i], $UPLOAD_DIR . $fname)) continue;
|
||||||
|
$type = isImage($fname) ? 'image' : 'video';
|
||||||
|
$order = (int)$pdo->query("SELECT COALESCE(MAX(sort_order),0)+1 FROM product_media WHERE product_id=$id")->fetchColumn();
|
||||||
|
$pdo->prepare("INSERT INTO product_media (product_id,type,filename,original_name,sort_order) VALUES(?,?,?,?,?)")
|
||||||
|
->execute([$id,$type,$fname,$orig,$order]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppression médias cochés
|
||||||
|
if (!empty($_POST['del_media'])) {
|
||||||
|
foreach ($_POST['del_media'] as $mid) {
|
||||||
|
$mid = (int)$mid;
|
||||||
|
$m = $pdo->query("SELECT filename FROM product_media WHERE id=$mid")->fetch();
|
||||||
|
if ($m && $m['filename']) @unlink($UPLOAD_DIR . $m['filename']);
|
||||||
|
$pdo->prepare("DELETE FROM product_media WHERE id=?")->execute([$mid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Location: /produit.php?id=' . $id);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
show:
|
||||||
|
$cats = $pdo->query("SELECT * FROM categories ORDER BY name")->fetchAll();
|
||||||
|
$pageTitle = $id ? 'Modifier le produit' : 'Nouveau produit';
|
||||||
|
layout_head($pageTitle);
|
||||||
|
layout_sidebar($id ? '' : 'products');
|
||||||
|
layout_main_open();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="page-title"><?= $id ? 'Modifier' : 'Nouveau produit' ?></div>
|
||||||
|
<?php if ($id): ?><div class="page-sub"><a href="/produit.php?id=<?= $id ?>" style="color:var(--muted);text-decoration:none">← Retour au produit</a></div><?php endif ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($error)): ?><div style="background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;color:var(--red)"><?= h($error) ?></div><?php endif ?>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 360px;gap:1.5rem;align-items:start">
|
||||||
|
|
||||||
|
<!-- Colonne gauche -->
|
||||||
|
<div style="display:flex;flex-direction:column;gap:1rem">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Titre *</label>
|
||||||
|
<input type="text" name="title" value="<?= h($product['title'] ?? '') ?>" placeholder="Nom du produit" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea name="desc" rows="8" placeholder="Décrivez votre produit (sera envoyé sur Etsy)…"><?= h($product['description'] ?? '') ?></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div style="font-size:.85rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem"><i class="fas fa-images" style="margin-right:.4rem"></i>Photos & vidéos</div>
|
||||||
|
|
||||||
|
<?php if ($medias): ?>
|
||||||
|
<div class="media-grid" id="mediaGrid" style="margin-bottom:1rem">
|
||||||
|
<?php foreach ($medias as $m): ?>
|
||||||
|
<div class="media-item<?= $m['type']==='video'?' video-item':'' ?>" data-id="<?= $m['id'] ?>">
|
||||||
|
<?php if ($m['filename']): ?>
|
||||||
|
<?php if ($m['type']==='image'): ?>
|
||||||
|
<img src="/file.php?f=<?= urlencode($m['filename']) ?>" alt="">
|
||||||
|
<?php else: ?>
|
||||||
|
<video src="/file.php?f=<?= urlencode($m['filename']) ?>" muted playsinline></video>
|
||||||
|
<?php endif ?>
|
||||||
|
<?php elseif ($m['video_url']): ?>
|
||||||
|
<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;padding:.5rem;font-size:.7rem;word-break:break-all;color:var(--muted)"><?= h($m['video_url']) ?></div>
|
||||||
|
<?php endif ?>
|
||||||
|
<span class="sort-handle" title="Drag pour réordonner"><i class="fas fa-grip-vertical"></i></span>
|
||||||
|
<label style="all:unset"><input type="checkbox" name="del_media[]" value="<?= $m['id'] ?>" style="position:absolute;top:4px;right:4px;cursor:pointer;width:16px;height:16px" title="Supprimer ce média"></label>
|
||||||
|
</div>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</div>
|
||||||
|
<p class="form-hint" style="margin-bottom:.75rem"><i class="fas fa-square-check" style="margin-right:.3rem"></i>Cocher un média pour le supprimer à la sauvegarde</p>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<div class="drop-zone" id="dropZone">
|
||||||
|
<input type="file" name="medias[]" multiple accept="image/*,video/mp4,video/webm,video/quicktime" id="fileInput">
|
||||||
|
<i class="fas fa-cloud-arrow-up" style="font-size:1.5rem;margin-bottom:.5rem;display:block"></i>
|
||||||
|
<strong>Cliquer ou glisser</strong> des images / vidéos<br>
|
||||||
|
<span style="font-size:.75rem">JPG, PNG, WEBP, AVIF, GIF, MP4, WEBM, MOV</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-strip" id="previewStrip"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Colonne droite -->
|
||||||
|
<div style="display:flex;flex-direction:column;gap:1rem">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div style="font-size:.85rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem"><i class="fas fa-euro-sign" style="margin-right:.4rem"></i>Prix</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label>Prix de vente</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-prefix">€</span>
|
||||||
|
<input type="number" name="price" value="<?= h((string)($product['price'] ?? '0')) ?>" step="0.01" min="0" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label>Frais de port</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-prefix">€</span>
|
||||||
|
<input type="number" name="shipping" value="<?= h((string)($product['shipping'] ?? '0')) ?>" step="0.01" min="0" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div style="font-size:.85rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem"><i class="fas fa-layer-group" style="margin-right:.4rem"></i>Organisation</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Statut</label>
|
||||||
|
<select name="status">
|
||||||
|
<?php foreach (['brouillon'=>'Brouillon','prêt'=>'Prêt à publier','publié'=>'Publié'] as $v=>$l): ?>
|
||||||
|
<option value="<?= $v ?>"<?= ($product['status']??'brouillon')===$v?' selected':'' ?>><?= $l ?></option>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Catégorie</label>
|
||||||
|
<select name="category_id">
|
||||||
|
<option value="">— Aucune —</option>
|
||||||
|
<?php foreach ($cats as $c): ?>
|
||||||
|
<option value="<?= $c['id'] ?>"<?= ($product['category_id']??'')==$c['id']?' selected':'' ?>><?= h($c['name']) ?></option>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</select>
|
||||||
|
<div class="form-hint">Gérer les catégories dans <a href="/categories.php" style="color:var(--primary)">Catégories</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Référence / SKU</label>
|
||||||
|
<input type="text" name="sku" value="<?= h($product['sku'] ?? '') ?>" placeholder="REF-001">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label>Tags <span style="font-weight:400;text-transform:none">(séparés par des virgules)</span></label>
|
||||||
|
<input type="text" name="tags" value="<?= h($product['tags'] ?? '') ?>" placeholder="artisanat, fait main, cadeau…">
|
||||||
|
<div class="form-hint">Les tags seront utilisés lors de la publication Etsy</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($id && ($product['etsy_listing_id'] ?? '')): ?>
|
||||||
|
<div class="etsy-panel">
|
||||||
|
<div class="etsy-logo"><i class="fab fa-etsy"></i> Etsy</div>
|
||||||
|
<div class="mt-2 etsy-status-ok"><i class="fas fa-check-circle"></i> Publié — ID <?= h($product['etsy_listing_id']) ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:.75rem">
|
||||||
|
<button type="submit" class="btn btn-primary" style="flex:1"><i class="fas fa-save"></i> Sauvegarder</button>
|
||||||
|
<a href="<?= $id ? '/produit.php?id='.$id : '/' ?>" class="btn btn-secondary"><i class="fas fa-xmark"></i></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div><!-- /grid -->
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Preview médias avant upload
|
||||||
|
const input = document.getElementById('fileInput');
|
||||||
|
const strip = document.getElementById('previewStrip');
|
||||||
|
const drop = document.getElementById('dropZone');
|
||||||
|
let files = [];
|
||||||
|
|
||||||
|
function addFiles(newFiles) {
|
||||||
|
[...newFiles].forEach(f => {
|
||||||
|
files.push(f);
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'preview-item';
|
||||||
|
const rm = document.createElement('button');
|
||||||
|
rm.type = 'button'; rm.className = 'rm'; rm.innerHTML = '×';
|
||||||
|
rm.onclick = () => { files = files.filter(x=>x!==f); div.remove(); syncInput(); };
|
||||||
|
const tag = f.type.startsWith('image') ? document.createElement('img') : document.createElement('video');
|
||||||
|
tag.src = URL.createObjectURL(f);
|
||||||
|
if (!f.type.startsWith('image')) { tag.muted = true; tag.loop = true; tag.autoplay = true; }
|
||||||
|
div.appendChild(tag); div.appendChild(rm); strip.appendChild(div);
|
||||||
|
});
|
||||||
|
syncInput();
|
||||||
|
}
|
||||||
|
function syncInput() {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
files.forEach(f => dt.items.add(f));
|
||||||
|
input.files = dt.files;
|
||||||
|
}
|
||||||
|
input.addEventListener('change', () => { addFiles(input.files); });
|
||||||
|
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('drag-over'); });
|
||||||
|
drop.addEventListener('dragleave', () => drop.classList.remove('drag-over'));
|
||||||
|
drop.addEventListener('drop', e => { e.preventDefault(); drop.classList.remove('drag-over'); addFiles(e.dataTransfer.files); });
|
||||||
|
</script>
|
||||||
|
<?php layout_foot(); ?>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
$DB_PATH = getenv('DB_PATH') ?: '/data/vitrine.db';
|
||||||
|
$UPLOAD_DIR = getenv('UPLOAD_DIR') ?: '/data/uploads/';
|
||||||
|
|
||||||
|
if (!is_dir($UPLOAD_DIR)) @mkdir($UPLOAD_DIR, 0755, true);
|
||||||
|
if (!is_dir(dirname($DB_PATH))) @mkdir(dirname($DB_PATH), 0755, true);
|
||||||
|
|
||||||
|
$pdo = new PDO('sqlite:' . $DB_PATH, null, null, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]);
|
||||||
|
$pdo->exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
|
||||||
|
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
price REAL DEFAULT 0,
|
||||||
|
shipping REAL DEFAULT 0,
|
||||||
|
sku TEXT DEFAULT '',
|
||||||
|
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||||
|
status TEXT DEFAULT 'brouillon' CHECK(status IN ('brouillon','prêt','publié')),
|
||||||
|
etsy_listing_id TEXT DEFAULT '',
|
||||||
|
tags TEXT DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_media (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
type TEXT DEFAULT 'image' CHECK(type IN ('image','video')),
|
||||||
|
filename TEXT,
|
||||||
|
original_name TEXT,
|
||||||
|
video_url TEXT,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT DEFAULT ''
|
||||||
|
);
|
||||||
|
");
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }
|
||||||
|
|
||||||
|
function ok(mixed $data): never {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => true, 'data' => $data], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function err(string $msg, int $code = 400): never {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => false, 'error' => $msg]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPrice(float $p): string {
|
||||||
|
return number_format($p, 2, ',', ' ') . ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
function imgExts(): array { return ['jpg','jpeg','png','gif','webp','avif']; }
|
||||||
|
function vidExts(): array { return ['mp4','webm','mov']; }
|
||||||
|
|
||||||
|
function isImage(string $f): bool {
|
||||||
|
return in_array(strtolower(pathinfo($f, PATHINFO_EXTENSION)), imgExts());
|
||||||
|
}
|
||||||
|
function isVideo(string $f): bool {
|
||||||
|
return in_array(strtolower(pathinfo($f, PATHINFO_EXTENSION)), vidExts());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpload(string $field, string $dir): ?array {
|
||||||
|
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) return null;
|
||||||
|
$orig = $_FILES[$field]['name'];
|
||||||
|
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
|
||||||
|
$ok = array_merge(imgExts(), vidExts());
|
||||||
|
if (!in_array($ext, $ok)) return null;
|
||||||
|
$fname = uniqid('m', true) . '.' . $ext;
|
||||||
|
if (!move_uploaded_file($_FILES[$field]['tmp_name'], $dir . $fname)) return null;
|
||||||
|
return ['filename' => $fname, 'original_name' => $orig, 'size' => filesize($dir . $fname)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(string $s): string {
|
||||||
|
$map = [
|
||||||
|
'brouillon' => ['label' => 'Brouillon', 'class' => 'badge-muted'],
|
||||||
|
'prêt' => ['label' => 'Prêt', 'class' => 'badge-volt'],
|
||||||
|
'publié' => ['label' => 'Publié', 'class' => 'badge-green'],
|
||||||
|
];
|
||||||
|
$b = $map[$s] ?? ['label' => $s, 'class' => 'badge-muted'];
|
||||||
|
return '<span class="badge ' . $b['class'] . '">' . h($b['label']) . '</span>';
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
function layout_head(string $title): void {
|
||||||
|
echo '<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>' . htmlspecialchars($title) . ' — PercoVitrine</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
</head><body><div class="app-wrap">';
|
||||||
|
}
|
||||||
|
|
||||||
|
function layout_sidebar(string $active = ''): void {
|
||||||
|
$links = [
|
||||||
|
['href'=>'/','icon'=>'fa-store','label'=>'Produits','key'=>'products'],
|
||||||
|
['href'=>'/categories.php','icon'=>'fa-tags','label'=>'Catégories','key'=>'categories'],
|
||||||
|
['href'=>'/settings.php','icon'=>'fa-gear','label'=>'Réglages / Etsy','key'=>'settings'],
|
||||||
|
];
|
||||||
|
echo '<aside class="sidebar"><a href="/" class="sidebar-brand"><i class="fas fa-store"></i> <span>PercoVitrine</span></a><nav class="sidebar-nav">';
|
||||||
|
echo '<div class="sidebar-section">Menu</div>';
|
||||||
|
foreach ($links as $l) {
|
||||||
|
$cls = $active === $l['key'] ? ' active' : '';
|
||||||
|
echo '<a href="' . $l['href'] . '" class="sidebar-link' . $cls . '"><i class="fas ' . $l['icon'] . '"></i><span>' . $l['label'] . '</span></a>';
|
||||||
|
}
|
||||||
|
echo '</nav></aside>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function layout_main_open(): void {
|
||||||
|
echo '<main class="main">';
|
||||||
|
}
|
||||||
|
|
||||||
|
function layout_foot(): void {
|
||||||
|
echo '</main></div><div id="toasts" class="toast-wrap"></div>
|
||||||
|
<script>
|
||||||
|
function toast(msg,type="ok"){const c=document.getElementById("toasts");const t=document.createElement("div");t.className="toast"+(type==="error"?" error":"");t.textContent=msg;c.appendChild(t);setTimeout(()=>t.remove(),3500);}
|
||||||
|
function confirm2(msg){return new Promise(r=>{if(confirm(msg))r(true);else r(false);})}
|
||||||
|
</script></body></html>';
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/db.php';
|
||||||
|
require __DIR__ . '/includes/helpers.php';
|
||||||
|
require __DIR__ . '/includes/layout.php';
|
||||||
|
|
||||||
|
$status = $_GET['status'] ?? '';
|
||||||
|
$cat = $_GET['cat'] ?? '';
|
||||||
|
$q = trim($_GET['q'] ?? '');
|
||||||
|
|
||||||
|
$where = []; $params = [];
|
||||||
|
if ($status) { $where[] = 'p.status = ?'; $params[] = $status; }
|
||||||
|
if ($cat) { $where[] = 'p.category_id = ?'; $params[] = (int)$cat; }
|
||||||
|
if ($q) { $where[] = '(p.title LIKE ? OR p.sku LIKE ? OR p.tags LIKE ?)'; $params = array_merge($params, ["%$q%","%$q%","%$q%"]); }
|
||||||
|
|
||||||
|
$sql = "SELECT p.*, c.name as cat_name,
|
||||||
|
(SELECT filename FROM product_media WHERE product_id=p.id AND type='image' ORDER BY sort_order LIMIT 1) as thumb
|
||||||
|
FROM products p LEFT JOIN categories c ON c.id=p.category_id"
|
||||||
|
. ($where ? ' WHERE '.implode(' AND ',$where) : '')
|
||||||
|
. ' ORDER BY p.updated_at DESC';
|
||||||
|
$stmt = $pdo->prepare($sql); $stmt->execute($params);
|
||||||
|
$products = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$cats = $pdo->query("SELECT * FROM categories ORDER BY name")->fetchAll();
|
||||||
|
$total = count($products);
|
||||||
|
|
||||||
|
layout_head('Produits');
|
||||||
|
layout_sidebar('products');
|
||||||
|
layout_main_open();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="page-title"><i class="fas fa-store" style="color:var(--primary);margin-right:.4rem"></i>Mes produits</div>
|
||||||
|
<div class="page-sub"><?= $total ?> produit<?= $total!==1?'s':'' ?></div>
|
||||||
|
</div>
|
||||||
|
<a href="/form.php" class="btn btn-primary"><i class="fas fa-plus"></i> Nouveau produit</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtres -->
|
||||||
|
<div class="filters">
|
||||||
|
<form method="get" style="display:contents">
|
||||||
|
<input type="text" name="q" value="<?= h($q) ?>" placeholder="Rechercher…" style="width:200px;height:34px;padding:.3rem .7rem">
|
||||||
|
<?php if ($status||$cat) echo '<input type="hidden" name="status" value="'.h($status).'"><input type="hidden" name="cat" value="'.h($cat).'">'; ?>
|
||||||
|
</form>
|
||||||
|
<a href="/" class="filter-chip<?= !$status&&!$cat&&!$q?' active':'' ?>">Tous</a>
|
||||||
|
<a href="?status=brouillon<?= $q?"&q=".urlencode($q):'' ?>" class="filter-chip<?= $status==='brouillon'?' active':'' ?>">Brouillons</a>
|
||||||
|
<a href="?status=prêt<?= $q?"&q=".urlencode($q):'' ?>" class="filter-chip<?= $status==='prêt'?' active':'' ?>">Prêts</a>
|
||||||
|
<a href="?status=publié<?= $q?"&q=".urlencode($q):'' ?>" class="filter-chip<?= $status==='publié'?' active':'' ?>">Publiés</a>
|
||||||
|
<?php foreach ($cats as $c): ?>
|
||||||
|
<a href="?cat=<?= $c['id'] ?><?= $q?"&q=".urlencode($q):'' ?>" class="filter-chip<?= $cat==(string)$c['id']?' active':'' ?>"><?= h($c['name']) ?></a>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!$products): ?>
|
||||||
|
<div class="empty-state"><i class="fas fa-store"></i><p>Aucun produit pour l'instant.</p><a href="/form.php" class="btn btn-primary mt-3"><i class="fas fa-plus"></i> Créer mon premier produit</a></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="products-grid">
|
||||||
|
<?php foreach ($products as $p): ?>
|
||||||
|
<div class="product-card" style="cursor:default">
|
||||||
|
<?php if ($p['thumb']): ?>
|
||||||
|
<img src="/file.php?f=<?= urlencode($p['thumb']) ?>" alt="" class="product-thumb">
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="product-thumb-placeholder"><i class="fas fa-image"></i></div>
|
||||||
|
<?php endif ?>
|
||||||
|
<div class="product-body">
|
||||||
|
<div class="product-title"><?= h($p['title']) ?></div>
|
||||||
|
<?php if ($p['cat_name']): ?><div class="product-cat"><i class="fas fa-tag" style="font-size:.65rem"></i> <?= h($p['cat_name']) ?></div><?php endif ?>
|
||||||
|
<?php if ($p['sku']): ?><div class="product-sku">Réf : <?= h($p['sku']) ?></div><?php endif ?>
|
||||||
|
<div class="product-meta">
|
||||||
|
<span class="product-price"><?= fmtPrice($p['price']) ?></span>
|
||||||
|
<?= statusBadge($p['status']) ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="product-actions">
|
||||||
|
<a href="/produit.php?id=<?= $p['id'] ?>" class="btn btn-secondary btn-sm" title="Voir"><i class="fas fa-eye"></i></a>
|
||||||
|
<a href="/form.php?id=<?= $p['id'] ?>" class="btn btn-secondary btn-sm" title="Modifier"><i class="fas fa-pen"></i></a>
|
||||||
|
<button onclick="deleteProduct(<?= $p['id'] ?>,this)" class="btn btn-danger btn-sm" title="Supprimer"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function deleteProduct(id, btn) {
|
||||||
|
if (!confirm('Supprimer ce produit définitivement ?')) return;
|
||||||
|
const r = await fetch('/api.php?action=product&id=' + id, {method:'DELETE'});
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) { btn.closest('.product-card').remove(); toast('Produit supprimé'); }
|
||||||
|
else toast(j.error || 'Erreur', 'error');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php layout_foot(); ?>
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/db.php';
|
||||||
|
require __DIR__ . '/includes/helpers.php';
|
||||||
|
require __DIR__ . '/includes/layout.php';
|
||||||
|
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if (!$id) { header('Location: /'); exit; }
|
||||||
|
|
||||||
|
$p = $pdo->query("SELECT p.*, c.name as cat_name FROM products p LEFT JOIN categories c ON c.id=p.category_id WHERE p.id=$id")->fetch();
|
||||||
|
if (!$p) { header('Location: /'); exit; }
|
||||||
|
|
||||||
|
$medias = $pdo->query("SELECT * FROM product_media WHERE product_id=$id ORDER BY sort_order")->fetchAll();
|
||||||
|
$images = array_filter($medias, fn($m) => $m['type']==='image' && $m['filename']);
|
||||||
|
$videos = array_filter($medias, fn($m) => $m['type']==='video');
|
||||||
|
|
||||||
|
$etsyKey = $pdo->query("SELECT value FROM settings WHERE key='etsy_api_key'")->fetchColumn();
|
||||||
|
|
||||||
|
layout_head(h($p['title']));
|
||||||
|
layout_sidebar('');
|
||||||
|
layout_main_open();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<a href="/" style="color:var(--muted);text-decoration:none;font-size:.85rem">← Tous les produits</a>
|
||||||
|
<div class="page-title" style="margin-top:.25rem"><?= h($p['title']) ?></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:.5rem">
|
||||||
|
<a href="/form.php?id=<?= $id ?>" class="btn btn-secondary"><i class="fas fa-pen"></i> Modifier</a>
|
||||||
|
<?php if ($etsyKey && $p['status']==='prêt' && !$p['etsy_listing_id']): ?>
|
||||||
|
<button onclick="publishEtsy(<?= $id ?>)" class="btn btn-success"><i class="fab fa-etsy"></i> Publier sur Etsy</button>
|
||||||
|
<?php endif ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="product-detail-grid">
|
||||||
|
|
||||||
|
<!-- Médias -->
|
||||||
|
<div>
|
||||||
|
<?php
|
||||||
|
$allMedia = array_merge(array_values($images), array_values($videos));
|
||||||
|
$first = $allMedia[0] ?? null;
|
||||||
|
?>
|
||||||
|
<div class="media-main" id="mediaMain">
|
||||||
|
<?php if ($first): ?>
|
||||||
|
<?php if ($first['type']==='image'): ?>
|
||||||
|
<img src="/file.php?f=<?= urlencode($first['filename']) ?>" alt="">
|
||||||
|
<?php else: ?>
|
||||||
|
<video src="<?= $first['filename'] ? '/file.php?f='.urlencode($first['filename']) : h($first['video_url']) ?>" controls style="width:100%;border-radius:10px;aspect-ratio:1;object-fit:cover"></video>
|
||||||
|
<?php endif ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="aspect-ratio:1;background:var(--bg-card);border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--border);font-size:4rem"><i class="fas fa-image"></i></div>
|
||||||
|
<?php endif ?>
|
||||||
|
</div>
|
||||||
|
<?php if (count($allMedia) > 1): ?>
|
||||||
|
<div class="media-thumbs">
|
||||||
|
<?php foreach ($allMedia as $i => $m): ?>
|
||||||
|
<div class="media-thumb<?= $i===0?' active':'' ?>" onclick="switchMedia(this,'<?= $m['type'] ?>','<?= $m['filename'] ? '/file.php?f='.urlencode($m['filename']) : h($m['video_url']) ?>')">
|
||||||
|
<?php if ($m['type']==='image'): ?>
|
||||||
|
<img src="/file.php?f=<?= urlencode($m['filename']) ?>" alt="">
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="width:100%;height:100%;background:var(--bg);display:flex;align-items:center;justify-content:center;color:var(--muted)"><i class="fas fa-play"></i></div>
|
||||||
|
<?php endif ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<?php if ($p['description']): ?>
|
||||||
|
<div class="card" style="margin-top:1.25rem">
|
||||||
|
<div style="font-size:.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.75rem">Description</div>
|
||||||
|
<div class="desc-block"><?= nl2br(h($p['description'])) ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Infos produit -->
|
||||||
|
<div class="product-info-card">
|
||||||
|
<div class="card">
|
||||||
|
<div class="price-block"><?= fmtPrice($p['price']) ?></div>
|
||||||
|
<?php if ($p['shipping'] > 0): ?>
|
||||||
|
<div class="shipping-block" style="margin-top:.35rem"><i class="fas fa-truck" style="margin-right:.3rem"></i>Frais de port : <?= fmtPrice($p['shipping']) ?></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="shipping-block" style="margin-top:.35rem"><i class="fas fa-truck" style="margin-right:.3rem"></i>Livraison gratuite</div>
|
||||||
|
<?php endif ?>
|
||||||
|
<div style="margin-top:.75rem"><?= statusBadge($p['status']) ?></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<?php if ($p['cat_name']): ?>
|
||||||
|
<div class="info-row"><span class="info-label">Catégorie</span><span><?= h($p['cat_name']) ?></span></div>
|
||||||
|
<?php endif ?>
|
||||||
|
<?php if ($p['sku']): ?>
|
||||||
|
<div class="info-row"><span class="info-label">Référence</span><code style="font-size:.8rem"><?= h($p['sku']) ?></code></div>
|
||||||
|
<?php endif ?>
|
||||||
|
<?php if ($p['tags']): ?>
|
||||||
|
<div class="info-row"><span class="info-label">Tags</span><span style="text-align:right;color:var(--muted);font-size:.8rem"><?= h($p['tags']) ?></span></div>
|
||||||
|
<?php endif ?>
|
||||||
|
<div class="info-row"><span class="info-label">Créé le</span><span style="font-size:.82rem;color:var(--muted)"><?= date('d/m/Y', strtotime($p['created_at'])) ?></span></div>
|
||||||
|
<div class="info-row"><span class="info-label">Modifié</span><span style="font-size:.82rem;color:var(--muted)"><?= date('d/m/Y H:i', strtotime($p['updated_at'])) ?></span></div>
|
||||||
|
<?php if ($p['etsy_listing_id']): ?>
|
||||||
|
<div class="info-row"><span class="info-label">Etsy ID</span><span style="font-size:.82rem;font-family:monospace"><?= h($p['etsy_listing_id']) ?></span></div>
|
||||||
|
<?php endif ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($etsyKey): ?>
|
||||||
|
<div class="etsy-panel">
|
||||||
|
<div class="etsy-logo"><i class="fab fa-etsy"></i> Etsy</div>
|
||||||
|
<?php if ($p['etsy_listing_id']): ?>
|
||||||
|
<div class="mt-2 etsy-status-ok"><i class="fas fa-check-circle"></i> Publié sur Etsy (ID <?= h($p['etsy_listing_id']) ?>)</div>
|
||||||
|
<?php elseif ($p['status']==='prêt'): ?>
|
||||||
|
<div class="mt-2" style="font-size:.82rem;color:var(--muted)">Prêt à publier — clique sur "Publier sur Etsy"</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="mt-2 etsy-status-ko"><i class="fas fa-clock"></i> Statut "prêt" requis pour publier</div>
|
||||||
|
<?php endif ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="etsy-panel">
|
||||||
|
<div class="etsy-logo"><i class="fab fa-etsy"></i> Etsy</div>
|
||||||
|
<div class="mt-2 etsy-status-ko"><i class="fas fa-key"></i> Clé API non configurée — <a href="/settings.php" style="color:var(--primary)">Réglages</a></div>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:.5rem">
|
||||||
|
<a href="/form.php?id=<?= $id ?>" class="btn btn-secondary" style="flex:1"><i class="fas fa-pen"></i> Modifier</a>
|
||||||
|
<button onclick="if(confirm('Supprimer ce produit ?'))deleteProduct(<?= $id ?>)" class="btn btn-danger"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function switchMedia(thumb, type, src) {
|
||||||
|
document.querySelectorAll('.media-thumb').forEach(t=>t.classList.remove('active'));
|
||||||
|
thumb.classList.add('active');
|
||||||
|
const main = document.getElementById('mediaMain');
|
||||||
|
if (type === 'image') {
|
||||||
|
main.innerHTML = `<img src="${src}" alt="" style="width:100%;border-radius:10px;aspect-ratio:1;object-fit:cover">`;
|
||||||
|
} else {
|
||||||
|
main.innerHTML = `<video src="${src}" controls style="width:100%;border-radius:10px;aspect-ratio:1;object-fit:cover"></video>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function deleteProduct(id) {
|
||||||
|
const r = await fetch('/api.php?action=product&id='+id, {method:'DELETE'});
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) window.location='/';
|
||||||
|
else toast(j.error||'Erreur','error');
|
||||||
|
}
|
||||||
|
async function publishEtsy(id) {
|
||||||
|
toast('Publication Etsy pas encore disponible (API en cours d\'intégration)');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<?php layout_foot(); ?>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/db.php';
|
||||||
|
require __DIR__ . '/includes/helpers.php';
|
||||||
|
require __DIR__ . '/includes/layout.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$keys = ['etsy_api_key','etsy_api_secret','etsy_shop_id','currency'];
|
||||||
|
$stmt = $pdo->prepare("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)");
|
||||||
|
foreach ($keys as $k) {
|
||||||
|
$stmt->execute([$k, trim($_POST[$k] ?? '')]);
|
||||||
|
}
|
||||||
|
header('Location: /settings.php?saved=1');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cfg(PDO $pdo, string $key): string {
|
||||||
|
$v = $pdo->prepare("SELECT value FROM settings WHERE key=?");
|
||||||
|
$v->execute([$key]);
|
||||||
|
return $v->fetchColumn() ?: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
layout_head('Réglages');
|
||||||
|
layout_sidebar('settings');
|
||||||
|
layout_main_open();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title"><i class="fas fa-gear" style="color:var(--primary);margin-right:.4rem"></i>Réglages</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($_GET['saved'] ?? false): ?>
|
||||||
|
<div style="background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.3);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;color:var(--green)"><i class="fas fa-check-circle"></i> Réglages enregistrés.</div>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start">
|
||||||
|
|
||||||
|
<div style="display:flex;flex-direction:column;gap:1rem">
|
||||||
|
<!-- Etsy -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="etsy-logo mb-3"><i class="fab fa-etsy"></i> Intégration Etsy</div>
|
||||||
|
<p style="font-size:.83rem;color:var(--muted);margin-bottom:1rem;line-height:1.6">
|
||||||
|
Pour obtenir une clé API Etsy, va sur
|
||||||
|
<strong>etsy.com/developers</strong>, crée une application, et
|
||||||
|
copie la clé et le secret ci-dessous.<br>
|
||||||
|
L'intégration permettra de publier tes fiches directement depuis PercoVitrine.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API Key (Keystring)</label>
|
||||||
|
<input type="text" name="etsy_api_key" value="<?= h(cfg($pdo,'etsy_api_key')) ?>" placeholder="Coller la clé API…" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API Secret</label>
|
||||||
|
<input type="password" name="etsy_api_secret" value="<?= h(cfg($pdo,'etsy_api_secret')) ?>" placeholder="Coller le secret…" autocomplete="off">
|
||||||
|
<div class="form-hint">Stocké localement, jamais transmis sauf à Etsy</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label>Shop ID</label>
|
||||||
|
<input type="text" name="etsy_shop_id" value="<?= h(cfg($pdo,'etsy_shop_id')) ?>" placeholder="Nom ou ID de ta boutique Etsy">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;flex-direction:column;gap:1rem">
|
||||||
|
<!-- Général -->
|
||||||
|
<div class="card">
|
||||||
|
<div style="font-size:.85rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem"><i class="fas fa-sliders" style="margin-right:.4rem"></i>Général</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label>Devise</label>
|
||||||
|
<select name="currency">
|
||||||
|
<?php foreach (['EUR'=>'Euro (€)','USD'=>'Dollar ($)','GBP'=>'Livre sterling (£)','CHF'=>'Franc suisse (CHF)'] as $v=>$l): ?>
|
||||||
|
<option value="<?= $v ?>"<?= cfg($pdo,'currency')===$v?' selected':'' ?>><?= $l ?></option>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="background:rgba(45,163,242,.04);border-color:rgba(45,163,242,.15)">
|
||||||
|
<div style="font-size:.85rem;font-weight:700;margin-bottom:.5rem"><i class="fas fa-circle-info" style="color:var(--primary);margin-right:.4rem"></i>Roadmap intégrations</div>
|
||||||
|
<ul style="font-size:.82rem;color:var(--muted);padding-left:1.2rem;line-height:2">
|
||||||
|
<li><span style="color:var(--volt)">●</span> Etsy — en cours d'intégration</li>
|
||||||
|
<li><span style="color:var(--border)">●</span> Vinted — prévu</li>
|
||||||
|
<li><span style="color:var(--border)">●</span> Leboncoin — prévu</li>
|
||||||
|
<li><span style="color:var(--border)">●</span> WooCommerce — prévu</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<?php layout_foot(); ?>
|
||||||
Reference in New Issue
Block a user