feat: auto-sync product prices to Stripe from admin + UI fixes
- Admin edit hook syncs prices to Stripe (create/archive) - "Prochainement disponible" disabled button for products without price - Seed no longer hardcodes stripePriceId - Fix nginx port mismatch (3001 → 3000) - Coord tag background color + sound auto-start fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d0b9242b89
commit
4bb9ed86d4
1
.astro/types.d.ts
vendored
1
.astro/types.d.ts
vendored
@ -1,2 +1 @@
|
|||||||
/// <reference types="astro/client" />
|
/// <reference types="astro/client" />
|
||||||
/// <reference path="content.d.ts" />
|
|
||||||
95
admin.mjs
95
admin.mjs
@ -2,11 +2,16 @@ import AdminJS from 'adminjs'
|
|||||||
import AdminJSFastify from '@adminjs/fastify'
|
import AdminJSFastify from '@adminjs/fastify'
|
||||||
import { Database, Resource, getModelByName } from '@adminjs/prisma'
|
import { Database, Resource, getModelByName } from '@adminjs/prisma'
|
||||||
import bcrypt from 'bcrypt'
|
import bcrypt from 'bcrypt'
|
||||||
|
import Stripe from 'stripe'
|
||||||
import { exec } from 'child_process'
|
import { exec } from 'child_process'
|
||||||
import { prisma } from './src/lib/db.mjs'
|
import { prisma } from './src/lib/db.mjs'
|
||||||
|
|
||||||
AdminJS.registerAdapter({ Database, Resource })
|
AdminJS.registerAdapter({ Database, Resource })
|
||||||
|
|
||||||
|
const stripe = process.env.STRIPE_SECRET_KEY
|
||||||
|
? new Stripe(process.env.STRIPE_SECRET_KEY)
|
||||||
|
: null
|
||||||
|
|
||||||
// ── Auto-build (prod only) ──────────────────────────────────────────────────
|
// ── Auto-build (prod only) ──────────────────────────────────────────────────
|
||||||
let buildTimeout = null
|
let buildTimeout = null
|
||||||
let buildInProgress = false
|
let buildInProgress = false
|
||||||
@ -31,6 +36,92 @@ function triggerBuild() {
|
|||||||
|
|
||||||
const afterBuildHook = async (response) => { triggerBuild(); return response }
|
const afterBuildHook = async (response) => { triggerBuild(); return response }
|
||||||
|
|
||||||
|
// ── Stripe price sync after product edit ─────────────────────────────────────
|
||||||
|
async function syncPriceToStripe(response) {
|
||||||
|
if (!stripe) return response
|
||||||
|
|
||||||
|
const product = await prisma.product.findUnique({
|
||||||
|
where: { id: response.record.params.id },
|
||||||
|
})
|
||||||
|
if (!product) return response
|
||||||
|
|
||||||
|
const priceCents = product.price
|
||||||
|
const currency = (product.currency || 'EUR').toLowerCase()
|
||||||
|
const slug = product.slug
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Find or create Stripe product by metadata.slug
|
||||||
|
let stripeProduct = null
|
||||||
|
const existing = await stripe.products.search({ query: `metadata["slug"]:"${slug}"` })
|
||||||
|
if (existing.data.length > 0) {
|
||||||
|
stripeProduct = existing.data[0]
|
||||||
|
// Update product info
|
||||||
|
await stripe.products.update(stripeProduct.id, {
|
||||||
|
name: product.productDisplayName,
|
||||||
|
description: product.description,
|
||||||
|
images: product.ogImage ? [product.ogImage] : undefined,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
} else if (priceCents) {
|
||||||
|
stripeProduct = await stripe.products.create({
|
||||||
|
name: product.productDisplayName,
|
||||||
|
description: product.description,
|
||||||
|
images: product.ogImage ? [product.ogImage] : [],
|
||||||
|
metadata: { slug, stripeKey: product.stripeKey || slug, dbId: product.id },
|
||||||
|
})
|
||||||
|
console.log(`[stripe] Product created: ${stripeProduct.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stripeProduct) return response
|
||||||
|
|
||||||
|
// 2. If price is null, archive old price and clear DB
|
||||||
|
if (!priceCents) {
|
||||||
|
if (product.stripePriceId) {
|
||||||
|
await stripe.prices.update(product.stripePriceId, { active: false })
|
||||||
|
await prisma.product.update({ where: { id: product.id }, data: { stripePriceId: null } })
|
||||||
|
console.log(`[stripe] Price archived (product has no price now)`)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check if current Stripe price matches
|
||||||
|
if (product.stripePriceId) {
|
||||||
|
try {
|
||||||
|
const currentPrice = await stripe.prices.retrieve(product.stripePriceId)
|
||||||
|
if (currentPrice.unit_amount === priceCents && currentPrice.currency === currency && currentPrice.active) {
|
||||||
|
// Price unchanged
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
// Archive old price
|
||||||
|
await stripe.prices.update(product.stripePriceId, { active: false })
|
||||||
|
console.log(`[stripe] Old price archived: ${product.stripePriceId}`)
|
||||||
|
} catch {
|
||||||
|
// Old price doesn't exist, continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create new price
|
||||||
|
const newPrice = await stripe.prices.create({
|
||||||
|
product: stripeProduct.id,
|
||||||
|
unit_amount: priceCents,
|
||||||
|
currency,
|
||||||
|
metadata: { slug, dbId: product.id },
|
||||||
|
})
|
||||||
|
console.log(`[stripe] New price created: ${newPrice.id} (${priceCents / 100} ${currency.toUpperCase()})`)
|
||||||
|
|
||||||
|
// 5. Update DB
|
||||||
|
await prisma.product.update({
|
||||||
|
where: { id: product.id },
|
||||||
|
data: { stripePriceId: newPrice.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[stripe] Sync error for ${slug}:`, err.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
// ── AdminJS setup ───────────────────────────────────────────────────────────
|
// ── AdminJS setup ───────────────────────────────────────────────────────────
|
||||||
export async function setupAdmin(app) {
|
export async function setupAdmin(app) {
|
||||||
const admin = new AdminJS({
|
const admin = new AdminJS({
|
||||||
@ -56,8 +147,8 @@ export async function setupAdmin(app) {
|
|||||||
seoDescription: { type: 'textarea' },
|
seoDescription: { type: 'textarea' },
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
new: { after: [afterBuildHook] },
|
new: { after: [syncPriceToStripe, afterBuildHook] },
|
||||||
edit: { after: [afterBuildHook] },
|
edit: { after: [syncPriceToStripe, afterBuildHook] },
|
||||||
delete: { after: [afterBuildHook] },
|
delete: { after: [afterBuildHook] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,7 +7,7 @@ server {
|
|||||||
|
|
||||||
# ── API proxy → Fastify ──────────────────────────────────────────────────
|
# ── API proxy → Fastify ──────────────────────────────────────────────────
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:3001;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@ -16,20 +16,20 @@ server {
|
|||||||
|
|
||||||
# ── SEO (dynamique depuis DB) ────────────────────────────────────────────
|
# ── SEO (dynamique depuis DB) ────────────────────────────────────────────
|
||||||
location = /robots.txt {
|
location = /robots.txt {
|
||||||
proxy_pass http://127.0.0.1:3001;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /sitemap.xml {
|
location = /sitemap.xml {
|
||||||
proxy_pass http://127.0.0.1:3001;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Admin proxy → Fastify (AdminJS) ──────────────────────────────────────
|
# ── Admin proxy → Fastify (AdminJS) ──────────────────────────────────────
|
||||||
location ^~ /admin {
|
location ^~ /admin {
|
||||||
proxy_pass http://127.0.0.1:3001;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@ -25,7 +25,7 @@ const PRODUCTS = [
|
|||||||
price: 180000, // 1800 EUR in cents
|
price: 180000, // 1800 EUR in cents
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
availability: 'https://schema.org/LimitedAvailability',
|
availability: 'https://schema.org/LimitedAvailability',
|
||||||
stripePriceId: 'price_1T5SBlE5wMMoCUP5ZcjEStwe',
|
stripePriceId: null,
|
||||||
stripeKey: 'lumiere_orbitale',
|
stripeKey: 'lumiere_orbitale',
|
||||||
isPublished: true,
|
isPublished: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -470,10 +470,17 @@ hr { border: none; border-top: var(--border); margin: 0; }
|
|||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.checkout-btn:hover {
|
.checkout-btn:hover:not(:disabled) {
|
||||||
background: var(--clr-black);
|
background: var(--clr-black);
|
||||||
color: #e8a800;
|
color: #e8a800;
|
||||||
}
|
}
|
||||||
|
.checkout-btn--disabled {
|
||||||
|
background: #999;
|
||||||
|
border-color: #999;
|
||||||
|
color: #ddd;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Form qui se déploie */
|
/* Form qui se déploie */
|
||||||
.checkout-form-wrap {
|
.checkout-form-wrap {
|
||||||
|
|||||||
@ -526,17 +526,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const stripeKey = card.dataset.stripeKey;
|
const stripeKey = card.dataset.stripeKey;
|
||||||
const isOrderable = price && stripeKey;
|
const isOrderable = price && stripeKey;
|
||||||
|
|
||||||
|
checkoutSection.style.display = 'block';
|
||||||
|
|
||||||
if (isOrderable) {
|
if (isOrderable) {
|
||||||
currentStripeKey = stripeKey;
|
currentStripeKey = stripeKey;
|
||||||
checkoutPriceEl.textContent = formatPrice(parseInt(price, 10));
|
checkoutPriceEl.textContent = formatPrice(parseInt(price, 10));
|
||||||
checkoutSection.style.display = 'block';
|
checkoutToggleBtn.textContent = '[ COMMANDER CETTE PIÈCE ]';
|
||||||
|
checkoutToggleBtn.disabled = false;
|
||||||
|
checkoutToggleBtn.classList.remove('checkout-btn--disabled');
|
||||||
} else {
|
} else {
|
||||||
currentStripeKey = null;
|
currentStripeKey = null;
|
||||||
checkoutSection.style.display = 'none';
|
checkoutPriceEl.textContent = '';
|
||||||
|
checkoutToggleBtn.textContent = 'PROCHAINEMENT DISPONIBLE';
|
||||||
|
checkoutToggleBtn.disabled = true;
|
||||||
|
checkoutToggleBtn.classList.add('checkout-btn--disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
checkoutFormWrap.style.display = 'none';
|
checkoutFormWrap.style.display = 'none';
|
||||||
checkoutToggleBtn.textContent = '[ COMMANDER CETTE PIÈCE ]';
|
|
||||||
checkoutSubmitBtn.disabled = false;
|
checkoutSubmitBtn.disabled = false;
|
||||||
checkoutSubmitBtn.textContent = 'PROCÉDER AU PAIEMENT →';
|
checkoutSubmitBtn.textContent = 'PROCÉDER AU PAIEMENT →';
|
||||||
checkoutForm.reset();
|
checkoutForm.reset();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user