initial commit
This commit is contained in:
14
index.html
Normal file
14
index.html
Normal 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
jsconfig.json
Normal file
9
jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
10450
package-lock.json
generated
Normal file
10450
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
package.json
Normal file
51
package.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "web-app",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host :: --port 3000",
|
||||||
|
"build": "node tools/generate-llms.js || true && vite build",
|
||||||
|
"preview": "vite preview --host :: --port 3000"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/is-prop-valid": "^1.2.1",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-avatar": "^1.0.3",
|
||||||
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-progress": "^1.0.3",
|
||||||
|
"@radix-ui/react-radio-group": "^1.1.3",
|
||||||
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"@radix-ui/react-slider": "^1.1.2",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"@stripe/stripe-js": "^3.0.0",
|
||||||
|
"@supabase/supabase-js": "^2.39.8",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"framer-motion": "^10.16.4",
|
||||||
|
"lucide-react": "^0.292.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.16.0",
|
||||||
|
"tailwind-merge": "^1.14.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"terser": "^5.37.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.8.3",
|
||||||
|
"@types/react": "^18.2.15",
|
||||||
|
"@types/react-dom": "^18.2.7",
|
||||||
|
"@vitejs/plugin-react": "^4.0.3",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-react-app": "^7.0.1",
|
||||||
|
"postcss": "^8.4.31",
|
||||||
|
"tailwindcss": "^3.3.3",
|
||||||
|
"vite": "^4.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
430
plugins/selection-mode/selection-mode-script.js
Normal file
430
plugins/selection-mode/selection-mode-script.js
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
const ALLOWED_PARENT_ORIGINS = [
|
||||||
|
'https://horizons.hostinger.com',
|
||||||
|
'https://horizons.hostinger.dev',
|
||||||
|
'https://horizons-frontend-local.hostinger.dev',
|
||||||
|
'http://localhost:4000',
|
||||||
|
];
|
||||||
|
|
||||||
|
const IMPORTANT_STYLES = [
|
||||||
|
'display',
|
||||||
|
'position',
|
||||||
|
'flex-direction',
|
||||||
|
'justify-content',
|
||||||
|
'align-items',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'padding',
|
||||||
|
'margin',
|
||||||
|
'border',
|
||||||
|
'background-color',
|
||||||
|
'color',
|
||||||
|
'font-size',
|
||||||
|
'font-weight',
|
||||||
|
'font-family',
|
||||||
|
'border-radius',
|
||||||
|
'box-shadow',
|
||||||
|
'gap',
|
||||||
|
'grid-template-columns',
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRIMARY_400_COLOR = '#7B68EE';
|
||||||
|
const TEXT_CONTEXT_MAX_LENGTH = 500;
|
||||||
|
const DATA_SELECTION_MODE_ENABLED_ATTRIBUTE = 'data-selection-mode-enabled';
|
||||||
|
const MESSAGE_TYPE_ENABLE_SELECTION_MODE = 'enableSelectionMode';
|
||||||
|
const MESSAGE_TYPE_DISABLE_SELECTION_MODE = 'disableSelectionMode';
|
||||||
|
|
||||||
|
let selectionModeEnabled = false;
|
||||||
|
let currentHoverElement = null;
|
||||||
|
let overlayDiv = null;
|
||||||
|
let selectedOverlayDiv = null;
|
||||||
|
let selectedElement = null;
|
||||||
|
|
||||||
|
|
||||||
|
function injectStyles() {
|
||||||
|
if (document.getElementById('selection-mode-styles')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'selection-mode-styles';
|
||||||
|
style.textContent = `
|
||||||
|
#selection-mode-overlay {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px dashed ${PRIMARY_400_COLOR};
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 999999;
|
||||||
|
}
|
||||||
|
#selection-mode-selected-overlay {
|
||||||
|
position: absolute;
|
||||||
|
border: 3px solid ${PRIMARY_400_COLOR};
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 999998;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
console.warn('[SELECTION MODE] Invalid referrer URL:', document.referrer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract file path from React Fiber metadata (simplified - only for filePath)
|
||||||
|
* @param {*} node - DOM node
|
||||||
|
* @returns {string|null} - File path if found, null otherwise
|
||||||
|
*/
|
||||||
|
function getFilePathFromNode(node) {
|
||||||
|
const fiberKey = Object.keys(node).find(k => k.startsWith('__reactFiber'));
|
||||||
|
if (!fiberKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fiber = node[fiberKey];
|
||||||
|
if (!fiber) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traverse up the fiber tree to find source metadata
|
||||||
|
let currentFiber = fiber;
|
||||||
|
while (currentFiber) {
|
||||||
|
const source = currentFiber._debugSource
|
||||||
|
|| currentFiber.memoizedProps?.__source
|
||||||
|
|| currentFiber.pendingProps?.__source;
|
||||||
|
|
||||||
|
if (source?.fileName) {
|
||||||
|
return source.fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFiber = currentFiber.return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a CSS selector path to uniquely identify the element
|
||||||
|
* @param {*} element
|
||||||
|
* @returns {string} CSS selector path
|
||||||
|
*/
|
||||||
|
function getPathToElement(element) {
|
||||||
|
const path = [];
|
||||||
|
let current = element;
|
||||||
|
let depth = 0;
|
||||||
|
const maxDepth = 20; // Prevent infinite loops
|
||||||
|
|
||||||
|
while (current && current.nodeType === Node.ELEMENT_NODE && depth < maxDepth) {
|
||||||
|
let selector = current.nodeName.toLowerCase();
|
||||||
|
|
||||||
|
if (current.id) {
|
||||||
|
selector += `#${current.id}`;
|
||||||
|
path.unshift(selector);
|
||||||
|
break; // ID is unique, stop here
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.className && typeof current.className === 'string') {
|
||||||
|
const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0);
|
||||||
|
if (classes.length > 0) {
|
||||||
|
selector += `.${classes.join('.')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.parentElement) {
|
||||||
|
const siblings = Array.from(current.parentElement.children);
|
||||||
|
const sameTypeSiblings = siblings.filter(s => s.nodeName === current.nodeName);
|
||||||
|
if (sameTypeSiblings.length > 1) {
|
||||||
|
const index = sameTypeSiblings.indexOf(current) + 1;
|
||||||
|
selector += `:nth-of-type(${index})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path.unshift(selector);
|
||||||
|
current = current.parentElement;
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(' > ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getComputedStyles(element) {
|
||||||
|
const computedStyles = window.getComputedStyle(element);
|
||||||
|
|
||||||
|
return Object.fromEntries(IMPORTANT_STYLES.map((style) => {
|
||||||
|
const styleValue = computedStyles.getPropertyValue(style)?.trim();
|
||||||
|
|
||||||
|
return styleValue && styleValue !== 'none' && styleValue !== 'normal'
|
||||||
|
? [style, styleValue]
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDOMContext(element) {
|
||||||
|
if (!element) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textContent = element.textContent?.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
outerHTML: element.outerHTML,
|
||||||
|
selector: getPathToElement(element),
|
||||||
|
attributes: (element.attributes && element.attributes.length > 0)
|
||||||
|
? Object.fromEntries(Array.from(element.attributes).map((attr) => [attr.name, attr.value]))
|
||||||
|
: {},
|
||||||
|
computedStyles: getComputedStyles(element),
|
||||||
|
textContent: (textContent && textContent.length > 0 && textContent.length < TEXT_CONTEXT_MAX_LENGTH)
|
||||||
|
? element.textContent?.trim()
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOverlay() {
|
||||||
|
if (overlayDiv) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
injectStyles();
|
||||||
|
|
||||||
|
overlayDiv = document.createElement('div');
|
||||||
|
overlayDiv.id = 'selection-mode-overlay';
|
||||||
|
document.body.appendChild(overlayDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSelectedOverlay() {
|
||||||
|
if (selectedOverlayDiv) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
injectStyles();
|
||||||
|
|
||||||
|
selectedOverlayDiv = document.createElement('div');
|
||||||
|
selectedOverlayDiv.id = 'selection-mode-selected-overlay';
|
||||||
|
document.body.appendChild(selectedOverlayDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOverlay() {
|
||||||
|
if (overlayDiv && overlayDiv.parentNode) {
|
||||||
|
overlayDiv.parentNode.removeChild(overlayDiv);
|
||||||
|
overlayDiv = null;
|
||||||
|
}
|
||||||
|
if (selectedOverlayDiv && selectedOverlayDiv.parentNode) {
|
||||||
|
selectedOverlayDiv.parentNode.removeChild(selectedOverlayDiv);
|
||||||
|
selectedOverlayDiv = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showOverlay(element) {
|
||||||
|
if (!overlayDiv) {
|
||||||
|
createOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
overlayDiv.style.left = `${rect.left + window.scrollX}px`;
|
||||||
|
overlayDiv.style.top = `${rect.top + window.scrollY}px`;
|
||||||
|
overlayDiv.style.width = `${rect.width}px`;
|
||||||
|
overlayDiv.style.height = `${rect.height}px`;
|
||||||
|
overlayDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSelectedOverlay(element) {
|
||||||
|
if (!selectedOverlayDiv) {
|
||||||
|
createSelectedOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
selectedOverlayDiv.style.left = `${rect.left + window.scrollX}px`;
|
||||||
|
selectedOverlayDiv.style.top = `${rect.top + window.scrollY}px`;
|
||||||
|
selectedOverlayDiv.style.width = `${rect.width}px`;
|
||||||
|
selectedOverlayDiv.style.height = `${rect.height}px`;
|
||||||
|
selectedOverlayDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideOverlay() {
|
||||||
|
if (overlayDiv) {
|
||||||
|
overlayDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(event) {
|
||||||
|
if (!selectionModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = document.elementFromPoint(event.clientX, event.clientY);
|
||||||
|
if (!element) {
|
||||||
|
hideOverlay();
|
||||||
|
currentHoverElement = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element === overlayDiv || element === selectedOverlayDiv) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update if we're hovering a different element
|
||||||
|
if (currentHoverElement !== element) {
|
||||||
|
currentHoverElement = element;
|
||||||
|
|
||||||
|
// Show outline on the element
|
||||||
|
showOverlay(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(event) {
|
||||||
|
if (!selectionModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = event.touches[0];
|
||||||
|
if (!touch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||||
|
if (!element) {
|
||||||
|
currentHoverElement = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element === overlayDiv || element === selectedOverlayDiv) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentHoverElement = element;
|
||||||
|
|
||||||
|
showOverlay(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripFilePath(filePath) {
|
||||||
|
if (!filePath) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicHtmlIndex = filePath.indexOf('public_html/');
|
||||||
|
if (publicHtmlIndex !== -1) {
|
||||||
|
return filePath.substring(publicHtmlIndex + 'public_html/'.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(event) {
|
||||||
|
if (!selectionModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentHoverElement) {
|
||||||
|
const element = document.elementFromPoint(event.clientX, event.clientY);
|
||||||
|
|
||||||
|
if (!element || element === overlayDiv || element === selectedOverlayDiv) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentHoverElement = element;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
|
||||||
|
const domContext = extractDOMContext(currentHoverElement);
|
||||||
|
|
||||||
|
if (!domContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedElement = currentHoverElement;
|
||||||
|
if (selectedElement) {
|
||||||
|
showSelectedOverlay(selectedElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract file path from React Fiber (if available)
|
||||||
|
const filePath = getFilePathFromNode(currentHoverElement);
|
||||||
|
const strippedFilePath = filePath ? stripFilePath(filePath) : undefined;
|
||||||
|
|
||||||
|
// Send domContext and filePath to parent window
|
||||||
|
const parentOrigin = getParentOrigin();
|
||||||
|
if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
type: 'elementSelected',
|
||||||
|
payload: {
|
||||||
|
filePath: strippedFilePath,
|
||||||
|
domContext,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parentOrigin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
if (!selectionModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideOverlay();
|
||||||
|
currentHoverElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableSelectionMode() {
|
||||||
|
if (selectionModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionModeEnabled = true;
|
||||||
|
document.getElementById('root')?.setAttribute(DATA_SELECTION_MODE_ENABLED_ATTRIBUTE, 'true');
|
||||||
|
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
|
createOverlay();
|
||||||
|
document.addEventListener('mousemove', handleMouseMove, true);
|
||||||
|
document.addEventListener('touchstart', handleTouchStart, true);
|
||||||
|
document.addEventListener('click', handleClick, true);
|
||||||
|
document.addEventListener('mouseleave', handleMouseLeave, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableSelectionMode() {
|
||||||
|
if (!selectionModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionModeEnabled = false;
|
||||||
|
document.getElementById('root')?.removeAttribute(DATA_SELECTION_MODE_ENABLED_ATTRIBUTE);
|
||||||
|
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
|
||||||
|
hideOverlay();
|
||||||
|
removeOverlay();
|
||||||
|
currentHoverElement = null;
|
||||||
|
selectedElement = null;
|
||||||
|
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||||
|
document.removeEventListener('touchstart', handleTouchStart, true);
|
||||||
|
document.removeEventListener('click', handleClick, true);
|
||||||
|
document.removeEventListener('mouseleave', handleMouseLeave, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === MESSAGE_TYPE_ENABLE_SELECTION_MODE) {
|
||||||
|
enableSelectionMode();
|
||||||
|
}
|
||||||
|
if (event.data?.type === MESSAGE_TYPE_DISABLE_SELECTION_MODE) {
|
||||||
|
disableSelectionMode();
|
||||||
|
}
|
||||||
|
});
|
||||||
27
plugins/selection-mode/vite-plugin-selection-mode.js
Normal file
27
plugins/selection-mode/vite-plugin-selection-mode.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = resolve(__filename, '..');
|
||||||
|
|
||||||
|
export default function selectionModePlugin() {
|
||||||
|
return {
|
||||||
|
name: 'vite:selection-mode',
|
||||||
|
apply: 'serve',
|
||||||
|
|
||||||
|
transformIndexHtml() {
|
||||||
|
const scriptPath = resolve(__dirname, 'selection-mode-script.js');
|
||||||
|
const scriptContent = readFileSync(scriptPath, 'utf-8');
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'script',
|
||||||
|
attrs: { type: 'module' },
|
||||||
|
children: scriptContent,
|
||||||
|
injectTo: 'body',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
279
plugins/utils/ast-utils.js
Normal file
279
plugins/utils/ast-utils.js
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import generate from '@babel/generator';
|
||||||
|
import { parse } from '@babel/parser';
|
||||||
|
import traverseBabel from '@babel/traverse';
|
||||||
|
import {
|
||||||
|
isJSXIdentifier,
|
||||||
|
isJSXMemberExpression,
|
||||||
|
} from '@babel/types';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const VITE_PROJECT_ROOT = path.resolve(__dirname, '../..');
|
||||||
|
|
||||||
|
// Blacklist of components that should not be extracted (utility/non-visual components)
|
||||||
|
const COMPONENT_BLACKLIST = new Set([
|
||||||
|
'Helmet',
|
||||||
|
'HelmetProvider',
|
||||||
|
'Head',
|
||||||
|
'head',
|
||||||
|
'Meta',
|
||||||
|
'meta',
|
||||||
|
'Script',
|
||||||
|
'script',
|
||||||
|
'NoScript',
|
||||||
|
'noscript',
|
||||||
|
'Style',
|
||||||
|
'style',
|
||||||
|
'title',
|
||||||
|
'Title',
|
||||||
|
'link',
|
||||||
|
'Link',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a file path is safe to access
|
||||||
|
* @param {string} filePath - Relative file path
|
||||||
|
* @returns {{ isValid: boolean, absolutePath?: string, error?: string }} - Object containing validation result
|
||||||
|
*/
|
||||||
|
export function validateFilePath(filePath) {
|
||||||
|
if (!filePath) {
|
||||||
|
return { isValid: false, error: 'Missing filePath' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const absoluteFilePath = path.resolve(VITE_PROJECT_ROOT, filePath);
|
||||||
|
|
||||||
|
if (filePath.includes('..')
|
||||||
|
|| !absoluteFilePath.startsWith(VITE_PROJECT_ROOT)
|
||||||
|
|| absoluteFilePath.includes('node_modules')) {
|
||||||
|
return { isValid: false, error: 'Invalid path' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(absoluteFilePath)) {
|
||||||
|
return { isValid: false, error: 'File not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true, absolutePath: absoluteFilePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a file into a Babel AST
|
||||||
|
* @param {string} absoluteFilePath - Absolute path to file
|
||||||
|
* @returns {object} Babel AST
|
||||||
|
*/
|
||||||
|
export function parseFileToAST(absoluteFilePath) {
|
||||||
|
const content = fs.readFileSync(absoluteFilePath, 'utf-8');
|
||||||
|
|
||||||
|
return parse(content, {
|
||||||
|
sourceType: 'module',
|
||||||
|
plugins: ['jsx', 'typescript'],
|
||||||
|
errorRecovery: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a JSX opening element at a specific line and column
|
||||||
|
* @param {object} ast - Babel AST
|
||||||
|
* @param {number} line - Line number (1-indexed)
|
||||||
|
* @param {number} column - Column number (0-indexed for get-code-block, 1-indexed for apply-edit)
|
||||||
|
* @returns {object | null} Babel path to the JSX opening element
|
||||||
|
*/
|
||||||
|
export function findJSXElementAtPosition(ast, line, column) {
|
||||||
|
let targetNodePath = null;
|
||||||
|
let closestNodePath = null;
|
||||||
|
let closestDistance = Infinity;
|
||||||
|
const allNodesOnLine = [];
|
||||||
|
|
||||||
|
const visitor = {
|
||||||
|
JSXOpeningElement(path) {
|
||||||
|
const node = path.node;
|
||||||
|
if (node.loc) {
|
||||||
|
// Exact match (with tolerance for off-by-one column differences)
|
||||||
|
if (node.loc.start.line === line
|
||||||
|
&& Math.abs(node.loc.start.column - column) <= 1) {
|
||||||
|
targetNodePath = path;
|
||||||
|
path.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track all nodes on the same line
|
||||||
|
if (node.loc.start.line === line) {
|
||||||
|
allNodesOnLine.push({
|
||||||
|
path,
|
||||||
|
column: node.loc.start.column,
|
||||||
|
distance: Math.abs(node.loc.start.column - column),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track closest match on the same line for fallback
|
||||||
|
if (node.loc.start.line === line) {
|
||||||
|
const distance = Math.abs(node.loc.start.column - column);
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestDistance = distance;
|
||||||
|
closestNodePath = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Also check JSXElement nodes that contain the position
|
||||||
|
JSXElement(path) {
|
||||||
|
const node = path.node;
|
||||||
|
if (!node.loc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this element spans the target line (for multi-line elements)
|
||||||
|
if (node.loc.start.line > line || node.loc.end.line < line) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're inside this element's range, consider its opening element
|
||||||
|
if (!path.node.openingElement?.loc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openingLine = path.node.openingElement.loc.start.line;
|
||||||
|
const openingCol = path.node.openingElement.loc.start.column;
|
||||||
|
|
||||||
|
// Prefer elements that start on the exact line
|
||||||
|
if (openingLine === line) {
|
||||||
|
const distance = Math.abs(openingCol - column);
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestDistance = distance;
|
||||||
|
closestNodePath = path.get('openingElement');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle elements that start before the target line
|
||||||
|
if (openingLine < line) {
|
||||||
|
const distance = (line - openingLine) * 100; // Penalize by line distance
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestDistance = distance;
|
||||||
|
closestNodePath = path.get('openingElement');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
traverseBabel.default(ast, visitor);
|
||||||
|
|
||||||
|
// Return exact match if found, otherwise return closest match if within reasonable distance
|
||||||
|
// Use larger threshold (50 chars) for same-line elements, 5 lines for multi-line elements
|
||||||
|
const threshold = closestDistance < 100 ? 50 : 500;
|
||||||
|
return targetNodePath || (closestDistance <= threshold ? closestNodePath : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a JSX element name is blacklisted
|
||||||
|
* @param {object} jsxOpeningElement - Babel JSX opening element node
|
||||||
|
* @returns {boolean} True if blacklisted
|
||||||
|
*/
|
||||||
|
function isBlacklistedComponent(jsxOpeningElement) {
|
||||||
|
if (!jsxOpeningElement || !jsxOpeningElement.name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle JSXIdentifier (e.g., <Helmet>)
|
||||||
|
if (isJSXIdentifier(jsxOpeningElement.name)) {
|
||||||
|
return COMPONENT_BLACKLIST.has(jsxOpeningElement.name.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle JSXMemberExpression (e.g., <React.Fragment>)
|
||||||
|
if (isJSXMemberExpression(jsxOpeningElement.name)) {
|
||||||
|
let current = jsxOpeningElement.name;
|
||||||
|
while (isJSXMemberExpression(current)) {
|
||||||
|
current = current.property;
|
||||||
|
}
|
||||||
|
if (isJSXIdentifier(current)) {
|
||||||
|
return COMPONENT_BLACKLIST.has(current.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates code from an AST node
|
||||||
|
* @param {object} node - Babel AST node
|
||||||
|
* @param {object} options - Generator options
|
||||||
|
* @returns {string} Generated code
|
||||||
|
*/
|
||||||
|
export function generateCode(node, options = {}) {
|
||||||
|
const generateFunction = generate.default || generate;
|
||||||
|
const output = generateFunction(node, options);
|
||||||
|
return output.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a full source file from AST with source maps
|
||||||
|
* @param {object} ast - Babel AST
|
||||||
|
* @param {string} sourceFileName - Source file name for source map
|
||||||
|
* @param {string} originalCode - Original source code
|
||||||
|
* @returns {{code: string, map: object}} - Object containing generated code and source map
|
||||||
|
*/
|
||||||
|
export function generateSourceWithMap(ast, sourceFileName, originalCode) {
|
||||||
|
const generateFunction = generate.default || generate;
|
||||||
|
return generateFunction(ast, {
|
||||||
|
sourceMaps: true,
|
||||||
|
sourceFileName,
|
||||||
|
}, originalCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts code blocks from a JSX element at a specific location
|
||||||
|
* @param {string} filePath - Relative file path
|
||||||
|
* @param {number} line - Line number
|
||||||
|
* @param {number} column - Column number
|
||||||
|
* @param {object} [domContext] - Optional DOM context to return on failure
|
||||||
|
* @returns {{success: boolean, filePath?: string, specificLine?: string, error?: string, domContext?: object}} - Object with metadata for LLM
|
||||||
|
*/
|
||||||
|
export function extractCodeBlocks(filePath, line, column, domContext) {
|
||||||
|
try {
|
||||||
|
// Validate file path
|
||||||
|
const validation = validateFilePath(filePath);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
return { success: false, error: validation.error, domContext };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse AST
|
||||||
|
const ast = parseFileToAST(validation.absolutePath);
|
||||||
|
|
||||||
|
// Find target node
|
||||||
|
const targetNodePath = findJSXElementAtPosition(ast, line, column);
|
||||||
|
|
||||||
|
if (!targetNodePath) {
|
||||||
|
return { success: false, error: 'Target node not found at specified line/column', domContext };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the target node is a blacklisted component
|
||||||
|
const isBlacklisted = isBlacklistedComponent(targetNodePath.node);
|
||||||
|
|
||||||
|
if (isBlacklisted) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath,
|
||||||
|
specificLine: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get specific line code
|
||||||
|
const specificLine = generateCode(targetNodePath.parentPath?.node || targetNodePath.node);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath,
|
||||||
|
specificLine,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ast-utils] Error extracting code blocks:', error);
|
||||||
|
return { success: false, error: 'Failed to extract code blocks', domContext };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project root path
|
||||||
|
*/
|
||||||
|
export { VITE_PROJECT_ROOT };
|
||||||
356
plugins/visual-editor/edit-mode-script.js
Normal file
356
plugins/visual-editor/edit-mode-script.js
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't handle if selection mode is active
|
||||||
|
if (document.getElementById("root")?.getAttribute("data-selection-mode-enabled") === "true") {
|
||||||
|
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, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/{/g, "{")
|
||||||
|
.replace(/}/g, "}");
|
||||||
|
|
||||||
|
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() {
|
||||||
|
// Don't enable if selection mode is active
|
||||||
|
if (document.getElementById("root")?.getAttribute("data-selection-mode-enabled") === "true") {
|
||||||
|
console.warn("[EDIT MODE] Cannot enable edit mode while selection mode is active");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
137
plugins/visual-editor/visual-editor-config.js
Normal file
137
plugins/visual-editor/visual-editor-config.js
Normal 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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
32
plugins/visual-editor/vite-plugin-edit-mode.js
Normal file
32
plugins/visual-editor/vite-plugin-edit-mode.js
Normal 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'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
365
plugins/visual-editor/vite-plugin-react-inline-editor.js
Normal file
365
plugins/visual-editor/vite-plugin-react-inline-editor.js
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { parse } from '@babel/parser';
|
||||||
|
import traverseBabel from '@babel/traverse';
|
||||||
|
import * as t from '@babel/types';
|
||||||
|
import fs from 'fs';
|
||||||
|
import {
|
||||||
|
validateFilePath,
|
||||||
|
parseFileToAST,
|
||||||
|
findJSXElementAtPosition,
|
||||||
|
generateCode,
|
||||||
|
generateSourceWithMap,
|
||||||
|
VITE_PROJECT_ROOT
|
||||||
|
} from '../utils/ast-utils.js';
|
||||||
|
|
||||||
|
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 output = generateSourceWithMap(babelAst, 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;
|
||||||
|
|
||||||
|
// Validate file path
|
||||||
|
const validation = validateFilePath(filePath);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(JSON.stringify({ error: validation.error }));
|
||||||
|
}
|
||||||
|
absoluteFilePath = validation.absolutePath;
|
||||||
|
|
||||||
|
// Parse AST
|
||||||
|
const originalContent = fs.readFileSync(absoluteFilePath, 'utf-8');
|
||||||
|
const babelAst = parseFileToAST(absoluteFilePath);
|
||||||
|
|
||||||
|
// Find target node (note: apply-edit uses column+1)
|
||||||
|
const targetNodePath = findJSXElementAtPosition(babelAst, line, column + 1);
|
||||||
|
|
||||||
|
if (!targetNodePath) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(JSON.stringify({ error: 'Target node not found by line/column', editId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
beforeCode = generateCode(targetOpeningElement);
|
||||||
|
|
||||||
|
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;
|
||||||
|
afterCode = generateCode(targetOpeningElement);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (parentElementNode && t.isJSXElement(parentElementNode)) {
|
||||||
|
beforeCode = generateCode(parentElementNode);
|
||||||
|
|
||||||
|
parentElementNode.children = [];
|
||||||
|
if (newFullText && newFullText.trim() !== '') {
|
||||||
|
const newTextNode = t.jsxText(newFullText);
|
||||||
|
parentElementNode.children.push(newTextNode);
|
||||||
|
}
|
||||||
|
modified = true;
|
||||||
|
afterCode = generateCode(parentElementNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modified) {
|
||||||
|
res.writeHead(409, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(JSON.stringify({ error: 'Could not apply changes to AST.' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const webRelativeFilePath = path.relative(VITE_PROJECT_ROOT, absoluteFilePath).split(path.sep).join('/');
|
||||||
|
const output = generateSourceWithMap(babelAst, webRelativeFilePath, originalContent);
|
||||||
|
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.' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
125
plugins/vite-plugin-iframe-route-restoration.js
Normal file
125
plugins/vite-plugin-iframe-route-restoration.js
Normal 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
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
19
public/.htaccess
Normal file
19
public/.htaccess
Normal 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>
|
||||||
27
src/App.jsx
Normal file
27
src/App.jsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
|
import Layout from '@/components/Layout';
|
||||||
|
import HomePage from '@/pages/HomePage';
|
||||||
|
import OrderPage from '@/pages/OrderPage';
|
||||||
|
import ConfirmationPage from '@/pages/ConfirmationPage';
|
||||||
|
import CreationsPage from '@/pages/CreationsPage';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/commander" element={<OrderPage />} />
|
||||||
|
<Route path="/confirmation" element={<ConfirmationPage />} />
|
||||||
|
<Route path="/creations" element={<CreationsPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
<Toaster />
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
17
src/components/CallToAction.jsx
Normal file
17
src/components/CallToAction.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
23
src/components/Footer.jsx
Normal file
23
src/components/Footer.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Music2, Heart } from 'lucide-react';
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
return (
|
||||||
|
<footer className="bg-background/80 backdrop-blur-md shadow-t-lg py-8 text-center text-muted-foreground">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex justify-center items-center mb-4">
|
||||||
|
<Music2 className="h-6 w-6 text-primary mr-2" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Dites le en chanson © {currentYear}. Créé avec <Heart className="inline h-4 w-4 text-accent" /> par vous !
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs">
|
||||||
|
Transformez vos histoires en mélodies inoubliables.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
31
src/components/HeroImage.jsx
Normal file
31
src/components/HeroImage.jsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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;
|
||||||
108
src/components/ImageCarousel.jsx
Normal file
108
src/components/ImageCarousel.jsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-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" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
enter: (direction) => {
|
||||||
|
return {
|
||||||
|
x: direction > 0 ? 1000 : -1000,
|
||||||
|
opacity: 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
center: {
|
||||||
|
zIndex: 1,
|
||||||
|
x: 0,
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
exit: (direction) => {
|
||||||
|
return {
|
||||||
|
zIndex: 0,
|
||||||
|
x: direction < 0 ? 1000 : -1000,
|
||||||
|
opacity: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const swipeConfidenceThreshold = 10000;
|
||||||
|
const swipePower = (offset, velocity) => {
|
||||||
|
return Math.abs(offset) * velocity;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImageCarousel = () => {
|
||||||
|
const [[page, direction], setPage] = useState([0, 0]);
|
||||||
|
const imageIndex = ((page % carouselImages.length) + carouselImages.length) % carouselImages.length;
|
||||||
|
|
||||||
|
const paginate = (newDirection) => {
|
||||||
|
setPage([page + newDirection, newDirection]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
paginate(1);
|
||||||
|
}, 5000); // Change image every 5 seconds
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full max-w-4xl mx-auto h-72 md:h-96 overflow-hidden rounded-xl shadow-2xl">
|
||||||
|
<AnimatePresence initial={false} custom={direction}>
|
||||||
|
<motion.img
|
||||||
|
key={page}
|
||||||
|
src={carouselImages[imageIndex].src}
|
||||||
|
alt={carouselImages[imageIndex].alt}
|
||||||
|
custom={direction}
|
||||||
|
variants={variants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={{
|
||||||
|
x: { type: "spring", stiffness: 300, damping: 30 },
|
||||||
|
opacity: { duration: 0.2 }
|
||||||
|
}}
|
||||||
|
drag="x"
|
||||||
|
dragConstraints={{ left: 0, right: 0 }}
|
||||||
|
dragElastic={1}
|
||||||
|
onDragEnd={(e, { offset, velocity }) => {
|
||||||
|
const swipe = swipePower(offset.x, velocity.x);
|
||||||
|
if (swipe < -swipeConfidenceThreshold) {
|
||||||
|
paginate(1);
|
||||||
|
} else if (swipe > swipeConfidenceThreshold) {
|
||||||
|
paginate(-1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</AnimatePresence>
|
||||||
|
<div className="absolute top-1/2 left-2 transform -translate-y-1/2 z-10">
|
||||||
|
<Button variant="outline" size="icon" onClick={() => paginate(-1)} className="rounded-full bg-black/30 hover:bg-black/50 text-white border-none">
|
||||||
|
<ChevronLeft className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-1/2 right-2 transform -translate-y-1/2 z-10">
|
||||||
|
<Button variant="outline" size="icon" onClick={() => paginate(1)} className="rounded-full bg-black/30 hover:bg-black/50 text-white border-none">
|
||||||
|
<ChevronRight className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex space-x-2 z-10">
|
||||||
|
{carouselImages.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setPage([i, i > imageIndex ? 1 : -1])}
|
||||||
|
className={`w-3 h-3 rounded-full ${i === imageIndex ? 'bg-primary' : 'bg-white/50 hover:bg-white/80'} transition-colors`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageCarousel;
|
||||||
17
src/components/Layout.jsx
Normal file
17
src/components/Layout.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Navbar from '@/components/Navbar';
|
||||||
|
import Footer from '@/components/Footer';
|
||||||
|
|
||||||
|
const Layout = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background to-secondary/30 dark:from-background dark:to-secondary/10">
|
||||||
|
<Navbar />
|
||||||
|
<main className="flex-grow container mx-auto px-4 py-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
86
src/components/Navbar.jsx
Normal file
86
src/components/Navbar.jsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Music2, Menu, X, Sparkles } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
const Navbar = () => {
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const toggleMobileMenu = () => {
|
||||||
|
setMobileMenuOpen(!mobileMenuOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ to: "/", text: "Accueil" },
|
||||||
|
{ to: "/creations", text: "Créations" },
|
||||||
|
{ to: "/commander", text: "Commander une chanson", primary: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mobileMenuVariants = {
|
||||||
|
hidden: { opacity: 0, y: -20 },
|
||||||
|
visible: { opacity: 1, y: 0, transition: { duration: 0.3 } },
|
||||||
|
exit: { opacity: 0, y: -20, transition: { duration: 0.2 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="bg-background/80 backdrop-blur-md shadow-lg sticky top-0 z-50">
|
||||||
|
<div className="container mx-auto px-4 py-3 flex justify-between items-center">
|
||||||
|
<Link to="/" className="flex items-center space-x-2 text-2xl font-bold">
|
||||||
|
<Music2 className="h-8 w-8 text-primary" />
|
||||||
|
<span className="gradient-text">Dites le en chanson</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="hidden md:flex space-x-2 items-center">
|
||||||
|
<Button variant="ghost" asChild>
|
||||||
|
<Link to="/">Accueil</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" asChild>
|
||||||
|
<Link to="/creations" className="flex items-center">
|
||||||
|
<Sparkles className="h-4 w-4 mr-2 text-yellow-400" />
|
||||||
|
Créations
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild className="bg-gradient-to-r from-primary to-accent hover:opacity-90 transition-opacity">
|
||||||
|
<Link to="/commander">Commander une chanson</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:hidden">
|
||||||
|
<Button variant="ghost" onClick={toggleMobileMenu} size="icon">
|
||||||
|
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<motion.div
|
||||||
|
variants={mobileMenuVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
className="md:hidden absolute top-full left-0 right-0 bg-background/95 backdrop-blur-md shadow-lg pb-4 border-t border-border"
|
||||||
|
>
|
||||||
|
<div className="container mx-auto px-4 py-2 flex flex-col items-end space-y-2">
|
||||||
|
<Button variant="ghost" asChild className="w-full justify-end" onClick={toggleMobileMenu}>
|
||||||
|
<Link to="/">Accueil</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" asChild className="w-full justify-end" onClick={toggleMobileMenu}>
|
||||||
|
<Link to="/creations" className="flex items-center justify-end w-full">
|
||||||
|
<Sparkles className="h-4 w-4 mr-2 text-yellow-400" />
|
||||||
|
Créations
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild className="w-full justify-end bg-gradient-to-r from-primary to-accent hover:opacity-90 transition-opacity" onClick={toggleMobileMenu}>
|
||||||
|
<Link to="/commander">Commander une chanson</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
17
src/components/WelcomeMessage.jsx
Normal file
17
src/components/WelcomeMessage.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
const WelcomeMessage = () => {
|
||||||
|
return (
|
||||||
|
<motion.p
|
||||||
|
className='text-sm text-white leading-5 w-full'
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.8 }}
|
||||||
|
>
|
||||||
|
Write in the chat what you want to create.
|
||||||
|
</motion.p>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WelcomeMessage;
|
||||||
77
src/components/order/Step0ProductSelection.jsx
Normal file
77
src/components/order/Step0ProductSelection.jsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { products } from '@/config/orderFormConfig';
|
||||||
|
import { CheckCircle, Tag } from 'lucide-react';
|
||||||
|
|
||||||
|
const Step0ProductSelection = ({ selectedProduct, onProductSelect, error }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key="productSelection"
|
||||||
|
initial={{ opacity: 0, x: 50 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -50 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-semibold text-center gradient-text">Choisissez votre création</h2>
|
||||||
|
{error && <p className="text-sm text-center text-destructive">{error}</p>}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{products.map((product) => (
|
||||||
|
<motion.div
|
||||||
|
key={product.id}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
onClick={() => onProductSelect(product.id)}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer transition-all duration-300 ease-in-out h-full flex flex-col overflow-hidden',
|
||||||
|
selectedProduct === product.id
|
||||||
|
? 'ring-2 ring-primary shadow-primary/50 scale-105 border-primary'
|
||||||
|
: 'hover:shadow-lg',
|
||||||
|
error && !selectedProduct ? 'ring-2 ring-destructive border-destructive' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardHeader className="relative p-0">
|
||||||
|
<img
|
||||||
|
alt={product.name}
|
||||||
|
src={product.imageUrl || "https://images.unsplash.com/photo-1595872018818-97555653a011"}
|
||||||
|
className="w-full h-48 object-cover"
|
||||||
|
/>
|
||||||
|
{selectedProduct === product.id && (
|
||||||
|
<div className="absolute top-2 right-2 bg-primary rounded-full p-1 z-10">
|
||||||
|
<CheckCircle className="h-6 w-6 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{product.promotionPrice && (
|
||||||
|
<div className="absolute top-2 left-2 bg-accent text-accent-foreground px-2 py-1 rounded-md text-xs font-semibold flex items-center z-10">
|
||||||
|
<Tag className="h-3 w-3 mr-1" /> PROMO
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 flex-grow flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl mb-2 !bg-none !text-foreground">{product.name}</CardTitle>
|
||||||
|
<CardDescription className="text-sm mb-3">{product.description}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto">
|
||||||
|
{product.promotionPrice ? (
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<p className="text-2xl font-bold text-primary">{product.promotionPrice.toFixed(2)} €</p>
|
||||||
|
<p className="text-sm line-through text-muted-foreground">{product.price.toFixed(2)} €</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-2xl font-bold text-primary">{product.price.toFixed(2)} €</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Step0ProductSelection;
|
||||||
62
src/components/order/Step1Content.jsx
Normal file
62
src/components/order/Step1Content.jsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { languages } from '@/config/orderFormConfig';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Step1Content = ({ formData, handleChange, handleSelectChange, errors }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key="songInfo"
|
||||||
|
initial={{ opacity: 0, x: 50 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -50 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="recipientName" className={cn("text-lg", errors.recipientName && "text-destructive")}>Prénom ou surnom de la personne à qui vous offrez la chanson*</Label>
|
||||||
|
<Input id="recipientName" name="recipientName" value={formData.recipientName} onChange={handleChange} placeholder="Ex: Chloé, Mon amour, Papi..." className={cn("mt-2 text-base p-3", errors.recipientName && "border-destructive focus-visible:ring-destructive")}/>
|
||||||
|
{errors.recipientName && <p className="text-sm text-destructive mt-1">{errors.recipientName}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="songForWhom" className={cn("text-lg", errors.songForWhom && "text-destructive")}>C'est pour qui ?*</Label>
|
||||||
|
<Input id="songForWhom" name="songForWhom" value={formData.songForWhom} onChange={handleChange} placeholder="Ex: Mon/ma partenaire, un(e) ami(e), ma famille..." className={cn("mt-2 text-base p-3", errors.songForWhom && "border-destructive focus-visible:ring-destructive")}/>
|
||||||
|
{errors.songForWhom && <p className="text-sm text-destructive mt-1">{errors.songForWhom}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="occasion" className="text-lg">Occasion (si applicable)</Label>
|
||||||
|
<Input id="occasion" name="occasion" value={formData.occasion} onChange={handleChange} placeholder="Ex: Anniversaire, mariage, déclaration..." className="mt-2 text-base p-3"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="language" className={cn("text-lg", errors.language && "text-destructive")}>Langue de la chanson*</Label>
|
||||||
|
<Select name="language" onValueChange={(value) => handleSelectChange('language', value)} value={formData.language}>
|
||||||
|
<SelectTrigger className={cn("w-full mt-2 text-base p-3 h-auto", errors.language && "border-destructive focus:ring-destructive")}>
|
||||||
|
<SelectValue placeholder="Sélectionnez une langue" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{languages.map(lang => <SelectItem key={lang} value={lang}>{lang}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.language && <p className="text-sm text-destructive mt-1">{errors.language}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="anecdote1" className={cn("text-lg", errors.anecdote1 && "text-destructive")}>Anecdote 1 - Donnez nous quelques infos pour adapter le texte !*</Label>
|
||||||
|
<Textarea id="anecdote1" name="anecdote1" value={formData.anecdote1} onChange={handleChange} placeholder="Un souvenir marquant, une qualité spéciale, un rêve partagé..." rows={3} className={cn("mt-2 text-base p-3", errors.anecdote1 && "border-destructive focus-visible:ring-destructive")}/>
|
||||||
|
{errors.anecdote1 && <p className="text-sm text-destructive mt-1">{errors.anecdote1}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="anecdote2" className="text-lg">Anecdote 2 - Plus d'infos pour enrichir le texte !</Label>
|
||||||
|
<Textarea id="anecdote2" name="anecdote2" value={formData.anecdote2} onChange={handleChange} placeholder="Un détail amusant, une passion, un lieu important..." rows={3} className="mt-2 text-base p-3"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="anecdote3" className="text-lg">Anecdote 3 - Encore une info pour une chanson parfaite !</Label>
|
||||||
|
<Textarea id="anecdote3" name="anecdote3" value={formData.anecdote3} onChange={handleChange} placeholder="Un message secret, une private joke, un souhait..." rows={3} className="mt-2 text-base p-3"/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Step1Content;
|
||||||
65
src/components/order/Step2MusicalChoices.jsx
Normal file
65
src/components/order/Step2MusicalChoices.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { voiceGenders, musicalStyles, moods } from '@/config/orderFormConfig';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Step2MusicalChoices = ({ formData, handleSelectChange, handleRadioGroupChange, errors }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key="musicalChoices"
|
||||||
|
initial={{ opacity: 0, x: 50 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -50 }}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Label className={cn("text-lg mb-2 block", errors.voiceGender && "text-destructive")}>Genre de la voix*</Label>
|
||||||
|
<RadioGroup
|
||||||
|
name="voiceGender"
|
||||||
|
value={formData.voiceGender}
|
||||||
|
onValueChange={(value) => handleRadioGroupChange('voiceGender', value)}
|
||||||
|
className={cn("flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-4", errors.voiceGender && "rounded-md border border-destructive p-2")}
|
||||||
|
>
|
||||||
|
{voiceGenders.map((gender) => (
|
||||||
|
<div key={gender} className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value={gender} id={`voice-${gender.toLowerCase().replace(' ', '-')}`} />
|
||||||
|
<Label htmlFor={`voice-${gender.toLowerCase().replace(' ', '-')}`} className="text-base font-normal">{gender}</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
{errors.voiceGender && <p className="text-sm text-destructive mt-1">{errors.voiceGender}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="musicalStyle" className={cn("text-lg", errors.musicalStyle && "text-destructive")}>Style musical*</Label>
|
||||||
|
<Select name="musicalStyle" onValueChange={(value) => handleSelectChange('musicalStyle', value)} value={formData.musicalStyle}>
|
||||||
|
<SelectTrigger className={cn("w-full mt-2 text-base p-3 h-auto", errors.musicalStyle && "border-destructive focus:ring-destructive")}>
|
||||||
|
<SelectValue placeholder="Sélectionnez un style" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{musicalStyles.map(style => <SelectItem key={style} value={style}>{style}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.musicalStyle && <p className="text-sm text-destructive mt-1">{errors.musicalStyle}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="mood" className={cn("text-lg", errors.mood && "text-destructive")}>Ambiance*</Label>
|
||||||
|
<Select name="mood" onValueChange={(value) => handleSelectChange('mood', value)} value={formData.mood}>
|
||||||
|
<SelectTrigger className={cn("w-full mt-2 text-base p-3 h-auto", errors.mood && "border-destructive focus:ring-destructive")}>
|
||||||
|
<SelectValue placeholder="Sélectionnez une ambiance" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{moods.map(mood => <SelectItem key={mood} value={mood}>{mood}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.mood && <p className="text-sm text-destructive mt-1">{errors.mood}</p>}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Step2MusicalChoices;
|
||||||
70
src/components/order/Step3Review.jsx
Normal file
70
src/components/order/Step3Review.jsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { products } from '@/config/orderFormConfig';
|
||||||
|
import { CreditCard } from 'lucide-react';
|
||||||
|
|
||||||
|
const Step3Review = ({ formData }) => {
|
||||||
|
const selectedProductDetails = products.find(p => p.id === formData.selectedProduct);
|
||||||
|
const priceToDisplay = selectedProductDetails ? (selectedProductDetails.promotionPrice || selectedProductDetails.price) : 0;
|
||||||
|
|
||||||
|
const formatKey = (key) => {
|
||||||
|
const translations = {
|
||||||
|
selectedProduct: "Produit Sélectionné",
|
||||||
|
recipientName: "Prénom/Surnom du destinataire",
|
||||||
|
songForWhom: "Pour qui",
|
||||||
|
occasion: "Occasion",
|
||||||
|
language: "Langue",
|
||||||
|
anecdote1: "Anecdote 1",
|
||||||
|
anecdote2: "Anecdote 2",
|
||||||
|
anecdote3: "Anecdote 3",
|
||||||
|
voiceGender: "Genre de la voix",
|
||||||
|
musicalStyle: "Style musical",
|
||||||
|
mood: "Ambiance",
|
||||||
|
};
|
||||||
|
return translations[key] || key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayValue = (key, value) => {
|
||||||
|
if (key === 'selectedProduct') {
|
||||||
|
return selectedProductDetails?.name || 'Non sélectionné';
|
||||||
|
}
|
||||||
|
if (!value || (Array.isArray(value) && value.length === 0)) return 'Non spécifié';
|
||||||
|
return Array.isArray(value) ? value.join(', ') : value.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key="review"
|
||||||
|
initial={{ opacity: 0, x: 50 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -50 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<h3 className="text-2xl font-semibold gradient-text">Vérifiez votre commande</h3>
|
||||||
|
<div className="space-y-3 text-sm p-4 border rounded-lg bg-secondary/50 dark:bg-secondary/20">
|
||||||
|
{Object.entries(formData).map(([key, value]) => {
|
||||||
|
if (key === 'price' || key === 'productName' || key === 'stripePriceId') return null;
|
||||||
|
if ((!value && key !== 'selectedProduct') && !(Array.isArray(value) && value.length === 0)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex justify-between items-start">
|
||||||
|
<span className="font-medium text-muted-foreground">{formatKey(key)}:</span>
|
||||||
|
<span className="text-right max-w-[60%] break-words pl-2">{displayValue(key, value)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-center">Prix Total : {priceToDisplay.toFixed(2)} €</p>
|
||||||
|
<div className="text-sm text-muted-foreground p-4 border border-dashed border-primary/50 rounded-lg bg-primary/10 dark:bg-primary/5">
|
||||||
|
<div className="flex items-center font-semibold text-primary mb-2">
|
||||||
|
<CreditCard className="h-5 w-5 mr-2" />
|
||||||
|
Paiement Sécurisé
|
||||||
|
</div>
|
||||||
|
<p>Nous utilisons Stripe pour un paiement sécurisé. Vous serez redirigé vers leur plateforme pour finaliser votre commande. Après le paiement de votre commande, vous recevrez un email de confirmation.</p>
|
||||||
|
<p className="mt-2">Après validation de votre commande, vous recevrez un lien par email pour télécharger votre création.</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Step3Review;
|
||||||
47
src/components/ui/button.jsx
Normal file
47
src/components/ui/button.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
60
src/components/ui/card.jsx
Normal file
60
src/components/ui/card.jsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border bg-card text-card-foreground shadow-lg hover:shadow-xl transition-shadow duration-300 ease-in-out',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-2xl font-semibold leading-none tracking-tight gradient-text',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-6 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
19
src/components/ui/input.jsx
Normal file
19
src/components/ui/input.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
19
src/components/ui/label.jsx
Normal file
19
src/components/ui/label.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
22
src/components/ui/progress.jsx
Normal file
22
src/components/ui/progress.jsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full w-full flex-1 bg-gradient-to-r from-primary to-accent transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
));
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
36
src/components/ui/radio-group.jsx
Normal file
36
src/components/ui/radio-group.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<Check className="h-3.5 w-3.5 fill-current text-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
126
src/components/ui/select.jsx
Normal file
126
src/components/ui/select.jsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
||||||
18
src/components/ui/textarea.jsx
Normal file
18
src/components/ui/textarea.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
101
src/components/ui/toast.jsx
Normal file
101
src/components/ui/toast.jsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
'data-[swipe=move]:transition-none group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-background border',
|
||||||
|
destructive:
|
||||||
|
'group destructive border-destructive bg-destructive text-destructive-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-destructive/30 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
));
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm opacity-90', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Toast,
|
||||||
|
ToastAction,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
};
|
||||||
34
src/components/ui/toaster.jsx
Normal file
34
src/components/ui/toaster.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from '@/components/ui/toast';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(({ id, title, description, action, ...props }) => {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/components/ui/use-toast.js
Normal file
103
src/components/ui/use-toast.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState, useEffect } from "react"
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
function generateId() {
|
||||||
|
count = (count + 1) % Number.MAX_VALUE
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastStore = {
|
||||||
|
state: {
|
||||||
|
toasts: [],
|
||||||
|
},
|
||||||
|
listeners: [],
|
||||||
|
|
||||||
|
getState: () => toastStore.state,
|
||||||
|
|
||||||
|
setState: (nextState) => {
|
||||||
|
if (typeof nextState === 'function') {
|
||||||
|
toastStore.state = nextState(toastStore.state)
|
||||||
|
} else {
|
||||||
|
toastStore.state = { ...toastStore.state, ...nextState }
|
||||||
|
}
|
||||||
|
|
||||||
|
toastStore.listeners.forEach(listener => listener(toastStore.state))
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe: (listener) => {
|
||||||
|
toastStore.listeners.push(listener)
|
||||||
|
return () => {
|
||||||
|
toastStore.listeners = toastStore.listeners.filter(l => l !== listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toast = ({ ...props }) => {
|
||||||
|
const id = generateId()
|
||||||
|
|
||||||
|
const update = (props) =>
|
||||||
|
toastStore.setState((state) => ({
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === id ? { ...t, ...props } : t
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const dismiss = () => toastStore.setState((state) => ({
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
toastStore.setState((state) => ({
|
||||||
|
...state,
|
||||||
|
toasts: [
|
||||||
|
{ ...props, id, dismiss },
|
||||||
|
...state.toasts,
|
||||||
|
].slice(0, TOAST_LIMIT),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const [state, setState] = useState(toastStore.getState())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = toastStore.subscribe((state) => {
|
||||||
|
setState(state)
|
||||||
|
})
|
||||||
|
|
||||||
|
return unsubscribe
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeouts = []
|
||||||
|
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
if (toast.duration === Infinity) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toast.dismiss()
|
||||||
|
}, toast.duration || 5000)
|
||||||
|
|
||||||
|
timeouts.push(timeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
timeouts.forEach((timeout) => clearTimeout(timeout))
|
||||||
|
}
|
||||||
|
}, [state.toasts])
|
||||||
|
|
||||||
|
return {
|
||||||
|
toast,
|
||||||
|
toasts: state.toasts,
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/config/orderFormConfig.jsx
Normal file
86
src/config/orderFormConfig.jsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ShoppingBag, Edit2, Music, Send } from 'lucide-react';
|
||||||
|
|
||||||
|
export const STRIPE_PUBLISHABLE_KEY = 'pk_live_51RPSGmEPL3QASpovp8Q6p8ehNMW7TzSrOaV6zvPE1OtflMFN5jChQBEj5kr84wontlLOe8uiHyJBiCduzxIZwj5A00DIEVs31n';
|
||||||
|
|
||||||
|
export const products = [
|
||||||
|
{
|
||||||
|
id: 'prod_SQK3M9UvfXBUh0',
|
||||||
|
name: 'Chanson personnalisée',
|
||||||
|
price: 19.90,
|
||||||
|
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'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prod_SQK4s1RR0xK3oe',
|
||||||
|
name: '2 chansons personnalisées',
|
||||||
|
price: 29.90,
|
||||||
|
promotionPrice: null,
|
||||||
|
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'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prod_SQKBpI2jc1hHOQ',
|
||||||
|
name: '1 chanson personnalisée + 1 clip photo-musique',
|
||||||
|
price: 49.90,
|
||||||
|
promotionPrice: 44.90,
|
||||||
|
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'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prod_SQKCtsEPlwf329',
|
||||||
|
name: 'Texte personnalisé',
|
||||||
|
price: 7.90,
|
||||||
|
promotionPrice: null,
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const initialFormData = {
|
||||||
|
selectedProduct: null,
|
||||||
|
recipientName: '',
|
||||||
|
songForWhom: '',
|
||||||
|
occasion: '',
|
||||||
|
language: 'Français',
|
||||||
|
anecdote1: '',
|
||||||
|
anecdote2: '',
|
||||||
|
anecdote3: '',
|
||||||
|
voiceGender: '',
|
||||||
|
musicalStyle: '',
|
||||||
|
mood: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stepsConfig = [
|
||||||
|
{ id: 'productSelection', title: 'Choix du Produit', icon: <ShoppingBag className="h-5 w-5 mr-2" /> },
|
||||||
|
{ id: 'songInfo', title: 'Informations pour la chanson', icon: <Edit2 className="h-5 w-5 mr-2" /> },
|
||||||
|
{ 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"];
|
||||||
|
export const moods = ["Heureux", "Romantique", "Comique", "Sincère", "Triste"];
|
||||||
|
|
||||||
|
export const formValidations = {
|
||||||
|
productSelection: [
|
||||||
|
{ name: 'selectedProduct', message: "Veuillez sélectionner un produit." },
|
||||||
|
],
|
||||||
|
songInfo: [
|
||||||
|
{ name: 'recipientName', message: "Veuillez indiquer le prénom ou surnom." },
|
||||||
|
{ name: 'songForWhom', message: "Veuillez préciser pour qui est la chanson." },
|
||||||
|
{ name: 'anecdote1', message: "L'anecdote 1 est obligatoire." },
|
||||||
|
{ name: 'language', message: "Veuillez sélectionner une langue." },
|
||||||
|
],
|
||||||
|
musicalChoices: [
|
||||||
|
{ name: 'voiceGender', message: "Veuillez choisir un genre de voix." },
|
||||||
|
{ name: 'musicalStyle', message: "Veuillez choisir un style musical." },
|
||||||
|
{ name: 'mood', message: "Veuillez choisir une ambiance." },
|
||||||
|
],
|
||||||
|
};
|
||||||
62
src/index.css
Normal file
62
src/index.css
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 224 71.4% 4.1%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 224 71.4% 4.1%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 224 71.4% 4.1%;
|
||||||
|
--primary: 262.1 83.3% 57.8%; /* Vibrant Purple */
|
||||||
|
--primary-foreground: 355.7 100% 97.3%;
|
||||||
|
--secondary: 210 40% 96.1%; /* Light Blue-Gray */
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 340.1 92.3% 68.4%; /* Bright Pink */
|
||||||
|
--accent-foreground: 355.7 100% 97.3%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 215 20.2% 65.1%;
|
||||||
|
--input: 215 20.2% 65.1%;
|
||||||
|
--ring: 262.1 83.3% 57.8%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 224 71.4% 4.1%;
|
||||||
|
--foreground: 210 20% 98%;
|
||||||
|
--card: 224 71.4% 4.1%;
|
||||||
|
--card-foreground: 210 20% 98%;
|
||||||
|
--popover: 224 71.4% 4.1%;
|
||||||
|
--popover-foreground: 210 20% 98%;
|
||||||
|
--primary: 262.1 83.3% 57.8%;
|
||||||
|
--primary-foreground: 355.7 100% 97.3%;
|
||||||
|
--secondary: 222.2 47.4% 11.2%;
|
||||||
|
--secondary-foreground: 210 20% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 217.9 10.6% 64.9%;
|
||||||
|
--accent: 340.1 92.3% 68.4%;
|
||||||
|
--accent-foreground: 355.7 100% 97.3%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 85.7% 97.3%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 262.1 83.3% 57.8%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-accent;
|
||||||
|
}
|
||||||
13
src/lib/customSupabaseClient.js
Normal file
13
src/lib/customSupabaseClient.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
6
src/lib/supabaseClient.js
Normal file
6
src/lib/supabaseClient.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
const supabaseUrl = 'https://zneurmyahctnnzglnptl.supabase.co';
|
||||||
|
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InpuZXVybXlhaGN0bm56Z2xucHRsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDg4NTM5MTksImV4cCI6MjA2NDQyOTkxOX0.wK5iWQt3pXNNpi9xquCaowvQrz8k8qjlcwFMsehqnWI';
|
||||||
|
|
||||||
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||||
6
src/lib/utils.js
Normal file
6
src/lib/utils.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
10
src/main.jsx
Normal file
10
src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from '@/App';
|
||||||
|
import '@/index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
236
src/pages/ConfirmationPage.jsx
Normal file
236
src/pages/ConfirmationPage.jsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { CheckCircle, Package, Loader2, AlertTriangle, Info } from 'lucide-react';
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { supabase } from '@/lib/supabaseClient';
|
||||||
|
|
||||||
|
const ConfirmationPage = () => {
|
||||||
|
const [orderDetails, setOrderDetails] = useState(null);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const sessionId = searchParams.get('session_id');
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [mainStatus, setMainStatus] = useState({ type: 'info', message: 'Traitement en cours...' });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const processOrder = async () => {
|
||||||
|
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 storedOrderDataString = localStorage.getItem('confirmedOrderDataForConfirmationPage');
|
||||||
|
if (!storedOrderDataString) {
|
||||||
|
setMainStatus({ type: 'error', message: "Impossible de récupérer les détails de la commande. Veuillez nous contacter." });
|
||||||
|
toast({ title: "Erreur", description: "Détails de commande non trouvés.", variant: "destructive" });
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedOrderData = JSON.parse(storedOrderDataString);
|
||||||
|
setOrderDetails(storedOrderData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = { orderData: { ...storedOrderData }, sessionId: sessionId };
|
||||||
|
const { data: functionResponse, error: functionError } = await supabase.functions.invoke('send-order-confirmation-email', {
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (functionError) {
|
||||||
|
console.error("Supabase function invocation error:", functionError);
|
||||||
|
throw new Error(functionError.message || "Erreur d'invocation de la fonction Edge.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!functionResponse) {
|
||||||
|
console.error("No response data from edge function");
|
||||||
|
throw new Error("Aucune donnée retournée par la fonction Edge.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (functionResponse.orderId && functionResponse.orderId !== 'N/A') {
|
||||||
|
setOrderDetails(prev => ({ ...prev, id: functionResponse.orderId, customer_email: functionResponse.customerEmail || prev.customer_email }));
|
||||||
|
toast({
|
||||||
|
title: "Paiement Réussi!",
|
||||||
|
description: `Votre commande (ID: ${functionResponse.orderId.substring(0,8)}) est enregistrée et un email de confirmation vous a été envoyé.`,
|
||||||
|
variant: "success",
|
||||||
|
duration: 7000,
|
||||||
|
});
|
||||||
|
setMainStatus({ type: 'success', message: `Commande ${functionResponse.orderId.substring(0,8)} confirmée.` });
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Problème Enregistrement Commande",
|
||||||
|
description: `La commande n'a pas pu être formellement enregistrée. ${functionResponse.error || ''}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
setMainStatus({ type: 'error', message: `Erreur enregistrement: ${functionResponse.error || 'Détail non disponible'}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (functionResponse.adminEmailError) {
|
||||||
|
toast({
|
||||||
|
title: "Alerte Email Admin (Info)",
|
||||||
|
description: `L'email admin n'a pas pu être envoyé: ${functionResponse.adminEmailError}`,
|
||||||
|
variant: "default",
|
||||||
|
duration: 4000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (functionResponse.customerEmailError && functionResponse.customerEmail) {
|
||||||
|
toast({
|
||||||
|
title: "Alerte Email Client",
|
||||||
|
description: `L'email de confirmation client n'a pas pu être envoyé: ${functionResponse.customerEmailError}. Veuillez vérifier vos spams ou nous contacter.`,
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 10000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Erreur critique lors du traitement de la commande:", e);
|
||||||
|
setMainStatus({ type: 'error', message: `Erreur critique: ${e.message}. La commande a pu être enregistrée ou non.` });
|
||||||
|
toast({
|
||||||
|
title: "Erreur Critique",
|
||||||
|
description: `Un problème est survenu: ${e.message}. Veuillez vérifier l'état de votre commande ou nous contacter.`,
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processOrder();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [sessionId, toast]);
|
||||||
|
|
||||||
|
const handleReturnHome = () => {
|
||||||
|
localStorage.removeItem('songOrderForm');
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatKey = (key) => {
|
||||||
|
const translations = {
|
||||||
|
id: "ID Commande",
|
||||||
|
selectedProduct: "Produit Sélectionné",
|
||||||
|
productName: "Nom du Produit",
|
||||||
|
price: "Prix Payé",
|
||||||
|
recipientName: "Prénom/Surnom du destinataire",
|
||||||
|
songForWhom: "Pour qui",
|
||||||
|
occasion: "Occasion",
|
||||||
|
language: "Langue",
|
||||||
|
anecdote1: "Anecdote 1",
|
||||||
|
anecdote2: "Anecdote 2",
|
||||||
|
anecdote3: "Anecdote 3",
|
||||||
|
voiceGender: "Genre de la voix",
|
||||||
|
musicalStyle: "Style musical",
|
||||||
|
mood: "Ambiance",
|
||||||
|
customer_email: "Email du Client",
|
||||||
|
};
|
||||||
|
return translations[key] || key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayValue = (key, value) => {
|
||||||
|
if (key === 'price' && typeof value === 'number') {
|
||||||
|
return `${Number(value).toFixed(2)} €`;
|
||||||
|
}
|
||||||
|
if (key === 'id' && typeof value === 'string') {
|
||||||
|
return value.substring(0,8);
|
||||||
|
}
|
||||||
|
if (!value && value !==0 || (Array.isArray(value) && value.length === 0)) return 'Non spécifié';
|
||||||
|
return Array.isArray(value) ? value.join(', ') : value.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
|
||||||
|
<Loader2 className="h-16 w-16 animate-spin text-primary mb-4" />
|
||||||
|
<p className="text-xl text-muted-foreground">Traitement de votre commande...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overallSuccess = mainStatus.type === 'success';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="max-w-2xl mx-auto py-12 px-4 text-center"
|
||||||
|
>
|
||||||
|
<Card className="shadow-xl bg-card/80 backdrop-blur-md">
|
||||||
|
<CardHeader>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 260, damping: 20, delay: 0.2 }}
|
||||||
|
className={`mx-auto w-fit p-4 rounded-full mb-4 ${overallSuccess ? 'bg-green-500/20' : 'bg-amber-500/20'}`}
|
||||||
|
>
|
||||||
|
{overallSuccess ? <CheckCircle className="h-16 w-16 text-green-500" /> : <AlertTriangle className="h-16 w-16 text-amber-500" />}
|
||||||
|
</motion.div>
|
||||||
|
<CardTitle className={`text-4xl !bg-clip-text !text-transparent ${overallSuccess ? '!bg-gradient-to-r !from-green-500 !to-emerald-500' : '!bg-gradient-to-r !from-amber-500 !to-orange-500'}`}>
|
||||||
|
{overallSuccess ? 'Commande Confirmée !' : 'Statut de la Commande'}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-lg text-muted-foreground mt-2">
|
||||||
|
{overallSuccess ? 'Merci pour votre confiance ! Votre création est en préparation.' : 'Un problème est survenu avec votre commande. Veuillez nous contacter.'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className={`p-3 rounded-md text-sm flex items-center ${mainStatus.type === 'success' ? 'bg-green-500/10 text-green-700' : mainStatus.type === 'error' ? 'bg-red-500/10 text-red-700' : 'bg-blue-500/10 text-blue-700'}`}>
|
||||||
|
{mainStatus.type === 'success' ? <CheckCircle className="h-5 w-5 mr-2 flex-shrink-0" /> : <AlertTriangle className="h-5 w-5 mr-2 flex-shrink-0" />}
|
||||||
|
<span>{mainStatus.message}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{orderDetails ? (
|
||||||
|
<div className="text-left p-4 sm:p-6 bg-secondary/50 dark:bg-secondary/20 rounded-lg">
|
||||||
|
<h3 className="text-xl font-semibold mb-3 text-primary flex items-center">
|
||||||
|
<Package className="h-6 w-6 mr-2" />
|
||||||
|
Récapitulatif de votre commande :
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{Object.entries(orderDetails).map(([key, value]) => {
|
||||||
|
if (key === 'selectedProduct' || key === 'stripePriceId') return null;
|
||||||
|
const displayVal = displayValue(key, value);
|
||||||
|
if (displayVal === 'Non spécifié' && key !== 'customer_email' && key !== 'id') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={key} className="flex justify-between items-start py-1 border-b border-border/50 last:border-b-0">
|
||||||
|
<strong className="pr-2 text-foreground/80">{formatKey(key)}:</strong>
|
||||||
|
<span className="text-right max-w-[60%] sm:max-w-[70%] break-words text-foreground">{displayVal}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!isLoading && <p className="text-muted-foreground">Aucun détail de commande à afficher.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-pink-500/10 rounded-lg border border-purple-300/50">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Info className="h-6 w-6 mr-3 text-purple-400 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Un email de confirmation de commande vous a été envoyé. En cas de difficulté, n'hésitez pas à contrôler vos courriers indésirables.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Votre chanson unique sera prête et vous sera livrée par email dans les <strong>48 heures</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleReturnHome} size="lg" className="mt-8 w-full sm:w-auto bg-gradient-to-r from-primary to-accent hover:opacity-90 transition-opacity">
|
||||||
|
Retour à l'accueil
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmationPage;
|
||||||
126
src/pages/CreationsPage.jsx
Normal file
126
src/pages/CreationsPage.jsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Music, Video, Image as ImageIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
icon: <Video className="h-8 w-8 text-primary" />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const CreationsPage = () => {
|
||||||
|
const cardVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: (i) => ({
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
delay: i * 0.1,
|
||||||
|
duration: 0.5,
|
||||||
|
ease: "easeOut"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-12">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="text-center mb-12"
|
||||||
|
>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight mb-4">
|
||||||
|
<span className="gradient-text">Nos Créations</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
Découvrez quelques exemples de chansons et visuels que nous pouvons réaliser pour vous. Chaque création est unique, comme votre histoire.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{creations.map((creation, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
custom={index}
|
||||||
|
variants={cardVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
whileHover={{ y: -5, boxShadow: "0px 10px 20px rgba(0,0,0,0.1)" }}
|
||||||
|
>
|
||||||
|
<Card className="overflow-hidden h-full flex flex-col bg-card/80 backdrop-blur-sm border-border/50 shadow-lg hover:shadow-primary/20 transition-shadow duration-300">
|
||||||
|
<CardHeader className="flex flex-row items-start gap-4 p-4 bg-muted/30">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
|
{creation.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl font-semibold text-foreground">{creation.title}</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-muted-foreground">{creation.type === 'image' ? 'Image' : creation.type === 'audio' ? 'Audio' : 'Vidéo'}</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 flex-grow flex flex-col justify-between">
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{creation.type === 'audio' && (
|
||||||
|
<audio controls className="w-full my-4 rounded-md shadow-sm">
|
||||||
|
<source src={creation.url} type="audio/mpeg" />
|
||||||
|
Votre navigateur ne supporte pas l'élément audio.
|
||||||
|
</audio>
|
||||||
|
)}
|
||||||
|
{creation.type === 'video' && (
|
||||||
|
<div className="aspect-video w-full overflow-hidden rounded-md mb-4 border border-border">
|
||||||
|
<video controls className="w-full h-full object-cover" preload="metadata">
|
||||||
|
<source src={creation.url} type="video/mp4" />
|
||||||
|
Votre navigateur ne supporte pas l'élément vidéo.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-muted-foreground text-sm leading-relaxed">{creation.description}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreationsPage;
|
||||||
162
src/pages/HomePage.jsx
Normal file
162
src/pages/HomePage.jsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Music, Zap, Gift, Edit3 } from 'lucide-react';
|
||||||
|
import ImageCarousel from '@/components/ImageCarousel';
|
||||||
|
|
||||||
|
const HomePage = () => {
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Edit3 className="h-10 w-10 text-primary" />,
|
||||||
|
title: "Entièrement Personnalisé",
|
||||||
|
description: "Chaque chanson est unique, écrite spécialement pour vous à partir de vos anecdotes et émotions.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Music className="h-10 w-10 text-accent" />,
|
||||||
|
title: "Tous Genres Musicaux",
|
||||||
|
description: "Pop, rock, folk, électro... Choisissez le style qui vous correspond et nous créons la magie.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Zap className="h-10 w-10 text-primary" />,
|
||||||
|
title: "Création Rapide",
|
||||||
|
description: "Recevez votre chanson personnalisée en un temps record, prête à être partagée.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Gift className="h-10 w-10 text-accent" />,
|
||||||
|
title: "Le Cadeau Parfait",
|
||||||
|
description: "Offrez une chanson unique pour un anniversaire, un mariage, ou toute autre occasion spéciale.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="space-y-16 py-8"
|
||||||
|
>
|
||||||
|
<section className="text-center">
|
||||||
|
<motion.h1
|
||||||
|
className="text-5xl md:text-7xl font-extrabold mb-6"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<span className="gradient-text">Transformez Vos Histoires</span><br/> en Chansons Inoubliables
|
||||||
|
</motion.h1>
|
||||||
|
<motion.p
|
||||||
|
className="text-lg md:text-xl text-muted-foreground max-w-3xl mx-auto mb-10"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.4 }}
|
||||||
|
>
|
||||||
|
Offrez ou offrez-vous une chanson 100% personnalisée. Partagez vos anecdotes, choisissez votre style, et laissez nos artistes compositeurs créer une mélodie unique qui raconte votre histoire.
|
||||||
|
</motion.p>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.6 }}
|
||||||
|
>
|
||||||
|
<Button size="lg" asChild className="bg-gradient-to-r from-primary to-accent hover:opacity-90 transition-opacity text-lg px-10 py-6 rounded-full shadow-lg hover:shadow-primary/50">
|
||||||
|
<Link to="/commander">Créez votre chanson maintenant</Link>
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="mt-12"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.8 }}
|
||||||
|
>
|
||||||
|
<ImageCarousel />
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-12">
|
||||||
|
<h2 className="text-4xl font-bold text-center mb-12">
|
||||||
|
Comment ça <span className="gradient-text">marche</span> ?
|
||||||
|
</h2>
|
||||||
|
<div className="grid md:grid-cols-3 gap-8 text-center">
|
||||||
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.2 }}>
|
||||||
|
<Card className="h-full bg-card/70 backdrop-blur-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mx-auto bg-primary/10 p-4 rounded-full w-fit mb-4">
|
||||||
|
<span className="text-3xl font-bold text-primary">1</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl !bg-none !text-foreground">Racontez-nous</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription>Remplissez notre formulaire intuitif avec les détails, anecdotes, et émotions que vous souhaitez transmettre.</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.4 }}>
|
||||||
|
<Card className="h-full bg-card/70 backdrop-blur-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mx-auto bg-accent/10 p-4 rounded-full w-fit mb-4">
|
||||||
|
<span className="text-3xl font-bold text-accent">2</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl !bg-none !text-foreground">Nous composons</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription>Nos artistes talentueux (et une pincée d'IA créative !) transforment vos idées en une chanson unique et mémorable.</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.6 }}>
|
||||||
|
<Card className="h-full bg-card/70 backdrop-blur-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mx-auto bg-primary/10 p-4 rounded-full w-fit mb-4">
|
||||||
|
<span className="text-3xl font-bold text-primary">3</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl !bg-none !text-foreground">Recevez votre Hit</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription>Votre chef-d'œuvre musical vous est livré, prêt à émouvoir et à être partagé avec le monde entier.</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-12">
|
||||||
|
<h2 className="text-4xl font-bold text-center mb-12">
|
||||||
|
Pourquoi choisir <span className="gradient-text">Dites le en chanson</span> ?
|
||||||
|
</h2>
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={feature.title}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: index * 0.1 + 0.2 }}
|
||||||
|
>
|
||||||
|
<Card className="h-full text-center bg-card/70 backdrop-blur-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mx-auto mb-4">{feature.icon}</div>
|
||||||
|
<CardTitle className="text-xl !bg-none !text-foreground">{feature.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription>{feature.description}</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="text-center py-12 bg-gradient-to-r from-primary/10 to-accent/10 dark:from-primary/5 dark:to-accent/5 rounded-xl shadow-inner">
|
||||||
|
<h2 className="text-4xl font-bold mb-6">Prêt à créer votre <span className="gradient-text">bande-son personnelle</span> ?</h2>
|
||||||
|
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-8">
|
||||||
|
Ne laissez pas vos souvenirs s'estomper. Immortalisez-les en musique.
|
||||||
|
</p>
|
||||||
|
<Button size="lg" asChild className="bg-gradient-to-r from-primary to-accent hover:opacity-90 transition-opacity text-lg px-10 py-6 rounded-full shadow-lg hover:shadow-primary/50">
|
||||||
|
<Link to="/commander">Je crée ma chanson</Link>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
228
src/pages/OrderPage.jsx
Normal file
228
src/pages/OrderPage.jsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
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;
|
||||||
76
tailwind.config.js
Normal file
76
tailwind.config.js
Normal 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')],
|
||||||
|
};
|
||||||
182
tools/generate-llms.js
Normal file
182
tools/generate-llms.js
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
#!/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: /"/g,
|
||||||
|
amp: /&/g,
|
||||||
|
lt: /</g,
|
||||||
|
gt: />/g,
|
||||||
|
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, []))
|
||||||
|
pages = pages.filter(Boolean);
|
||||||
|
} 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();
|
||||||
|
}
|
||||||
266
vite.config.js
Normal file
266
vite.config.js
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
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';
|
||||||
|
import selectionModePlugin from './plugins/selection-mode/vite-plugin-selection-mode.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(), selectionModePlugin()] : []),
|
||||||
|
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'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user