Initial commit build
Some checks failed
DitesLeEnChanson/site/pipeline/head There was a failure building this commit

This commit is contained in:
balvarez
2025-12-31 16:12:48 +01:00
parent 75a7b2fbd9
commit 95d29c7faa
69 changed files with 1435 additions and 12482 deletions

4
src/.env Normal file
View File

@@ -0,0 +1,4 @@
VITE_SUPABASE_URL=https://supabase.abpcode.fr
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzY2Nzc3OTU5LCJleHAiOjE5MjQ0NTc5NTl9.I-qytVb1ef6QMR8IUDePJzESO3bJAnsGE075XQ2xiaI
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51RPSGmEPL3QASpovp8Q6p8ehNMW7TzSrOaV6zvPE1OtflMFN5jChQBEj5kr84wontlLOe8uiHyJBiCduzxIZwj5A00DIEVs31n
VITE_STRIPE_PUBLISHABLE_KEY_DEV=pk_test_51RPSH1ERAUBjYKpgbz4GjZjDtI24rqfBky5SO6AwdBfZaqNmFN0zQSxx0Z1wfFKtKXIZXfx5IOQSt2ularULIsto00frDMNi03

1
src/.nvmrc Normal file
View File

@@ -0,0 +1 @@
20.19.1

1
src/.version Normal file
View File

@@ -0,0 +1 @@
11

View File

@@ -1,17 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
const CallToAction = () => {
return (
<motion.h1
className='text-xl font-bold text-white leading-8 w-full'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.5 }}
>
Let's turn your ideas into reality
</motion.h1>
);
};
export default CallToAction;

View File

@@ -1,31 +0,0 @@
import React from 'react';
const HeroImage = () => {
return (
<div className="relative w-8 h-8 shrink-0" data-name="ic-sparkles">
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-full h-full"
>
<path
d="M11.787 9.5356C11.5053 8.82147 10.4947 8.82147 10.213 9.5356L8.742 13.2654C8.65601 13.4834 8.48343 13.656 8.2654 13.742L4.5356 15.213C3.82147 15.4947 3.82147 16.5053 4.5356 16.787L8.2654 18.258C8.48343 18.344 8.65601 18.5166 8.742 18.7346L10.213 22.4644C10.4947 23.1785 11.5053 23.1785 11.787 22.4644L13.258 18.7346C13.344 18.5166 13.5166 18.344 13.7346 18.258L17.4644 16.787C18.1785 16.5053 18.1785 15.4947 17.4644 15.213L13.7346 13.742C13.5166 13.656 13.344 13.4834 13.258 13.2654L11.787 9.5356Z"
fill="white"
/>
<path
d="M23.5621 2.38257C23.361 1.87248 22.639 1.87248 22.4379 2.38257L21.3871 5.04671C21.3257 5.20245 21.2024 5.32572 21.0467 5.38714L18.3826 6.43787C17.8725 6.63904 17.8725 7.36096 18.3826 7.56214L21.0467 8.61286C21.2024 8.67428 21.3257 8.79755 21.3871 8.95329L22.4379 11.6174C22.639 12.1275 23.361 12.1275 23.5621 11.6174L24.6129 8.95329C24.6743 8.79755 24.7976 8.67428 24.9533 8.61286L27.6174 7.56214C28.1275 7.36096 28.1275 6.63904 27.6174 6.43787L24.9533 5.38714C24.7976 5.32572 24.6743 5.20245 24.6129 5.04671L23.5621 2.38257Z"
fill="white"
/>
<path
d="M23.3373 22.2295C23.2166 21.9235 22.7834 21.9235 22.6627 22.2295L22.0323 23.828C21.9954 23.9215 21.9215 23.9954 21.828 24.0323L20.2295 24.6627C19.9235 24.7834 19.9235 25.2166 20.2295 25.3373L21.828 25.9677C21.9215 26.0046 21.9954 26.0785 22.0323 26.172L22.6627 27.7705C22.7834 28.0765 23.2166 28.0765 23.3373 27.7705L23.9677 26.172C24.0046 26.0785 24.0785 26.0046 24.172 25.9677L25.7705 25.3373C26.0765 25.2166 26.0765 24.7834 25.7705 24.6627L24.172 24.0323C24.0785 23.9954 24.0046 23.9215 23.9677 23.828L23.3373 22.2295Z"
fill="white"
/>
</svg>
</div>
);
};
export default HeroImage;

14
src/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="https://storage.googleapis.com/hostinger-horizons-assets-prod/72f15596-7338-40f3-8565-8548388d2677/4ac040560780878558644b6783d4f976.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dites le en chanson</title>
<meta name="description" content="Créez des chansons personnalisées uniques pour toutes vos occasions spéciales. Immortalisez vos histoires en mélodies inoubliables avec Dites le en chanson." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

9
src/jsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,13 +0,0 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://zneurmyahctnnzglnptl.supabase.co';
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InpuZXVybXlhaGN0bm56Z2xucHRsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDg4NTM5MTksImV4cCI6MjA2NDQyOTkxOX0.wK5iWQt3pXNNpi9xquCaowvQrz8k8qjlcwFMsehqnWI';
const customSupabaseClient = createClient(supabaseUrl, supabaseAnonKey);
export default customSupabaseClient;
export {
customSupabaseClient,
customSupabaseClient as supabase,
};

View File

@@ -1,6 +0,0 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://zneurmyahctnnzglnptl.supabase.co';
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InpuZXVybXlhaGN0bm56Z2xucHRsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDg4NTM5MTksImV4cCI6MjA2NDQyOTkxOX0.wK5iWQt3pXNNpi9xquCaowvQrz8k8qjlcwFMsehqnWI';
export const supabase = createClient(supabaseUrl, supabaseAnonKey);

View File

@@ -1,228 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useToast } from '@/components/ui/use-toast';
import { ChevronLeft, ChevronRight, ShoppingCart } from 'lucide-react';
import Step0ProductSelection from '@/components/order/Step0ProductSelection';
import Step1Content from '@/components/order/Step1Content';
import Step2MusicalChoices from '@/components/order/Step2MusicalChoices';
import Step3Review from '@/components/order/Step3Review';
import { initialFormData, stepsConfig, formValidations, products, STRIPE_PUBLISHABLE_KEY } from '@/config/orderFormConfig';
import { loadStripe } from '@stripe/stripe-js';
const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY);
const OrderPage = () => {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState(() => {
const savedData = localStorage.getItem('songOrderForm');
if (savedData) {
const parsedData = JSON.parse(savedData);
const selectedProductDetails = products.find(p => p.id === parsedData.selectedProduct);
if (!selectedProductDetails) {
return { ...initialFormData, selectedProduct: initialFormData.selectedProduct, price: 0, stripePriceId: null };
}
return {
...parsedData,
price: selectedProductDetails.promotionPrice || selectedProductDetails.price,
stripePriceId: selectedProductDetails.stripePriceId
};
}
return initialFormData;
});
const [errors, setErrors] = useState({});
const { toast } = useToast();
const navigate = useNavigate();
const [isProcessingPayment, setIsProcessingPayment] = useState(false);
useEffect(() => {
localStorage.setItem('songOrderForm', JSON.stringify(formData));
}, [formData]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: null }));
}
};
const handleSelectChange = (name, value) => {
setFormData(prev => ({ ...prev, [name]: value }));
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: null }));
}
};
const handleRadioGroupChange = (name, value) => {
setFormData(prev => ({ ...prev, [name]: value }));
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: null }));
}
};
const handleProductSelect = (productId) => {
const selectedProductDetails = products.find(p => p.id === productId);
setFormData(prev => ({
...prev,
selectedProduct: productId,
price: selectedProductDetails?.promotionPrice || selectedProductDetails?.price || 0,
stripePriceId: selectedProductDetails?.stripePriceId
}));
if (errors.selectedProduct) {
setErrors(prev => ({ ...prev, selectedProduct: null }));
}
};
const validateStep = () => {
const currentStepId = stepsConfig[currentStep].id;
const stepFields = formValidations[currentStepId];
let newErrors = {};
let isValid = true;
if (stepFields) {
for (const field of stepFields) {
const value = formData[field.name];
if (!value || (typeof value === 'string' && value.trim() === '')) {
newErrors[field.name] = field.message;
isValid = false;
}
}
}
setErrors(newErrors);
if (!isValid && Object.keys(newErrors).length > 0) {
const firstErrorField = Object.keys(newErrors)[0];
toast({ title: "Champ requis", description: newErrors[firstErrorField], variant: "destructive" });
}
return isValid;
};
const nextStep = () => {
if (validateStep()) {
setCurrentStep(prev => Math.min(prev + 1, stepsConfig.length - 1));
}
};
const prevStep = () => {
setErrors({});
setCurrentStep(prev => Math.max(prev - 1, 0));
}
const handleSubmit = async () => {
if (!validateStep() || !formData.selectedProduct || !formData.stripePriceId) {
toast({
title: "Erreur de commande",
description: "Veuillez sélectionner un produit et remplir tous les champs requis avant de continuer.",
variant: "destructive",
});
return;
}
setIsProcessingPayment(true);
toast({
title: "Préparation du paiement...",
description: "Vous allez être redirigé vers Stripe.",
});
const stripe = await stripePromise;
const selectedProductDetails = products.find(p => p.id === formData.selectedProduct);
if (!selectedProductDetails || !selectedProductDetails.stripePriceId || selectedProductDetails.stripePriceId.includes('YOUR_')) {
toast({
title: "Erreur de configuration produit",
description: "L'ID de prix Stripe pour ce produit n'est pas correctement configuré. Veuillez me fournir les bons Price IDs.",
variant: "destructive",
});
setIsProcessingPayment(false);
return;
}
const finalPrice = selectedProductDetails.promotionPrice || selectedProductDetails.price;
const finalFormDataForConfirmation = { ...formData, productName: selectedProductDetails?.name, price: finalPrice };
localStorage.setItem('confirmedOrderDataForConfirmationPage', JSON.stringify(finalFormDataForConfirmation));
const { error } = await stripe.redirectToCheckout({
lineItems: [{ price: selectedProductDetails.stripePriceId, quantity: 1 }],
mode: 'payment',
successUrl: `${window.location.origin}/confirmation?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${window.location.origin}/commander`,
customerEmail: formData.email,
});
if (error) {
console.error("Erreur Stripe:", error);
toast({
title: "Erreur de paiement",
description: error.message || "Une erreur est survenue lors de la redirection vers Stripe.",
variant: "destructive",
});
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
setIsProcessingPayment(false);
}
};
const progressPercentage = ((currentStep + 1) / stepsConfig.length) * 100;
const currentSelectedProduct = formData.selectedProduct ? products.find(p => p.id === formData.selectedProduct) : null;
const currentPriceToPay = currentSelectedProduct ? (currentSelectedProduct.promotionPrice || currentSelectedProduct.price) : 0;
const renderStepContent = () => {
const stepId = stepsConfig[currentStep].id;
switch (stepId) {
case 'productSelection':
return <Step0ProductSelection selectedProduct={formData.selectedProduct} onProductSelect={handleProductSelect} error={errors.selectedProduct} />;
case 'songInfo':
return <Step1Content formData={formData} handleChange={handleChange} handleSelectChange={handleSelectChange} errors={errors} />;
case 'musicalChoices':
return <Step2MusicalChoices formData={formData} handleSelectChange={handleSelectChange} handleRadioGroupChange={handleRadioGroupChange} errors={errors} />;
case 'review':
return <Step3Review formData={formData} handleChange={handleChange} errors={errors} />;
default:
return <div>Étape inconnue</div>;
}
};
return (
<div className="max-w-3xl mx-auto py-8">
<Card className="shadow-2xl bg-card/80 backdrop-blur-md">
<CardHeader className="text-center border-b pb-4">
<div className="flex items-center justify-center text-3xl font-bold mb-2">
{stepsConfig[currentStep].icon}
<h1 className="gradient-text">{stepsConfig[currentStep].title}</h1>
</div>
<CardDescription>Étape {currentStep + 1} sur {stepsConfig.length}</CardDescription>
<Progress value={progressPercentage} className="mt-4 h-3" />
</CardHeader>
<CardContent className="p-6 md:p-10 min-h-[300px]">
<AnimatePresence mode="wait">
{renderStepContent()}
</AnimatePresence>
</CardContent>
<CardFooter className="flex justify-between p-6 border-t">
<Button variant="outline" onClick={prevStep} disabled={currentStep === 0 || isProcessingPayment} className="text-base px-6 py-3">
<ChevronLeft className="h-5 w-5 mr-2" />
Précédent
</Button>
{currentStep < stepsConfig.length - 1 ? (
<Button onClick={nextStep} disabled={isProcessingPayment} className="text-base px-6 py-3 bg-gradient-to-r from-primary to-accent hover:opacity-90 transition-opacity">
Suivant
<ChevronRight className="h-5 w-5 ml-2" />
</Button>
) : (
<Button onClick={handleSubmit} disabled={isProcessingPayment} className="text-base px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-600 hover:opacity-90 transition-opacity">
{isProcessingPayment ? "Traitement..." : `Payer (${currentPriceToPay ? currentPriceToPay.toFixed(2) : '0.00'} €)`}
{!isProcessingPayment && <ShoppingCart className="h-5 w-5 ml-2" />}
</Button>
)}
</CardFooter>
</Card>
</div>
);
};
export default OrderPage;

View File

@@ -0,0 +1,315 @@
import { POPUP_STYLES } from './plugins/visual-editor/visual-editor-config.js';
const PLUGIN_APPLY_EDIT_API_URL = '/api/apply-edit';
const ALLOWED_PARENT_ORIGINS = [
'https://horizons.hostinger.com',
'https://horizons.hostinger.dev',
'https://horizons-frontend-local.hostinger.dev',
'http://localhost:4000',
];
let disabledTooltipElement = null;
let currentDisabledHoverElement = null;
let translations = {
disabledTooltipText: "This text can be changed only through chat.",
disabledTooltipTextImage: "This image can only be changed through chat."
};
let areStylesInjected = false;
let globalEventHandlers = null;
let currentEditingInfo = null;
function injectPopupStyles() {
if (areStylesInjected) return;
const styleElement = document.createElement('style');
styleElement.id = 'inline-editor-styles';
styleElement.textContent = POPUP_STYLES;
document.head.appendChild(styleElement);
areStylesInjected = true;
}
function findEditableElementAtPoint(event) {
let editableElement = event.target.closest('[data-edit-id]');
if (editableElement) {
return editableElement;
}
const elementsAtPoint = document.elementsFromPoint(event.clientX, event.clientY);
const found = elementsAtPoint.find(el => el !== event.target && el.hasAttribute('data-edit-id'));
if (found) return found;
return null;
}
function findDisabledElementAtPoint(event) {
const direct = event.target.closest('[data-edit-disabled]');
if (direct) return direct;
const elementsAtPoint = document.elementsFromPoint(event.clientX, event.clientY);
const found = elementsAtPoint.find(el => el !== event.target && el.hasAttribute('data-edit-disabled'));
if (found) return found;
return null;
}
function showPopup(targetElement, editId, currentContent, isImage = false) {
currentEditingInfo = { editId, targetElement };
const parentOrigin = getParentOrigin();
if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
const eventType = isImage ? 'imageEditEnter' : 'editEnter';
window.parent.postMessage({
type: eventType,
payload: { currentText: currentContent }
}, parentOrigin);
}
}
function handleGlobalEvent(event) {
if (!document.getElementById('root')?.getAttribute('data-edit-mode-enabled')) {
return;
}
if (event.target.closest('#inline-editor-popup')) {
return;
}
const editableElement = findEditableElementAtPoint(event);
if (editableElement) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (event.type === 'click') {
const editId = editableElement.getAttribute('data-edit-id');
if (!editId) {
console.warn('[INLINE EDITOR] Clicked element missing data-edit-id');
return;
}
const isImage = editableElement.tagName.toLowerCase() === 'img';
let currentContent = '';
if (isImage) {
currentContent = editableElement.getAttribute('src') || '';
} else {
currentContent = editableElement.textContent || '';
}
showPopup(editableElement, editId, currentContent, isImage);
}
} else {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
}
}
function getParentOrigin() {
if (window.location.ancestorOrigins && window.location.ancestorOrigins.length > 0) {
return window.location.ancestorOrigins[0];
}
if (document.referrer) {
try {
return new URL(document.referrer).origin;
} catch (e) {
console.warn('Invalid referrer URL:', document.referrer);
}
}
return null;
}
async function handleEditSave(updatedText) {
const newText = updatedText
// Replacing characters that cause Babel parser to crash
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/{/g, '&#123;')
.replace(/}/g, '&#125;')
const { editId } = currentEditingInfo;
try {
const response = await fetch(PLUGIN_APPLY_EDIT_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
editId: editId,
newFullText: newText
}),
});
const result = await response.json();
if (result.success) {
const parentOrigin = getParentOrigin();
if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
window.parent.postMessage({
type: 'editApplied',
payload: {
editId: editId,
fileContent: result.newFileContent,
beforeCode: result.beforeCode,
afterCode: result.afterCode,
}
}, parentOrigin);
} else {
console.error('Unauthorized parent origin:', parentOrigin);
}
} else {
console.error(`[vite][visual-editor] Error saving changes: ${result.error}`);
}
} catch (error) {
console.error(`[vite][visual-editor] Error during fetch for ${editId}:`, error);
}
}
function createDisabledTooltip() {
if (disabledTooltipElement) return;
disabledTooltipElement = document.createElement('div');
disabledTooltipElement.id = 'inline-editor-disabled-tooltip';
document.body.appendChild(disabledTooltipElement);
}
function showDisabledTooltip(targetElement, isImage = false) {
if (!disabledTooltipElement) createDisabledTooltip();
disabledTooltipElement.textContent = isImage ? translations.disabledTooltipTextImage : translations.disabledTooltipText;
if (!disabledTooltipElement.isConnected) {
document.body.appendChild(disabledTooltipElement);
}
disabledTooltipElement.classList.add('tooltip-active');
const tooltipWidth = disabledTooltipElement.offsetWidth;
const tooltipHeight = disabledTooltipElement.offsetHeight;
const rect = targetElement.getBoundingClientRect();
// Ensures that tooltip is not off the screen with 5px margin
let newLeft = rect.left + window.scrollX + (rect.width / 2) - (tooltipWidth / 2);
let newTop = rect.bottom + window.scrollY + 5;
if (newLeft < window.scrollX) {
newLeft = window.scrollX + 5;
}
if (newLeft + tooltipWidth > window.innerWidth + window.scrollX) {
newLeft = window.innerWidth + window.scrollX - tooltipWidth - 5;
}
if (newTop + tooltipHeight > window.innerHeight + window.scrollY) {
newTop = rect.top + window.scrollY - tooltipHeight - 5;
}
if (newTop < window.scrollY) {
newTop = window.scrollY + 5;
}
disabledTooltipElement.style.left = `${newLeft}px`;
disabledTooltipElement.style.top = `${newTop}px`;
}
function hideDisabledTooltip() {
if (disabledTooltipElement) {
disabledTooltipElement.classList.remove('tooltip-active');
}
}
function handleDisabledElementHover(event) {
const isImage = event.currentTarget.tagName.toLowerCase() === 'img';
showDisabledTooltip(event.currentTarget, isImage);
}
function handleDisabledElementLeave() {
hideDisabledTooltip();
}
function handleDisabledGlobalHover(event) {
const disabledElement = findDisabledElementAtPoint(event);
if (disabledElement) {
if (currentDisabledHoverElement !== disabledElement) {
currentDisabledHoverElement = disabledElement;
const isImage = disabledElement.tagName.toLowerCase() === 'img';
showDisabledTooltip(disabledElement, isImage);
}
} else {
if (currentDisabledHoverElement) {
currentDisabledHoverElement = null;
hideDisabledTooltip();
}
}
}
function enableEditMode() {
document.getElementById('root')?.setAttribute('data-edit-mode-enabled', 'true');
injectPopupStyles();
if (!globalEventHandlers) {
globalEventHandlers = {
mousedown: handleGlobalEvent,
pointerdown: handleGlobalEvent,
click: handleGlobalEvent
};
Object.entries(globalEventHandlers).forEach(([eventType, handler]) => {
document.addEventListener(eventType, handler, true);
});
}
document.addEventListener('mousemove', handleDisabledGlobalHover, true);
document.querySelectorAll('[data-edit-disabled]').forEach(el => {
el.removeEventListener('mouseenter', handleDisabledElementHover);
el.addEventListener('mouseenter', handleDisabledElementHover);
el.removeEventListener('mouseleave', handleDisabledElementLeave);
el.addEventListener('mouseleave', handleDisabledElementLeave);
});
}
function disableEditMode() {
document.getElementById('root')?.removeAttribute('data-edit-mode-enabled');
hideDisabledTooltip();
if (globalEventHandlers) {
Object.entries(globalEventHandlers).forEach(([eventType, handler]) => {
document.removeEventListener(eventType, handler, true);
});
globalEventHandlers = null;
}
document.removeEventListener('mousemove', handleDisabledGlobalHover, true);
currentDisabledHoverElement = null;
document.querySelectorAll('[data-edit-disabled]').forEach(el => {
el.removeEventListener('mouseenter', handleDisabledElementHover);
el.removeEventListener('mouseleave', handleDisabledElementLeave);
});
}
window.addEventListener("message", function(event) {
if (event.data?.type === "edit-save") {
handleEditSave(event.data?.payload?.newText);
}
if (event.data?.type === "enable-edit-mode") {
if (event.data?.translations) {
translations = { ...translations, ...event.data.translations };
}
enableEditMode();
}
if (event.data?.type === "disable-edit-mode") {
disableEditMode();
}
});

View File

@@ -0,0 +1,137 @@
export const POPUP_STYLES = `
#inline-editor-popup {
width: 360px;
position: fixed;
z-index: 10000;
background: #161718;
color: white;
border: 1px solid #4a5568;
border-radius: 16px;
padding: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
flex-direction: column;
gap: 10px;
display: none;
}
@media (max-width: 768px) {
#inline-editor-popup {
width: calc(100% - 20px);
}
}
#inline-editor-popup.is-active {
display: flex;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
#inline-editor-popup.is-disabled-view {
padding: 10px 15px;
}
#inline-editor-popup textarea {
height: 100px;
padding: 4px 8px;
background: transparent;
color: white;
font-family: inherit;
font-size: 0.875rem;
line-height: 1.42;
resize: none;
outline: none;
}
#inline-editor-popup .button-container {
display: flex;
justify-content: flex-end;
gap: 10px;
}
#inline-editor-popup .popup-button {
border: none;
padding: 6px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 0.75rem;
font-weight: 700;
height: 34px;
outline: none;
}
#inline-editor-popup .save-button {
background: #673de6;
color: white;
}
#inline-editor-popup .cancel-button {
background: transparent;
border: 1px solid #3b3d4a;
color: white;
&:hover {
background:#474958;
}
}
`;
export function getPopupHTMLTemplate(saveLabel, cancelLabel) {
return `
<textarea></textarea>
<div class="button-container">
<button class="popup-button cancel-button">${cancelLabel}</button>
<button class="popup-button save-button">${saveLabel}</button>
</div>
`;
};
export const EDIT_MODE_STYLES = `
#root[data-edit-mode-enabled="true"] [data-edit-id] {
cursor: pointer;
outline: 2px dashed #357DF9;
outline-offset: 2px;
min-height: 1em;
}
#root[data-edit-mode-enabled="true"] img[data-edit-id] {
outline-offset: -2px;
}
#root[data-edit-mode-enabled="true"] {
cursor: pointer;
}
#root[data-edit-mode-enabled="true"] [data-edit-id]:hover {
background-color: #357DF933;
outline-color: #357DF9;
}
@keyframes fadeInTooltip {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
#inline-editor-disabled-tooltip {
display: none;
opacity: 0;
position: absolute;
background-color: #1D1E20;
color: white;
padding: 4px 8px;
border-radius: 8px;
z-index: 10001;
font-size: 14px;
border: 1px solid #3B3D4A;
max-width: 184px;
text-align: center;
}
#inline-editor-disabled-tooltip.tooltip-active {
display: block;
animation: fadeInTooltip 0.2s ease-out forwards;
}
`;

View File

@@ -0,0 +1,32 @@
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { fileURLToPath } from 'url';
import { EDIT_MODE_STYLES } from './visual-editor-config';
const __filename = fileURLToPath(import.meta.url);
const __dirname = resolve(__filename, '..');
export default function inlineEditDevPlugin() {
return {
name: 'vite:inline-edit-dev',
apply: 'serve',
transformIndexHtml() {
const scriptPath = resolve(__dirname, 'edit-mode-script.js');
const scriptContent = readFileSync(scriptPath, 'utf-8');
return [
{
tag: 'script',
attrs: { type: 'module' },
children: scriptContent,
injectTo: 'body'
},
{
tag: 'style',
children: EDIT_MODE_STYLES,
injectTo: 'head'
}
];
}
};
}

View File

@@ -0,0 +1,384 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { parse } from '@babel/parser';
import traverseBabel from '@babel/traverse';
import generate from '@babel/generator';
import * as t from '@babel/types';
import fs from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const VITE_PROJECT_ROOT = path.resolve(__dirname, '../..');
const EDITABLE_HTML_TAGS = ["a", "Button", "button", "p", "span", "h1", "h2", "h3", "h4", "h5", "h6", "label", "Label", "img"];
function parseEditId(editId) {
const parts = editId.split(':');
if (parts.length < 3) {
return null;
}
const column = parseInt(parts.at(-1), 10);
const line = parseInt(parts.at(-2), 10);
const filePath = parts.slice(0, -2).join(':');
if (!filePath || isNaN(line) || isNaN(column)) {
return null;
}
return { filePath, line, column };
}
function checkTagNameEditable(openingElementNode, editableTagsList) {
if (!openingElementNode || !openingElementNode.name) return false;
const nameNode = openingElementNode.name;
// Check 1: Direct name (for <p>, <Button>)
if (nameNode.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.name)) {
return true;
}
// Check 2: Property name of a member expression (for <motion.h1>, check if "h1" is in editableTagsList)
if (nameNode.type === 'JSXMemberExpression' && nameNode.property && nameNode.property.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.property.name)) {
return true;
}
return false;
}
function validateImageSrc(openingNode) {
if (!openingNode || !openingNode.name || openingNode.name.name !== 'img') {
return { isValid: true, reason: null }; // Not an image, skip validation
}
const hasPropsSpread = openingNode.attributes.some(attr =>
t.isJSXSpreadAttribute(attr) &&
attr.argument &&
t.isIdentifier(attr.argument) &&
attr.argument.name === 'props'
);
if (hasPropsSpread) {
return { isValid: false, reason: 'props-spread' };
}
const srcAttr = openingNode.attributes.find(attr =>
t.isJSXAttribute(attr) &&
attr.name &&
attr.name.name === 'src'
);
if (!srcAttr) {
return { isValid: false, reason: 'missing-src' };
}
if (!t.isStringLiteral(srcAttr.value)) {
return { isValid: false, reason: 'dynamic-src' };
}
if (!srcAttr.value.value || srcAttr.value.value.trim() === '') {
return { isValid: false, reason: 'empty-src' };
}
return { isValid: true, reason: null };
}
export default function inlineEditPlugin() {
return {
name: 'vite-inline-edit-plugin',
enforce: 'pre',
transform(code, id) {
if (!/\.(jsx|tsx)$/.test(id) || !id.startsWith(VITE_PROJECT_ROOT) || id.includes('node_modules')) {
return null;
}
const relativeFilePath = path.relative(VITE_PROJECT_ROOT, id);
const webRelativeFilePath = relativeFilePath.split(path.sep).join('/');
try {
const babelAst = parse(code, {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
errorRecovery: true
});
let attributesAdded = 0;
traverseBabel.default(babelAst, {
enter(path) {
if (path.isJSXOpeningElement()) {
const openingNode = path.node;
const elementNode = path.parentPath.node; // The JSXElement itself
if (!openingNode.loc) {
return;
}
const alreadyHasId = openingNode.attributes.some(
(attr) => t.isJSXAttribute(attr) && attr.name.name === 'data-edit-id'
);
if (alreadyHasId) {
return;
}
// Condition 1: Is the current element tag type editable?
const isCurrentElementEditable = checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS);
if (!isCurrentElementEditable) {
return;
}
const imageValidation = validateImageSrc(openingNode);
if (!imageValidation.isValid) {
const disabledAttribute = t.jsxAttribute(
t.jsxIdentifier('data-edit-disabled'),
t.stringLiteral('true')
);
openingNode.attributes.push(disabledAttribute);
attributesAdded++;
return;
}
let shouldBeDisabledDueToChildren = false;
// Condition 2: Does the element have dynamic or editable children
if (t.isJSXElement(elementNode) && elementNode.children) {
// Check if element has {...props} spread attribute - disable editing if it does
const hasPropsSpread = openingNode.attributes.some(attr => t.isJSXSpreadAttribute(attr)
&& attr.argument
&& t.isIdentifier(attr.argument)
&& attr.argument.name === 'props'
);
const hasDynamicChild = elementNode.children.some(child =>
t.isJSXExpressionContainer(child)
);
if (hasDynamicChild || hasPropsSpread) {
shouldBeDisabledDueToChildren = true;
}
}
if (!shouldBeDisabledDueToChildren && t.isJSXElement(elementNode) && elementNode.children) {
const hasEditableJsxChild = elementNode.children.some(child => {
if (t.isJSXElement(child)) {
return checkTagNameEditable(child.openingElement, EDITABLE_HTML_TAGS);
}
return false;
});
if (hasEditableJsxChild) {
shouldBeDisabledDueToChildren = true;
}
}
if (shouldBeDisabledDueToChildren) {
const disabledAttribute = t.jsxAttribute(
t.jsxIdentifier('data-edit-disabled'),
t.stringLiteral('true')
);
openingNode.attributes.push(disabledAttribute);
attributesAdded++;
return;
}
// Condition 3: Parent is non-editable if AT LEAST ONE child JSXElement is a non-editable type.
if (t.isJSXElement(elementNode) && elementNode.children && elementNode.children.length > 0) {
let hasNonEditableJsxChild = false;
for (const child of elementNode.children) {
if (t.isJSXElement(child)) {
if (!checkTagNameEditable(child.openingElement, EDITABLE_HTML_TAGS)) {
hasNonEditableJsxChild = true;
break;
}
}
}
if (hasNonEditableJsxChild) {
const disabledAttribute = t.jsxAttribute(
t.jsxIdentifier('data-edit-disabled'),
t.stringLiteral("true")
);
openingNode.attributes.push(disabledAttribute);
attributesAdded++;
return;
}
}
// Condition 4: Is any ancestor JSXElement also editable?
let currentAncestorCandidatePath = path.parentPath.parentPath;
while (currentAncestorCandidatePath) {
const ancestorJsxElementPath = currentAncestorCandidatePath.isJSXElement()
? currentAncestorCandidatePath
: currentAncestorCandidatePath.findParent(p => p.isJSXElement());
if (!ancestorJsxElementPath) {
break;
}
if (checkTagNameEditable(ancestorJsxElementPath.node.openingElement, EDITABLE_HTML_TAGS)) {
return;
}
currentAncestorCandidatePath = ancestorJsxElementPath.parentPath;
}
const line = openingNode.loc.start.line;
const column = openingNode.loc.start.column + 1;
const editId = `${webRelativeFilePath}:${line}:${column}`;
const idAttribute = t.jsxAttribute(
t.jsxIdentifier('data-edit-id'),
t.stringLiteral(editId)
);
openingNode.attributes.push(idAttribute);
attributesAdded++;
}
}
});
if (attributesAdded > 0) {
const generateFunction = generate.default || generate;
const output = generateFunction(babelAst, {
sourceMaps: true,
sourceFileName: webRelativeFilePath
}, code);
return { code: output.code, map: output.map };
}
return null;
} catch (error) {
console.error(`[vite][visual-editor] Error transforming ${id}:`, error);
return null;
}
},
// Updates source code based on the changes received from the client
configureServer(server) {
server.middlewares.use('/api/apply-edit', async (req, res, next) => {
if (req.method !== 'POST') return next();
let body = '';
req.on('data', chunk => { body += chunk.toString(); });
req.on('end', async () => {
let absoluteFilePath = '';
try {
const { editId, newFullText } = JSON.parse(body);
if (!editId || typeof newFullText === 'undefined') {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'Missing editId or newFullText' }));
}
const parsedId = parseEditId(editId);
if (!parsedId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'Invalid editId format (filePath:line:column)' }));
}
const { filePath, line, column } = parsedId;
absoluteFilePath = path.resolve(VITE_PROJECT_ROOT, filePath);
if (filePath.includes('..') || !absoluteFilePath.startsWith(VITE_PROJECT_ROOT) || absoluteFilePath.includes('node_modules')) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'Invalid path' }));
}
const originalContent = fs.readFileSync(absoluteFilePath, 'utf-8');
const babelAst = parse(originalContent, {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
errorRecovery: true
});
let targetNodePath = null;
const visitor = {
JSXOpeningElement(path) {
const node = path.node;
if (node.loc && node.loc.start.line === line && node.loc.start.column + 1 === column) {
targetNodePath = path;
path.stop();
}
}
};
traverseBabel.default(babelAst, visitor);
if (!targetNodePath) {
res.writeHead(404, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'Target node not found by line/column', editId }));
}
const generateFunction = generate.default || generate;
const targetOpeningElement = targetNodePath.node;
const parentElementNode = targetNodePath.parentPath?.node;
const isImageElement = targetOpeningElement.name && targetOpeningElement.name.name === 'img';
let beforeCode = '';
let afterCode = '';
let modified = false;
if (isImageElement) {
// Handle image src attribute update
const beforeOutput = generateFunction(targetOpeningElement, {});
beforeCode = beforeOutput.code;
const srcAttr = targetOpeningElement.attributes.find(attr =>
t.isJSXAttribute(attr) && attr.name && attr.name.name === 'src'
);
if (srcAttr && t.isStringLiteral(srcAttr.value)) {
srcAttr.value = t.stringLiteral(newFullText);
modified = true;
const afterOutput = generateFunction(targetOpeningElement, {});
afterCode = afterOutput.code;
}
} else {
if (parentElementNode && t.isJSXElement(parentElementNode)) {
const beforeOutput = generateFunction(parentElementNode, {});
beforeCode = beforeOutput.code;
parentElementNode.children = [];
if (newFullText && newFullText.trim() !== '') {
const newTextNode = t.jsxText(newFullText);
parentElementNode.children.push(newTextNode);
}
modified = true;
const afterOutput = generateFunction(parentElementNode, {});
afterCode = afterOutput.code;
}
}
if (!modified) {
res.writeHead(409, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'Could not apply changes to AST.' }));
}
const output = generateFunction(babelAst, {});
const newContent = output.code;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
newFileContent: newContent,
beforeCode,
afterCode,
}));
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error during edit application.' }));
}
});
});
}
};
}

View File

@@ -0,0 +1,125 @@
export default function iframeRouteRestorationPlugin() {
return {
name: 'vite:iframe-route-restoration',
apply: 'serve',
transformIndexHtml() {
const script = `
const ALLOWED_PARENT_ORIGINS = [
"https://horizons.hostinger.com",
"https://horizons.hostinger.dev",
"https://horizons-frontend-local.hostinger.dev",
];
// Check to see if the page is in an iframe
if (window.self !== window.top) {
const STORAGE_KEY = 'horizons-iframe-saved-route';
const getCurrentRoute = () => location.pathname + location.search + location.hash;
const save = () => {
try {
const currentRoute = getCurrentRoute();
sessionStorage.setItem(STORAGE_KEY, currentRoute);
window.parent.postMessage({message: 'route-changed', route: currentRoute}, '*');
} catch {}
};
const replaceHistoryState = (url) => {
try {
history.replaceState(null, '', url);
window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }));
return true;
} catch {}
return false;
};
const restore = () => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (!saved) return;
if (!saved.startsWith('/')) {
sessionStorage.removeItem(STORAGE_KEY);
return;
}
const current = getCurrentRoute();
if (current !== saved) {
if (!replaceHistoryState(saved)) {
replaceHistoryState('/');
}
requestAnimationFrame(() => setTimeout(() => {
try {
const text = (document.body?.innerText || '').trim();
// If the restored route results in too little content, assume it is invalid and navigate home
if (text.length < 50) {
replaceHistoryState('/');
}
} catch {}
}, 1000));
}
} catch {}
};
const originalPushState = history.pushState;
history.pushState = function(...args) {
originalPushState.apply(this, args);
save();
};
const originalReplaceState = history.replaceState;
history.replaceState = function(...args) {
originalReplaceState.apply(this, args);
save();
};
const getParentOrigin = () => {
if (
window.location.ancestorOrigins &&
window.location.ancestorOrigins.length > 0
) {
return window.location.ancestorOrigins[0];
}
if (document.referrer) {
try {
return new URL(document.referrer).origin;
} catch (e) {
console.warn("Invalid referrer URL:", document.referrer);
}
}
return null;
};
window.addEventListener('popstate', save);
window.addEventListener('hashchange', save);
window.addEventListener("message", function (event) {
const parentOrigin = getParentOrigin();
if (event.data?.type === "redirect-home" && parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
const saved = sessionStorage.getItem(STORAGE_KEY);
if(saved && saved !== '/') {
replaceHistoryState('/')
}
}
});
restore();
}
`;
return [
{
tag: 'script',
attrs: { type: 'module' },
children: script,
injectTo: 'head'
}
];
}
};
}

6
src/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

19
src/public/.htaccess Normal file
View File

@@ -0,0 +1,19 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.html [L]
</IfModule>
<IfModule mod_headers.c>
Header set X-Powered-By "Hostinger Horizons"
# Cache everything on CDN by default
Header set Cache-Control "public, s-maxage=604800, max-age=0"
# Cache in browser all assets
<If "%{REQUEST_URI} =~ m#^/assets/.*$#">
Header set Cache-Control "public, max-age=604800"
</If>
</IfModule>

View File

@@ -1,17 +1,17 @@
import React from 'react';
import { motion } from 'framer-motion';
const WelcomeMessage = () => {
const CallToAction = () => {
return (
<motion.p
className='text-sm text-white leading-5 w-full'
className='text-md text-white max-w-lg mx-auto'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.8 }}
>
Write in the chat what you want to create.
Let's turn your ideas into reality.
</motion.p>
);
};
export default WelcomeMessage;
export default CallToAction;

View File

@@ -0,0 +1,14 @@
import React from 'react';
const HeroImage = () => {
return (
<div className='flex justify-center items-center'>
<img
src='https://imagedelivery.net/LqiWLm-3MGbYHtFuUbcBtA/119580eb-abd9-4191-b93a-f01938786700/public'
alt='Hostinger Horizons'
/>
</div>
);
};
export default HeroImage;

View File

@@ -4,11 +4,11 @@ import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
const carouselImages = [
{ src: "https://images.unsplash.com/photo-1470225620780-dba8ba36b745", alt: "DJ mixant de la musique lors d'un concert vibrant" },
{ src: "https://images.unsplash.com/photo-1511379938547-c1f69419868d", alt: "Gros plan sur des écouteurs posés sur un clavier d'ordinateur portable" },
{ src: "https://images.unsplash.com/photo-1487180144351-b8472da7d491", alt: "Personne jouant de la guitare acoustique avec un effet de lumière bokeh" },
{ src: "https://images.unsplash.com/photo-1507838153414-b4b713384a76", alt: "Piano à queue dans une pièce faiblement éclairée avec des partitions" },
{ src: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f", alt: "Microphone de studio vintage sur fond sombre" }
{ src: "https://files.dites-le-en-chanson.fr/images/photo-1470225620780-dba8ba36b745.jpg", alt: "DJ mixant de la musique lors d'un concert vibrant" },
{ src: "https://files.dites-le-en-chanson.fr/images/photo-1511379938547-c1f69419868d.jpg", alt: "Gros plan sur des écouteurs posés sur un clavier d'ordinateur portable" },
{ src: "https://files.dites-le-en-chanson.fr/images/photo-1487180144351-b8472da7d491.jpg", alt: "Personne jouant de la guitare acoustique avec un effet de lumière bokeh" },
{ src: "https://files.dites-le-en-chanson.fr/images/photo-1507838153414-b4b713384a76.jpg", alt: "Piano à queue dans une pièce faiblement éclairée avec des partitions" },
{ src: "https://files.dites-le-en-chanson.fr/images/photo-1493225457124-a3eb161ffa5f.jpg", alt: "Microphone de studio vintage sur fond sombre" }
];
const variants = {
@@ -105,4 +105,4 @@ import React, { useState, useEffect } from 'react';
);
};
export default ImageCarousel;
export default ImageCarousel;

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { motion } from 'framer-motion';
const WelcomeMessage = () => {
return (
<motion.p
className='text-xl md:text-2xl text-white max-w-2xl mx-auto'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.5 }}
>
Hello there! I'm <span className='font-semibold text-purple-300'>Horizons</span>, your AI coding companion.
I'm here to help you build amazing web application!
</motion.p>
);
};
export default WelcomeMessage;

View File

@@ -14,7 +14,7 @@ export function Toaster() {
return (
<ToastProvider>
{toasts.map(({ id, title, description, action, ...props }) => {
{toasts.map(({ id, title, description, action, dismiss, update, ...props }) => {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">

View File

@@ -1,49 +1,57 @@
import React from 'react';
import { ShoppingBag, Edit2, Music, Send } from 'lucide-react';
export const STRIPE_PUBLISHABLE_KEY = 'pk_live_51RPSGmEPL3QASpovp8Q6p8ehNMW7TzSrOaV6zvPE1OtflMFN5jChQBEj5kr84wontlLOe8uiHyJBiCduzxIZwj5A00DIEVs31n';
export const STRIPE_PUBLISHABLE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY_DEV;
export const products = [
{
id: 'prod_SQK3M9UvfXBUh0',
name: 'Chanson personnalisée',
{
id: 'prod_SQK3M9UvfXBUh0',
name: 'Chanson personnalisée',
price: 19.90,
promotionPrice: 17.90,
stripePriceId: 'price_1RVTPKEPL3QASpovq34PyAvN',
promotionPrice: 17.90,
stripePriceId: 'price_1RVTPKEPL3QASpovq34PyAvN',
description: 'Une chanson complète, paroles et musique, rien que pour vous, à un prix spécial !',
imageUrl: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/images//chanson-personnalisee.jpg'
imageUrl: 'https://files.dites-le-en-chanson.fr/products/chanson-personnalisee.jpg'
},
{
id: 'prod_SQK4s1RR0xK3oe',
name: '2 chansons personnalisées',
price: 29.90,
{
id: 'prod_SQK4s1RR0xK3oe',
name: '2 chansons personnalisées',
price: 29.90,
promotionPrice: null,
stripePriceId: 'price_1RVTQKEPL3QASpovL3Cg7K9S',
stripePriceId: 'price_1RVTQKEPL3QASpovL3Cg7K9S',
description: 'Deux chansons uniques pour deux fois plus d\'émotion.',
imageUrl: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/images//chansons-personnalisees.jpg'
imageUrl: 'https://files.dites-le-en-chanson.fr/products/chansons-personnalisees.jpg'
},
{
id: 'prod_SQKBpI2jc1hHOQ',
name: '1 chanson personnalisée + 1 clip photo-musique',
price: 49.90,
{
id: 'prod_SQKBpI2jc1hHOQ',
name: '1 chanson personnalisée + 1 clip photo-musique',
price: 49.90,
promotionPrice: 44.90,
stripePriceId: 'price_1RVTXYEPL3QASpovSei3ZV48',
stripePriceId: 'price_1RVTXYEPL3QASpovSei3ZV48',
description: 'Votre chanson mise en musique et accompagnée d\'un clip photo, en promotion !',
imageUrl: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/images//chanson-photo.jpg'
imageUrl: 'https://files.dites-le-en-chanson.fr/products/chanson-photo.jpg'
},
{
id: 'prod_SQKCtsEPlwf329',
name: 'Texte personnalisé',
price: 7.90,
{
id: 'prod_SQKCtsEPlwf329',
name: 'Texte personnalisé',
price: 7.90,
promotionPrice: null,
stripePriceId: 'price_1RVTXtEPL3QASpovaWjXf30q',
stripePriceId: 'price_1RVTXtEPL3QASpovaWjXf30q',
description: 'Un texte de chanson unique, écrit sur mesure pour vous.',
imageUrl: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/images//texte-personalise.jpg'
imageUrl: 'https://files.dites-le-en-chanson.fr/products/texte-personalise.jpg'
},
{
id: 'prod_SQMCFqFLaNHxx0',
name: 'Test',
price: 17.90,
promotionPrice: null,
stripePriceId: 'price_1RVVUBERAUBjYKpgYuQyuI76',
description: 'Un texte de chanson unique, écrit sur mesure pour vous.',
imageUrl: 'https://files.dites-le-en-chanson.fr/products/texte-personalise.jpg'
}
];
export const initialFormData = {
selectedProduct: null,
selectedProduct: null,
recipientName: '',
songForWhom: '',
occasion: '',
@@ -62,7 +70,7 @@ import React from 'react';
{ id: 'musicalChoices', title: 'Choix Musicaux', icon: <Music className="h-5 w-5 mr-2" /> },
{ id: 'review', title: 'Récapitulatif & Paiement', icon: <Send className="h-5 w-5 mr-2" /> },
];
export const languages = ["Français", "Anglais", "Espagnol", "Autre"];
export const voiceGenders = ["Femme", "Homme", "Peu importe"];
export const musicalStyles = ["Pop", "Hip Hop", "Rock", "Funny", "Acoustique", "R&B", "Reggae", "Funk", "Soul", "Country", "Electro", "Métal", "Gospel", "Zouk", "Variété Française"];
@@ -83,4 +91,4 @@ import React from 'react';
{ name: 'musicalStyle', message: "Veuillez choisir un style musical." },
{ name: 'mood', message: "Veuillez choisir une ambiance." },
],
};
};

View File

@@ -0,0 +1,6 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);

View File

@@ -17,6 +17,18 @@ import React, { useEffect, useState } from 'react';
const navigate = useNavigate();
useEffect(() => {
// Empêcher l'exécution multiple (React StrictMode en dev)
// Utiliser sessionId comme clé pour tracker les sessions déjà traitées
const processedKey = `processed_${sessionId}`;
if (!sessionId || sessionStorage.getItem(processedKey)) {
if (!sessionId) {
setMainStatus({ type: 'error', message: "Session de paiement invalide ou manquante." });
toast({ title: "Erreur", description: "Session de paiement invalide.", variant: "destructive" });
setIsLoading(false);
}
return;
}
const processOrder = async () => {
if (!sessionId) {
setMainStatus({ type: 'error', message: "Session de paiement invalide ou manquante." });
@@ -36,8 +48,36 @@ import React, { useEffect, useState } from 'react';
const storedOrderData = JSON.parse(storedOrderDataString);
setOrderDetails(storedOrderData);
// Récupérer les données formatées pour la DB si disponibles, sinon transformer depuis storedOrderData
const orderDataForDBString = localStorage.getItem('orderDataForDB');
let orderDataForDB;
if (orderDataForDBString) {
orderDataForDB = JSON.parse(orderDataForDBString);
} else {
// Fallback: transformer depuis storedOrderData (format camelCase)
orderDataForDB = {
product_name: storedOrderData.productName || '',
price: storedOrderData.price || 0,
recipient_name: storedOrderData.recipientName || '',
song_for_whom: storedOrderData.songForWhom || '',
occasion: storedOrderData.occasion || '',
language: storedOrderData.language || '',
anecdote1: storedOrderData.anecdote1 || '',
anecdote2: storedOrderData.anecdote2 || '',
anecdote3: storedOrderData.anecdote3 || '',
voice_gender: storedOrderData.voiceGender || '',
musical_style: storedOrderData.musicalStyle || '',
mood: storedOrderData.mood || '',
customer_email: storedOrderData.email || storedOrderData.customer_email || '',
};
}
// Marquer comme en cours de traitement AVANT l'appel API
sessionStorage.setItem(processedKey, 'true');
try {
const payload = { orderData: { ...storedOrderData }, sessionId: sessionId };
const payload = { orderData: orderDataForDB, sessionId: sessionId };
const { data: functionResponse, error: functionError } = await supabase.functions.invoke('send-order-confirmation-email', {
body: JSON.stringify(payload),
});
@@ -98,13 +138,14 @@ import React, { useEffect, useState } from 'react';
});
} finally {
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
localStorage.removeItem('orderDataForDB');
setIsLoading(false);
}
};
processOrder();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, toast]);
}, [sessionId]);
const handleReturnHome = () => {
localStorage.removeItem('songOrderForm');

View File

@@ -8,35 +8,35 @@ const creations = [
type: 'image',
title: 'Pochette d\'Album Conceptuelle',
description: 'Une image de démonstration pour illustrer une pochette ou un visuel accompagnant une chanson.',
url: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/demo//exemple.jpg',
url: 'https://files.dites-le-en-chanson.fr/demo/exemple.jpg',
icon: <ImageIcon className="h-8 w-8 text-primary" />
},
{
type: 'audio',
title: 'Extrait Musical Personnalisé',
description: 'Écoutez un exemple de création audio, une mélodie entraînante et unique.',
url: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/demo//exemple1.mp3',
url: 'https://files.dites-le-en-chanson.fr/demo/exemple1.mp3',
icon: <Music className="h-8 w-8 text-primary" />
},
{
type: 'video',
title: 'Clip Vidéo Émotionnel',
description: 'Visualisez un exemple de montage vidéo accompagnant une chanson, capturant l\'essence du message.',
url: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/demo//exemple2.mp4',
url: 'https://files.dites-le-en-chanson.fr/demo/exemple2.mp4',
icon: <Video className="h-8 w-8 text-primary" />
},
{
type: 'video',
title: 'Vidéo Lyrique Animée',
description: 'Un autre exemple de vidéo, peut-être avec des paroles animées ou un style visuel différent.',
url: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/demo//exemple3.mp4',
url: 'https://files.dites-le-en-chanson.fr/demo/exemple3.mp4',
icon: <Video className="h-8 w-8 text-primary" />
},
{
type: 'video',
title: 'Performance Acoustique Filmée',
description: 'Découvrez une performance simple et touchante, mettant en valeur la voix et la mélodie.',
url: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/demo//exemple4.mp4',
url: 'https://files.dites-le-en-chanson.fr/demo/exemple4.mp4',
icon: <Video className="h-8 w-8 text-primary" />
}
];
@@ -95,11 +95,11 @@ const CreationsPage = () => {
<div>
{creation.type === 'image' && (
<div className="aspect-video w-full overflow-hidden rounded-md mb-4 border border-border">
<img-replace src={creation.url} alt={creation.title} className="w-full h-full object-cover transition-transform duration-300 hover:scale-105" />
<img src={creation.url} alt={creation.title} className="w-full h-full object-cover transition-transform duration-300 hover:scale-105" />
</div>
)}
{creation.type === 'audio' && (
<audio controls className="w-full my-4 rounded-md shadow-sm">
<audio controls className="w-full my-16 rounded-md shadow-sm">
<source src={creation.url} type="audio/mpeg" />
Votre navigateur ne supporte pas l'élément audio.
</audio>
@@ -123,4 +123,4 @@ const CreationsPage = () => {
);
};
export default CreationsPage;
export default CreationsPage;

374
src/src/pages/OrderPage.jsx Normal file
View File

@@ -0,0 +1,374 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useToast } from '@/components/ui/use-toast';
import { ChevronLeft, ChevronRight, ShoppingCart } from 'lucide-react';
import Step0ProductSelection from '@/components/order/Step0ProductSelection';
import Step1Content from '@/components/order/Step1Content';
import Step2MusicalChoices from '@/components/order/Step2MusicalChoices';
import Step3Review from '@/components/order/Step3Review';
import {
initialFormData,
stepsConfig,
formValidations,
products,
STRIPE_PUBLISHABLE_KEY
} from '@/config/orderFormConfig';
import { loadStripe } from '@stripe/stripe-js';
import { supabase } from '@/lib/supabaseClient';
const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY);
const OrderPage = () => {
const [ currentStep, setCurrentStep ] = useState(0);
const [ formData, setFormData ] = useState(() => {
const savedData = localStorage.getItem('songOrderForm');
if (savedData) {
const parsedData = JSON.parse(savedData);
const selectedProductDetails = products.find(p => p.id === parsedData.selectedProduct);
if (!selectedProductDetails) {
return {...initialFormData, selectedProduct: initialFormData.selectedProduct, price: 0, stripePriceId: null};
}
return {
...parsedData,
price: selectedProductDetails.promotionPrice || selectedProductDetails.price,
stripePriceId: selectedProductDetails.stripePriceId
};
}
return initialFormData;
});
const [ errors, setErrors ] = useState({});
const {toast} = useToast();
const navigate = useNavigate();
const [ isProcessingPayment, setIsProcessingPayment ] = useState(false);
useEffect(() => {
localStorage.setItem('songOrderForm', JSON.stringify(formData));
}, [ formData ]);
const handleChange = (e) => {
const {name, value} = e.target;
setFormData(prev => ({...prev, [name]: value}));
if (errors[name]) {
setErrors(prev => ({...prev, [name]: null}));
}
};
const handleSelectChange = (name, value) => {
setFormData(prev => ({...prev, [name]: value}));
if (errors[name]) {
setErrors(prev => ({...prev, [name]: null}));
}
};
const handleRadioGroupChange = (name, value) => {
setFormData(prev => ({...prev, [name]: value}));
if (errors[name]) {
setErrors(prev => ({...prev, [name]: null}));
}
};
const handleProductSelect = (productId) => {
const selectedProductDetails = products.find(p => p.id === productId);
setFormData(prev => ({
...prev,
selectedProduct: productId,
price: selectedProductDetails?.promotionPrice || selectedProductDetails?.price || 0,
stripePriceId: selectedProductDetails?.stripePriceId
}));
if (errors.selectedProduct) {
setErrors(prev => ({...prev, selectedProduct: null}));
}
};
const validateStep = () => {
const currentStepId = stepsConfig[currentStep].id;
const stepFields = formValidations[currentStepId];
let newErrors = {};
let isValid = true;
if (stepFields) {
for (const field of stepFields) {
const value = formData[field.name];
if (!value || (typeof value === 'string' && value.trim() === '')) {
newErrors[field.name] = field.message;
isValid = false;
}
}
}
setErrors(newErrors);
if (!isValid && Object.keys(newErrors).length > 0) {
const firstErrorField = Object.keys(newErrors)[0];
toast({title: "Champ requis", description: newErrors[firstErrorField], variant: "destructive"});
}
return isValid;
};
const nextStep = () => {
if (validateStep()) {
setCurrentStep(prev => Math.min(prev + 1, stepsConfig.length - 1));
}
};
const prevStep = () => {
setErrors({});
setCurrentStep(prev => Math.max(prev - 1, 0));
}
const handleSubmit = async () => {
if (!validateStep() || !formData.selectedProduct || !formData.stripePriceId) {
toast({
title: "Erreur de commande",
description: "Veuillez sélectionner un produit et remplir tous les champs requis avant de continuer.",
variant: "destructive",
});
return;
}
setIsProcessingPayment(true);
toast({
title: "Préparation du paiement...",
description: "Vous allez être redirigé vers Stripe.",
});
try {
const stripe = await stripePromise;
if (!stripe) {
throw new Error("Stripe n'a pas pu être initialisé. Vérifiez votre clé publique.");
}
const selectedProductDetails = products.find(p => p.id === formData.selectedProduct);
if (!selectedProductDetails || !selectedProductDetails.stripePriceId || selectedProductDetails.stripePriceId.includes('YOUR_')) {
toast({
title: "Erreur de configuration produit",
description: "L'ID de prix Stripe pour ce produit n'est pas correctement configuré. Veuillez me fournir les bons Price IDs.",
variant: "destructive",
});
setIsProcessingPayment(false);
return;
}
const finalPrice = selectedProductDetails.promotionPrice || selectedProductDetails.price;
// Transformer les données pour correspondre à la structure de la table orders
const orderDataForDB = {
product_name: selectedProductDetails?.name,
price: finalPrice,
recipient_name: formData.recipientName,
song_for_whom: formData.songForWhom,
occasion: formData.occasion,
language: formData.language,
anecdote1: formData.anecdote1,
anecdote2: formData.anecdote2,
anecdote3: formData.anecdote3,
voice_gender: formData.voiceGender,
musical_style: formData.musicalStyle,
mood: formData.mood,
customer_email: formData.email,
};
// Garder aussi les données en camelCase pour l'affichage dans ConfirmationPage
const finalFormDataForConfirmation = {
...formData,
productName: selectedProductDetails?.name,
price: finalPrice,
email: formData.email,
};
localStorage.setItem('confirmedOrderDataForConfirmationPage', JSON.stringify(finalFormDataForConfirmation));
localStorage.setItem('orderDataForDB', JSON.stringify(orderDataForDB));
// Créer la session Checkout via une fonction Supabase Edge
console.log("Création de la session Checkout Stripe...");
const {data: sessionData, error: sessionError} = await supabase.functions.invoke('create-checkout-session', {
body: JSON.stringify({
priceId: selectedProductDetails.stripePriceId,
quantity: 1,
customerEmail: formData.email,
successUrl: `${window.location.origin}/confirmation?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${window.location.origin}/commander`,
}),
});
if (sessionError) {
console.error("Erreur lors de la création de la session:", sessionError);
toast({
title: "Erreur de paiement",
description: sessionError.message || "Impossible de créer la session de paiement. Veuillez réessayer.",
variant: "destructive",
});
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
localStorage.removeItem('orderDataForDB');
setIsProcessingPayment(false);
return;
}
// Vérifier la structure de la réponse
console.log("Réponse complète de la fonction Edge:", sessionData);
// La réponse peut être directement sessionId ou dans un objet
const sessionId = sessionData?.sessionId || sessionData?.id || sessionData;
if (!sessionId || typeof sessionId !== 'string') {
console.error("Aucune session ID valide retournée:", sessionData);
toast({
title: "Erreur de paiement",
description: "La session de paiement n'a pas pu être créée. Veuillez réessayer.",
variant: "destructive",
});
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
localStorage.removeItem('orderDataForDB');
setIsProcessingPayment(false);
return;
}
// Rediriger vers Stripe avec le sessionId
console.log("Redirection vers Stripe avec sessionId:", sessionId);
// Vérifier si on a une URL de checkout dans la réponse
const checkoutUrl = sessionData?.url || sessionData?.checkoutUrl;
if (checkoutUrl) {
// Si on a l'URL directement, l'utiliser
console.log("Redirection vers l'URL de checkout fournie:", checkoutUrl);
window.location.href = checkoutUrl;
return;
}
// Sinon, utiliser redirectToCheckout de Stripe.js
try {
// Mémoriser l'URL actuelle pour détecter si la redirection a eu lieu
const currentUrl = window.location.href;
const {error: redirectError} = await stripe.redirectToCheckout({
sessionId: sessionId,
});
if (redirectError) {
console.error("Erreur Stripe lors de la redirection:", redirectError);
toast({
title: "Erreur de paiement",
description: redirectError.message || "Une erreur est survenue lors de la redirection vers Stripe.",
variant: "destructive",
});
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
localStorage.removeItem('orderDataForDB');
setIsProcessingPayment(false);
} else {
// La redirection devrait se produire
console.log("Redirection initiée avec succès via redirectToCheckout");
// Vérifier après un court délai si la redirection a réellement eu lieu
// (fallback si redirectToCheckout ne fonctionne pas silencieusement)
setTimeout(() => {
if (window.location.href === currentUrl && document.visibilityState === 'visible') {
console.warn("La redirection automatique n'a pas fonctionné, tentative alternative...");
// Essayer une redirection manuelle via l'API Stripe
// Note: Cette approche nécessite que la fonction Edge retourne l'URL de checkout
// Pour l'instant, on affiche un message à l'utilisateur
toast({
title: "Redirection en cours...",
description: "Si la redirection ne fonctionne pas, veuillez cliquer sur le lien de paiement.",
variant: "default",
});
setIsProcessingPayment(false);
}
}, 1500);
}
} catch (redirectErr) {
console.error("Exception lors de la redirection:", redirectErr);
toast({
title: "Erreur de paiement",
description: redirectErr.message || "Une erreur est survenue lors de la redirection vers Stripe.",
variant: "destructive",
});
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
localStorage.removeItem('orderDataForDB');
setIsProcessingPayment(false);
}
} catch (err) {
console.error("Erreur lors de la préparation du paiement:", err);
toast({
title: "Erreur de paiement",
description: err.message || "Une erreur inattendue est survenue.",
variant: "destructive",
});
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
localStorage.removeItem('orderDataForDB');
setIsProcessingPayment(false);
}
};
const progressPercentage = ((currentStep + 1) / stepsConfig.length) * 100;
const currentSelectedProduct = formData.selectedProduct ? products.find(p => p.id === formData.selectedProduct) : null;
const currentPriceToPay = currentSelectedProduct ? (currentSelectedProduct.promotionPrice || currentSelectedProduct.price) : 0;
const renderStepContent = () => {
const stepId = stepsConfig[currentStep].id;
switch (stepId) {
case 'productSelection':
return <Step0ProductSelection selectedProduct={formData.selectedProduct} onProductSelect={handleProductSelect}
error={errors.selectedProduct}/>;
case 'songInfo':
return <Step1Content formData={formData} handleChange={handleChange} handleSelectChange={handleSelectChange}
errors={errors}/>;
case 'musicalChoices':
return <Step2MusicalChoices formData={formData} handleSelectChange={handleSelectChange}
handleRadioGroupChange={handleRadioGroupChange} errors={errors}/>;
case 'review':
return <Step3Review formData={formData} handleChange={handleChange} errors={errors}/>;
default:
return <div>Étape inconnue</div>;
}
};
return (
<div className="max-w-3xl mx-auto py-8">
<Card className="shadow-2xl bg-card/80 backdrop-blur-md">
<CardHeader className="text-center border-b pb-4">
<div className="flex items-center justify-center text-3xl font-bold mb-2">
{stepsConfig[currentStep].icon}
<h1 className="gradient-text">{stepsConfig[currentStep].title}</h1>
</div>
<CardDescription>Étape {currentStep + 1} sur {stepsConfig.length}</CardDescription>
<Progress value={progressPercentage} className="mt-4 h-3"/>
</CardHeader>
<CardContent className="p-6 md:p-10 min-h-[300px]">
<AnimatePresence mode="wait">
{renderStepContent()}
</AnimatePresence>
</CardContent>
<CardFooter className="flex justify-between p-6 border-t">
<Button variant="outline" onClick={prevStep} disabled={currentStep === 0 || isProcessingPayment}
className="text-base px-6 py-3">
<ChevronLeft className="h-5 w-5 mr-2"/>
Précédent
</Button>
{currentStep < stepsConfig.length - 1 ? (
<Button onClick={nextStep} disabled={isProcessingPayment}
className="text-base px-6 py-3 bg-gradient-to-r from-primary to-accent hover:opacity-90 transition-opacity">
Suivant
<ChevronRight className="h-5 w-5 ml-2"/>
</Button>
) : (
<Button onClick={handleSubmit} disabled={isProcessingPayment}
className="text-base px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-600 hover:opacity-90 transition-opacity">
{isProcessingPayment ? "Traitement..." : `Payer (${currentPriceToPay ? currentPriceToPay.toFixed(2) : '0.00'} €)`}
{!isProcessingPayment && <ShoppingCart className="h-5 w-5 ml-2"/>}
</Button>
)}
</CardFooter>
</Card>
</div>
);
};
export default OrderPage;

76
src/tailwind.config.js Normal file
View File

@@ -0,0 +1,76 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'./pages/**/*.{js,jsx}',
'./components/**/*.{js,jsx}',
'./app/**/*.{js,jsx}',
'./src/**/*.{js,jsx}',
],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};

181
src/tools/generate-llms.js Normal file
View File

@@ -0,0 +1,181 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const CLEAN_CONTENT_REGEX = {
comments: /\/\*[\s\S]*?\*\/|\/\/.*$/gm,
templateLiterals: /`[\s\S]*?`/g,
strings: /'[^']*'|"[^"]*"/g,
jsxExpressions: /\{.*?\}/g,
htmlEntities: {
quot: /&quot;/g,
amp: /&amp;/g,
lt: /&lt;/g,
gt: /&gt;/g,
apos: /&apos;/g
}
};
const EXTRACTION_REGEX = {
route: /<Route\s+[^>]*>/g,
path: /path=["']([^"']+)["']/,
element: /element=\{<(\w+)[^}]*\/?\s*>\}/,
helmet: /<Helmet[^>]*?>([\s\S]*?)<\/Helmet>/i,
helmetTest: /<Helmet[\s\S]*?<\/Helmet>/i,
title: /<title[^>]*?>\s*(.*?)\s*<\/title>/i,
description: /<meta\s+name=["']description["']\s+content=["'](.*?)["']/i
};
function cleanContent(content) {
return content
.replace(CLEAN_CONTENT_REGEX.comments, '')
.replace(CLEAN_CONTENT_REGEX.templateLiterals, '""')
.replace(CLEAN_CONTENT_REGEX.strings, '""');
}
function cleanText(text) {
if (!text) return text;
return text
.replace(CLEAN_CONTENT_REGEX.jsxExpressions, '')
.replace(CLEAN_CONTENT_REGEX.htmlEntities.quot, '"')
.replace(CLEAN_CONTENT_REGEX.htmlEntities.amp, '&')
.replace(CLEAN_CONTENT_REGEX.htmlEntities.lt, '<')
.replace(CLEAN_CONTENT_REGEX.htmlEntities.gt, '>')
.replace(CLEAN_CONTENT_REGEX.htmlEntities.apos, "'")
.trim();
}
function extractRoutes(appJsxPath) {
if (!fs.existsSync(appJsxPath)) return new Map();
try {
const content = fs.readFileSync(appJsxPath, 'utf8');
const routes = new Map();
const routeMatches = [...content.matchAll(EXTRACTION_REGEX.route)];
for (const match of routeMatches) {
const routeTag = match[0];
const pathMatch = routeTag.match(EXTRACTION_REGEX.path);
const elementMatch = routeTag.match(EXTRACTION_REGEX.element);
const isIndex = routeTag.includes('index');
if (elementMatch) {
const componentName = elementMatch[1];
let routePath;
if (isIndex) {
routePath = '/';
} else if (pathMatch) {
routePath = pathMatch[1].startsWith('/') ? pathMatch[1] : `/${pathMatch[1]}`;
}
routes.set(componentName, routePath);
}
}
return routes;
} catch (error) {
return new Map();
}
}
function findReactFiles(dir) {
return fs.readdirSync(dir).map(item => path.join(dir, item));
}
function extractHelmetData(content, filePath, routes) {
const cleanedContent = cleanContent(content);
if (!EXTRACTION_REGEX.helmetTest.test(cleanedContent)) {
return null;
}
const helmetMatch = content.match(EXTRACTION_REGEX.helmet);
if (!helmetMatch) return null;
const helmetContent = helmetMatch[1];
const titleMatch = helmetContent.match(EXTRACTION_REGEX.title);
const descMatch = helmetContent.match(EXTRACTION_REGEX.description);
const title = cleanText(titleMatch?.[1]);
const description = cleanText(descMatch?.[1]);
const fileName = path.basename(filePath, path.extname(filePath));
const url = routes.length && routes.has(fileName)
? routes.get(fileName)
: generateFallbackUrl(fileName);
return {
url,
title: title || 'Untitled Page',
description: description || 'No description available'
};
}
function generateFallbackUrl(fileName) {
const cleanName = fileName.replace(/Page$/, '').toLowerCase();
return cleanName === 'app' ? '/' : `/${cleanName}`;
}
function generateLlmsTxt(pages) {
const sortedPages = pages.sort((a, b) => a.title.localeCompare(b.title));
const pageEntries = sortedPages.map(page =>
`- [${page.title}](${page.url}): ${page.description}`
).join('\n');
return `## Pages\n${pageEntries}`;
}
function ensureDirectoryExists(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function processPageFile(filePath, routes) {
try {
const content = fs.readFileSync(filePath, 'utf8');
return extractHelmetData(content, filePath, routes);
} catch (error) {
console.error(`❌ Error processing ${filePath}:`, error.message);
return null;
}
}
function main() {
const pagesDir = path.join(process.cwd(), 'src', 'pages');
const appJsxPath = path.join(process.cwd(), 'src', 'App.jsx');
let pages = [];
if (!fs.existsSync(pagesDir)) {
pages.push(processPageFile(appJsxPath, []));
} else {
const routes = extractRoutes(appJsxPath);
const reactFiles = findReactFiles(pagesDir);
pages = reactFiles
.map(filePath => processPageFile(filePath, routes))
.filter(Boolean);
if (pages.length === 0) {
console.error('❌ No pages with Helmet components found!');
process.exit(1);
}
}
const llmsTxtContent = generateLlmsTxt(pages);
const outputPath = path.join(process.cwd(), 'public', 'llms.txt');
ensureDirectoryExists(path.dirname(outputPath));
fs.writeFileSync(outputPath, llmsTxtContent, 'utf8');
}
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
main();
}

265
src/vite.config.js Normal file
View File

@@ -0,0 +1,265 @@
import path from 'node:path';
import react from '@vitejs/plugin-react';
import { createLogger, defineConfig } from 'vite';
import inlineEditPlugin from './plugins/visual-editor/vite-plugin-react-inline-editor.js';
import editModeDevPlugin from './plugins/visual-editor/vite-plugin-edit-mode.js';
import iframeRouteRestorationPlugin from './plugins/vite-plugin-iframe-route-restoration.js';
const isDev = process.env.NODE_ENV !== 'production';
const configHorizonsViteErrorHandler = `
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const addedNode of mutation.addedNodes) {
if (
addedNode.nodeType === Node.ELEMENT_NODE &&
(
addedNode.tagName?.toLowerCase() === 'vite-error-overlay' ||
addedNode.classList?.contains('backdrop')
)
) {
handleViteOverlay(addedNode);
}
}
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
function handleViteOverlay(node) {
if (!node.shadowRoot) {
return;
}
const backdrop = node.shadowRoot.querySelector('.backdrop');
if (backdrop) {
const overlayHtml = backdrop.outerHTML;
const parser = new DOMParser();
const doc = parser.parseFromString(overlayHtml, 'text/html');
const messageBodyElement = doc.querySelector('.message-body');
const fileElement = doc.querySelector('.file');
const messageText = messageBodyElement ? messageBodyElement.textContent.trim() : '';
const fileText = fileElement ? fileElement.textContent.trim() : '';
const error = messageText + (fileText ? ' File:' + fileText : '');
window.parent.postMessage({
type: 'horizons-vite-error',
error,
}, '*');
}
}
`;
const configHorizonsRuntimeErrorHandler = `
window.onerror = (message, source, lineno, colno, errorObj) => {
const errorDetails = errorObj ? JSON.stringify({
name: errorObj.name,
message: errorObj.message,
stack: errorObj.stack,
source,
lineno,
colno,
}) : null;
window.parent.postMessage({
type: 'horizons-runtime-error',
message,
error: errorDetails
}, '*');
};
`;
const configHorizonsConsoleErrroHandler = `
const originalConsoleError = console.error;
console.error = function(...args) {
originalConsoleError.apply(console, args);
let errorString = '';
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg instanceof Error) {
errorString = arg.stack || \`\${arg.name}: \${arg.message}\`;
break;
}
}
if (!errorString) {
errorString = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' ');
}
window.parent.postMessage({
type: 'horizons-console-error',
error: errorString
}, '*');
};
`;
const configWindowFetchMonkeyPatch = `
const originalFetch = window.fetch;
window.fetch = function(...args) {
const url = args[0] instanceof Request ? args[0].url : args[0];
// Skip WebSocket URLs
if (url.startsWith('ws:') || url.startsWith('wss:')) {
return originalFetch.apply(this, args);
}
return originalFetch.apply(this, args)
.then(async response => {
const contentType = response.headers.get('Content-Type') || '';
// Exclude HTML document responses
const isDocumentResponse =
contentType.includes('text/html') ||
contentType.includes('application/xhtml+xml');
if (!response.ok && !isDocumentResponse) {
const responseClone = response.clone();
const errorFromRes = await responseClone.text();
const requestUrl = response.url;
console.error(\`Fetch error from \${requestUrl}: \${errorFromRes}\`);
}
return response;
})
.catch(error => {
if (!url.match(/\.html?$/i)) {
console.error(error);
}
throw error;
});
};
`;
const configNavigationHandler = `
if (window.navigation && window.self !== window.top) {
window.navigation.addEventListener('navigate', (event) => {
const url = event.destination.url;
try {
const destinationUrl = new URL(url);
const destinationOrigin = destinationUrl.origin;
const currentOrigin = window.location.origin;
if (destinationOrigin === currentOrigin) {
return;
}
} catch (error) {
return;
}
window.parent.postMessage({
type: 'horizons-navigation-error',
url,
}, '*');
});
}
`;
const addTransformIndexHtml = {
name: 'add-transform-index-html',
transformIndexHtml(html) {
const tags = [
{
tag: 'script',
attrs: { type: 'module' },
children: configHorizonsRuntimeErrorHandler,
injectTo: 'head',
},
{
tag: 'script',
attrs: { type: 'module' },
children: configHorizonsViteErrorHandler,
injectTo: 'head',
},
{
tag: 'script',
attrs: {type: 'module'},
children: configHorizonsConsoleErrroHandler,
injectTo: 'head',
},
{
tag: 'script',
attrs: { type: 'module' },
children: configWindowFetchMonkeyPatch,
injectTo: 'head',
},
{
tag: 'script',
attrs: { type: 'module' },
children: configNavigationHandler,
injectTo: 'head',
},
];
if (!isDev && process.env.TEMPLATE_BANNER_SCRIPT_URL && process.env.TEMPLATE_REDIRECT_URL) {
tags.push(
{
tag: 'script',
attrs: {
src: process.env.TEMPLATE_BANNER_SCRIPT_URL,
'template-redirect-url': process.env.TEMPLATE_REDIRECT_URL,
},
injectTo: 'head',
}
);
}
return {
html,
tags,
};
},
};
console.warn = () => {};
const logger = createLogger()
const loggerError = logger.error
logger.error = (msg, options) => {
if (options?.error?.toString().includes('CssSyntaxError: [postcss]')) {
return;
}
loggerError(msg, options);
}
export default defineConfig({
customLogger: logger,
plugins: [
...(isDev ? [inlineEditPlugin(), editModeDevPlugin(), iframeRouteRestorationPlugin()] : []),
react(),
addTransformIndexHtml
],
server: {
cors: true,
headers: {
'Cross-Origin-Embedder-Policy': 'credentialless',
},
allowedHosts: true,
},
resolve: {
extensions: ['.jsx', '.js', '.tsx', '.ts', '.json', ],
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
rollupOptions: {
external: [
'@babel/parser',
'@babel/traverse',
'@babel/generator',
'@babel/types'
]
}
}
});