Initial commit build
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
src/dist/
|
||||||
|
src/node_modules/
|
||||||
|
src/package.json
|
||||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/site.iml" filepath="$PROJECT_DIR$/.idea/site.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
12
.idea/site.iml
generated
Normal file
12
.idea/site.iml
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
1
Jenkinsfile
vendored
Normal file
1
Jenkinsfile
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
nodePipeline(name: 'dites-le-en-chanson-site')
|
||||||
10450
package-lock.json
generated
10450
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,430 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
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.' }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
4
src/.env
Normal file
4
src/.env
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
VITE_SUPABASE_URL=https://supabase.abpcode.fr
|
||||||
|
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzY2Nzc3OTU5LCJleHAiOjE5MjQ0NTc5NTl9.I-qytVb1ef6QMR8IUDePJzESO3bJAnsGE075XQ2xiaI
|
||||||
|
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51RPSGmEPL3QASpovp8Q6p8ehNMW7TzSrOaV6zvPE1OtflMFN5jChQBEj5kr84wontlLOe8uiHyJBiCduzxIZwj5A00DIEVs31n
|
||||||
|
VITE_STRIPE_PUBLISHABLE_KEY_DEV=pk_test_51RPSH1ERAUBjYKpgbz4GjZjDtI24rqfBky5SO6AwdBfZaqNmFN0zQSxx0Z1wfFKtKXIZXfx5IOQSt2ularULIsto00frDMNi03
|
||||||
1
src/.version
Normal file
1
src/.version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
11
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
const CallToAction = () => {
|
|
||||||
return (
|
|
||||||
<motion.h1
|
|
||||||
className='text-xl font-bold text-white leading-8 w-full'
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.5 }}
|
|
||||||
>
|
|
||||||
Let's turn your ideas into reality
|
|
||||||
</motion.h1>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CallToAction;
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const HeroImage = () => {
|
|
||||||
return (
|
|
||||||
<div className="relative w-8 h-8 shrink-0" data-name="ic-sparkles">
|
|
||||||
<svg
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
viewBox="0 0 32 32"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="w-full h-full"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M11.787 9.5356C11.5053 8.82147 10.4947 8.82147 10.213 9.5356L8.742 13.2654C8.65601 13.4834 8.48343 13.656 8.2654 13.742L4.5356 15.213C3.82147 15.4947 3.82147 16.5053 4.5356 16.787L8.2654 18.258C8.48343 18.344 8.65601 18.5166 8.742 18.7346L10.213 22.4644C10.4947 23.1785 11.5053 23.1785 11.787 22.4644L13.258 18.7346C13.344 18.5166 13.5166 18.344 13.7346 18.258L17.4644 16.787C18.1785 16.5053 18.1785 15.4947 17.4644 15.213L13.7346 13.742C13.5166 13.656 13.344 13.4834 13.258 13.2654L11.787 9.5356Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M23.5621 2.38257C23.361 1.87248 22.639 1.87248 22.4379 2.38257L21.3871 5.04671C21.3257 5.20245 21.2024 5.32572 21.0467 5.38714L18.3826 6.43787C17.8725 6.63904 17.8725 7.36096 18.3826 7.56214L21.0467 8.61286C21.2024 8.67428 21.3257 8.79755 21.3871 8.95329L22.4379 11.6174C22.639 12.1275 23.361 12.1275 23.5621 11.6174L24.6129 8.95329C24.6743 8.79755 24.7976 8.67428 24.9533 8.61286L27.6174 7.56214C28.1275 7.36096 28.1275 6.63904 27.6174 6.43787L24.9533 5.38714C24.7976 5.32572 24.6743 5.20245 24.6129 5.04671L23.5621 2.38257Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M23.3373 22.2295C23.2166 21.9235 22.7834 21.9235 22.6627 22.2295L22.0323 23.828C21.9954 23.9215 21.9215 23.9954 21.828 24.0323L20.2295 24.6627C19.9235 24.7834 19.9235 25.2166 20.2295 25.3373L21.828 25.9677C21.9215 26.0046 21.9954 26.0785 22.0323 26.172L22.6627 27.7705C22.7834 28.0765 23.2166 28.0765 23.3373 27.7705L23.9677 26.172C24.0046 26.0785 24.0785 26.0046 24.172 25.9677L25.7705 25.3373C26.0765 25.2166 26.0765 24.7834 25.7705 24.6627L24.172 24.0323C24.0785 23.9954 24.0046 23.9215 23.9677 23.828L23.3373 22.2295Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HeroImage;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
const supabaseUrl = 'https://zneurmyahctnnzglnptl.supabase.co';
|
|
||||||
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InpuZXVybXlhaGN0bm56Z2xucHRsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDg4NTM5MTksImV4cCI6MjA2NDQyOTkxOX0.wK5iWQt3pXNNpi9xquCaowvQrz8k8qjlcwFMsehqnWI';
|
|
||||||
|
|
||||||
const customSupabaseClient = createClient(supabaseUrl, supabaseAnonKey);
|
|
||||||
|
|
||||||
export default customSupabaseClient;
|
|
||||||
|
|
||||||
export {
|
|
||||||
customSupabaseClient,
|
|
||||||
customSupabaseClient as supabase,
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
const supabaseUrl = 'https://zneurmyahctnnzglnptl.supabase.co';
|
|
||||||
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InpuZXVybXlhaGN0bm56Z2xucHRsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDg4NTM5MTksImV4cCI6MjA2NDQyOTkxOX0.wK5iWQt3pXNNpi9xquCaowvQrz8k8qjlcwFMsehqnWI';
|
|
||||||
|
|
||||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
|
||||||
import { ChevronLeft, ChevronRight, ShoppingCart } from 'lucide-react';
|
|
||||||
import Step0ProductSelection from '@/components/order/Step0ProductSelection';
|
|
||||||
import Step1Content from '@/components/order/Step1Content';
|
|
||||||
import Step2MusicalChoices from '@/components/order/Step2MusicalChoices';
|
|
||||||
import Step3Review from '@/components/order/Step3Review';
|
|
||||||
import { initialFormData, stepsConfig, formValidations, products, STRIPE_PUBLISHABLE_KEY } from '@/config/orderFormConfig';
|
|
||||||
import { loadStripe } from '@stripe/stripe-js';
|
|
||||||
|
|
||||||
const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY);
|
|
||||||
|
|
||||||
const OrderPage = () => {
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
|
||||||
const [formData, setFormData] = useState(() => {
|
|
||||||
const savedData = localStorage.getItem('songOrderForm');
|
|
||||||
if (savedData) {
|
|
||||||
const parsedData = JSON.parse(savedData);
|
|
||||||
const selectedProductDetails = products.find(p => p.id === parsedData.selectedProduct);
|
|
||||||
if (!selectedProductDetails) {
|
|
||||||
return { ...initialFormData, selectedProduct: initialFormData.selectedProduct, price: 0, stripePriceId: null };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...parsedData,
|
|
||||||
price: selectedProductDetails.promotionPrice || selectedProductDetails.price,
|
|
||||||
stripePriceId: selectedProductDetails.stripePriceId
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return initialFormData;
|
|
||||||
});
|
|
||||||
const [errors, setErrors] = useState({});
|
|
||||||
const { toast } = useToast();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [isProcessingPayment, setIsProcessingPayment] = useState(false);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem('songOrderForm', JSON.stringify(formData));
|
|
||||||
}, [formData]);
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
|
||||||
if (errors[name]) {
|
|
||||||
setErrors(prev => ({ ...prev, [name]: null }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectChange = (name, value) => {
|
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
|
||||||
if (errors[name]) {
|
|
||||||
setErrors(prev => ({ ...prev, [name]: null }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRadioGroupChange = (name, value) => {
|
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
|
||||||
if (errors[name]) {
|
|
||||||
setErrors(prev => ({ ...prev, [name]: null }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProductSelect = (productId) => {
|
|
||||||
const selectedProductDetails = products.find(p => p.id === productId);
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
selectedProduct: productId,
|
|
||||||
price: selectedProductDetails?.promotionPrice || selectedProductDetails?.price || 0,
|
|
||||||
stripePriceId: selectedProductDetails?.stripePriceId
|
|
||||||
}));
|
|
||||||
if (errors.selectedProduct) {
|
|
||||||
setErrors(prev => ({ ...prev, selectedProduct: null }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateStep = () => {
|
|
||||||
const currentStepId = stepsConfig[currentStep].id;
|
|
||||||
const stepFields = formValidations[currentStepId];
|
|
||||||
let newErrors = {};
|
|
||||||
let isValid = true;
|
|
||||||
|
|
||||||
if (stepFields) {
|
|
||||||
for (const field of stepFields) {
|
|
||||||
const value = formData[field.name];
|
|
||||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
|
||||||
newErrors[field.name] = field.message;
|
|
||||||
isValid = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
if (!isValid && Object.keys(newErrors).length > 0) {
|
|
||||||
const firstErrorField = Object.keys(newErrors)[0];
|
|
||||||
toast({ title: "Champ requis", description: newErrors[firstErrorField], variant: "destructive" });
|
|
||||||
}
|
|
||||||
return isValid;
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextStep = () => {
|
|
||||||
if (validateStep()) {
|
|
||||||
setCurrentStep(prev => Math.min(prev + 1, stepsConfig.length - 1));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const prevStep = () => {
|
|
||||||
setErrors({});
|
|
||||||
setCurrentStep(prev => Math.max(prev - 1, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!validateStep() || !formData.selectedProduct || !formData.stripePriceId) {
|
|
||||||
toast({
|
|
||||||
title: "Erreur de commande",
|
|
||||||
description: "Veuillez sélectionner un produit et remplir tous les champs requis avant de continuer.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessingPayment(true);
|
|
||||||
toast({
|
|
||||||
title: "Préparation du paiement...",
|
|
||||||
description: "Vous allez être redirigé vers Stripe.",
|
|
||||||
});
|
|
||||||
|
|
||||||
const stripe = await stripePromise;
|
|
||||||
const selectedProductDetails = products.find(p => p.id === formData.selectedProduct);
|
|
||||||
|
|
||||||
if (!selectedProductDetails || !selectedProductDetails.stripePriceId || selectedProductDetails.stripePriceId.includes('YOUR_')) {
|
|
||||||
toast({
|
|
||||||
title: "Erreur de configuration produit",
|
|
||||||
description: "L'ID de prix Stripe pour ce produit n'est pas correctement configuré. Veuillez me fournir les bons Price IDs.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
setIsProcessingPayment(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalPrice = selectedProductDetails.promotionPrice || selectedProductDetails.price;
|
|
||||||
|
|
||||||
const finalFormDataForConfirmation = { ...formData, productName: selectedProductDetails?.name, price: finalPrice };
|
|
||||||
localStorage.setItem('confirmedOrderDataForConfirmationPage', JSON.stringify(finalFormDataForConfirmation));
|
|
||||||
|
|
||||||
const { error } = await stripe.redirectToCheckout({
|
|
||||||
lineItems: [{ price: selectedProductDetails.stripePriceId, quantity: 1 }],
|
|
||||||
mode: 'payment',
|
|
||||||
successUrl: `${window.location.origin}/confirmation?session_id={CHECKOUT_SESSION_ID}`,
|
|
||||||
cancelUrl: `${window.location.origin}/commander`,
|
|
||||||
customerEmail: formData.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Erreur Stripe:", error);
|
|
||||||
toast({
|
|
||||||
title: "Erreur de paiement",
|
|
||||||
description: error.message || "Une erreur est survenue lors de la redirection vers Stripe.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
|
|
||||||
setIsProcessingPayment(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const progressPercentage = ((currentStep + 1) / stepsConfig.length) * 100;
|
|
||||||
const currentSelectedProduct = formData.selectedProduct ? products.find(p => p.id === formData.selectedProduct) : null;
|
|
||||||
const currentPriceToPay = currentSelectedProduct ? (currentSelectedProduct.promotionPrice || currentSelectedProduct.price) : 0;
|
|
||||||
|
|
||||||
|
|
||||||
const renderStepContent = () => {
|
|
||||||
const stepId = stepsConfig[currentStep].id;
|
|
||||||
switch (stepId) {
|
|
||||||
case 'productSelection':
|
|
||||||
return <Step0ProductSelection selectedProduct={formData.selectedProduct} onProductSelect={handleProductSelect} error={errors.selectedProduct} />;
|
|
||||||
case 'songInfo':
|
|
||||||
return <Step1Content formData={formData} handleChange={handleChange} handleSelectChange={handleSelectChange} errors={errors} />;
|
|
||||||
case 'musicalChoices':
|
|
||||||
return <Step2MusicalChoices formData={formData} handleSelectChange={handleSelectChange} handleRadioGroupChange={handleRadioGroupChange} errors={errors} />;
|
|
||||||
case 'review':
|
|
||||||
return <Step3Review formData={formData} handleChange={handleChange} errors={errors} />;
|
|
||||||
default:
|
|
||||||
return <div>Étape inconnue</div>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-3xl mx-auto py-8">
|
|
||||||
<Card className="shadow-2xl bg-card/80 backdrop-blur-md">
|
|
||||||
<CardHeader className="text-center border-b pb-4">
|
|
||||||
<div className="flex items-center justify-center text-3xl font-bold mb-2">
|
|
||||||
{stepsConfig[currentStep].icon}
|
|
||||||
<h1 className="gradient-text">{stepsConfig[currentStep].title}</h1>
|
|
||||||
</div>
|
|
||||||
<CardDescription>Étape {currentStep + 1} sur {stepsConfig.length}</CardDescription>
|
|
||||||
<Progress value={progressPercentage} className="mt-4 h-3" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-6 md:p-10 min-h-[300px]">
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{renderStepContent()}
|
|
||||||
</AnimatePresence>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex justify-between p-6 border-t">
|
|
||||||
<Button variant="outline" onClick={prevStep} disabled={currentStep === 0 || isProcessingPayment} className="text-base px-6 py-3">
|
|
||||||
<ChevronLeft className="h-5 w-5 mr-2" />
|
|
||||||
Précédent
|
|
||||||
</Button>
|
|
||||||
{currentStep < stepsConfig.length - 1 ? (
|
|
||||||
<Button onClick={nextStep} disabled={isProcessingPayment} className="text-base px-6 py-3 bg-gradient-to-r from-primary to-accent hover:opacity-90 transition-opacity">
|
|
||||||
Suivant
|
|
||||||
<ChevronRight className="h-5 w-5 ml-2" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button onClick={handleSubmit} disabled={isProcessingPayment} className="text-base px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-600 hover:opacity-90 transition-opacity">
|
|
||||||
{isProcessingPayment ? "Traitement..." : `Payer (${currentPriceToPay ? currentPriceToPay.toFixed(2) : '0.00'} €)`}
|
|
||||||
{!isProcessingPayment && <ShoppingCart className="h-5 w-5 ml-2" />}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OrderPage;
|
|
||||||
315
src/plugins/visual-editor/edit-mode-script.js
Normal file
315
src/plugins/visual-editor/edit-mode-script.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import { POPUP_STYLES } from './plugins/visual-editor/visual-editor-config.js';
|
||||||
|
|
||||||
|
const PLUGIN_APPLY_EDIT_API_URL = '/api/apply-edit';
|
||||||
|
|
||||||
|
const ALLOWED_PARENT_ORIGINS = [
|
||||||
|
'https://horizons.hostinger.com',
|
||||||
|
'https://horizons.hostinger.dev',
|
||||||
|
'https://horizons-frontend-local.hostinger.dev',
|
||||||
|
'http://localhost:4000',
|
||||||
|
];
|
||||||
|
|
||||||
|
let disabledTooltipElement = null;
|
||||||
|
let currentDisabledHoverElement = null;
|
||||||
|
|
||||||
|
let translations = {
|
||||||
|
disabledTooltipText: "This text can be changed only through chat.",
|
||||||
|
disabledTooltipTextImage: "This image can only be changed through chat."
|
||||||
|
};
|
||||||
|
|
||||||
|
let areStylesInjected = false;
|
||||||
|
|
||||||
|
let globalEventHandlers = null;
|
||||||
|
|
||||||
|
let currentEditingInfo = null;
|
||||||
|
|
||||||
|
function injectPopupStyles() {
|
||||||
|
if (areStylesInjected) return;
|
||||||
|
|
||||||
|
const styleElement = document.createElement('style');
|
||||||
|
styleElement.id = 'inline-editor-styles';
|
||||||
|
styleElement.textContent = POPUP_STYLES;
|
||||||
|
document.head.appendChild(styleElement);
|
||||||
|
areStylesInjected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findEditableElementAtPoint(event) {
|
||||||
|
let editableElement = event.target.closest('[data-edit-id]');
|
||||||
|
|
||||||
|
if (editableElement) {
|
||||||
|
return editableElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementsAtPoint = document.elementsFromPoint(event.clientX, event.clientY);
|
||||||
|
|
||||||
|
const found = elementsAtPoint.find(el => el !== event.target && el.hasAttribute('data-edit-id'));
|
||||||
|
if (found) return found;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDisabledElementAtPoint(event) {
|
||||||
|
const direct = event.target.closest('[data-edit-disabled]');
|
||||||
|
if (direct) return direct;
|
||||||
|
const elementsAtPoint = document.elementsFromPoint(event.clientX, event.clientY);
|
||||||
|
const found = elementsAtPoint.find(el => el !== event.target && el.hasAttribute('data-edit-disabled'));
|
||||||
|
if (found) return found;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPopup(targetElement, editId, currentContent, isImage = false) {
|
||||||
|
currentEditingInfo = { editId, targetElement };
|
||||||
|
|
||||||
|
const parentOrigin = getParentOrigin();
|
||||||
|
|
||||||
|
if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||||
|
const eventType = isImage ? 'imageEditEnter' : 'editEnter';
|
||||||
|
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: eventType,
|
||||||
|
payload: { currentText: currentContent }
|
||||||
|
}, parentOrigin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalEvent(event) {
|
||||||
|
if (!document.getElementById('root')?.getAttribute('data-edit-mode-enabled')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.closest('#inline-editor-popup')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableElement = findEditableElementAtPoint(event);
|
||||||
|
|
||||||
|
if (editableElement) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
|
||||||
|
if (event.type === 'click') {
|
||||||
|
const editId = editableElement.getAttribute('data-edit-id');
|
||||||
|
if (!editId) {
|
||||||
|
console.warn('[INLINE EDITOR] Clicked element missing data-edit-id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImage = editableElement.tagName.toLowerCase() === 'img';
|
||||||
|
let currentContent = '';
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
currentContent = editableElement.getAttribute('src') || '';
|
||||||
|
} else {
|
||||||
|
currentContent = editableElement.textContent || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
showPopup(editableElement, editId, currentContent, isImage);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParentOrigin() {
|
||||||
|
if (window.location.ancestorOrigins && window.location.ancestorOrigins.length > 0) {
|
||||||
|
return window.location.ancestorOrigins[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.referrer) {
|
||||||
|
try {
|
||||||
|
return new URL(document.referrer).origin;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Invalid referrer URL:', document.referrer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSave(updatedText) {
|
||||||
|
const newText = updatedText
|
||||||
|
// Replacing characters that cause Babel parser to crash
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.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() {
|
||||||
|
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
src/plugins/visual-editor/visual-editor-config.js
Normal file
137
src/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
src/plugins/visual-editor/vite-plugin-edit-mode.js
Normal file
32
src/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'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
384
src/plugins/visual-editor/vite-plugin-react-inline-editor.js
Normal file
384
src/plugins/visual-editor/vite-plugin-react-inline-editor.js
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { parse } from '@babel/parser';
|
||||||
|
import traverseBabel from '@babel/traverse';
|
||||||
|
import generate from '@babel/generator';
|
||||||
|
import * as t from '@babel/types';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const VITE_PROJECT_ROOT = path.resolve(__dirname, '../..');
|
||||||
|
const EDITABLE_HTML_TAGS = ["a", "Button", "button", "p", "span", "h1", "h2", "h3", "h4", "h5", "h6", "label", "Label", "img"];
|
||||||
|
|
||||||
|
function parseEditId(editId) {
|
||||||
|
const parts = editId.split(':');
|
||||||
|
|
||||||
|
if (parts.length < 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const column = parseInt(parts.at(-1), 10);
|
||||||
|
const line = parseInt(parts.at(-2), 10);
|
||||||
|
const filePath = parts.slice(0, -2).join(':');
|
||||||
|
|
||||||
|
if (!filePath || isNaN(line) || isNaN(column)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { filePath, line, column };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkTagNameEditable(openingElementNode, editableTagsList) {
|
||||||
|
if (!openingElementNode || !openingElementNode.name) return false;
|
||||||
|
const nameNode = openingElementNode.name;
|
||||||
|
|
||||||
|
// Check 1: Direct name (for <p>, <Button>)
|
||||||
|
if (nameNode.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 2: Property name of a member expression (for <motion.h1>, check if "h1" is in editableTagsList)
|
||||||
|
if (nameNode.type === 'JSXMemberExpression' && nameNode.property && nameNode.property.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.property.name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateImageSrc(openingNode) {
|
||||||
|
if (!openingNode || !openingNode.name || openingNode.name.name !== 'img') {
|
||||||
|
return { isValid: true, reason: null }; // Not an image, skip validation
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPropsSpread = openingNode.attributes.some(attr =>
|
||||||
|
t.isJSXSpreadAttribute(attr) &&
|
||||||
|
attr.argument &&
|
||||||
|
t.isIdentifier(attr.argument) &&
|
||||||
|
attr.argument.name === 'props'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasPropsSpread) {
|
||||||
|
return { isValid: false, reason: 'props-spread' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcAttr = openingNode.attributes.find(attr =>
|
||||||
|
t.isJSXAttribute(attr) &&
|
||||||
|
attr.name &&
|
||||||
|
attr.name.name === 'src'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!srcAttr) {
|
||||||
|
return { isValid: false, reason: 'missing-src' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!t.isStringLiteral(srcAttr.value)) {
|
||||||
|
return { isValid: false, reason: 'dynamic-src' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!srcAttr.value.value || srcAttr.value.value.trim() === '') {
|
||||||
|
return { isValid: false, reason: 'empty-src' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true, reason: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function inlineEditPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'vite-inline-edit-plugin',
|
||||||
|
enforce: 'pre',
|
||||||
|
|
||||||
|
transform(code, id) {
|
||||||
|
if (!/\.(jsx|tsx)$/.test(id) || !id.startsWith(VITE_PROJECT_ROOT) || id.includes('node_modules')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeFilePath = path.relative(VITE_PROJECT_ROOT, id);
|
||||||
|
const webRelativeFilePath = relativeFilePath.split(path.sep).join('/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const babelAst = parse(code, {
|
||||||
|
sourceType: 'module',
|
||||||
|
plugins: ['jsx', 'typescript'],
|
||||||
|
errorRecovery: true
|
||||||
|
});
|
||||||
|
|
||||||
|
let attributesAdded = 0;
|
||||||
|
|
||||||
|
traverseBabel.default(babelAst, {
|
||||||
|
enter(path) {
|
||||||
|
if (path.isJSXOpeningElement()) {
|
||||||
|
const openingNode = path.node;
|
||||||
|
const elementNode = path.parentPath.node; // The JSXElement itself
|
||||||
|
|
||||||
|
if (!openingNode.loc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyHasId = openingNode.attributes.some(
|
||||||
|
(attr) => t.isJSXAttribute(attr) && attr.name.name === 'data-edit-id'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (alreadyHasId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition 1: Is the current element tag type editable?
|
||||||
|
const isCurrentElementEditable = checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS);
|
||||||
|
if (!isCurrentElementEditable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageValidation = validateImageSrc(openingNode);
|
||||||
|
if (!imageValidation.isValid) {
|
||||||
|
const disabledAttribute = t.jsxAttribute(
|
||||||
|
t.jsxIdentifier('data-edit-disabled'),
|
||||||
|
t.stringLiteral('true')
|
||||||
|
);
|
||||||
|
openingNode.attributes.push(disabledAttribute);
|
||||||
|
attributesAdded++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shouldBeDisabledDueToChildren = false;
|
||||||
|
|
||||||
|
// Condition 2: Does the element have dynamic or editable children
|
||||||
|
if (t.isJSXElement(elementNode) && elementNode.children) {
|
||||||
|
// Check if element has {...props} spread attribute - disable editing if it does
|
||||||
|
const hasPropsSpread = openingNode.attributes.some(attr => t.isJSXSpreadAttribute(attr)
|
||||||
|
&& attr.argument
|
||||||
|
&& t.isIdentifier(attr.argument)
|
||||||
|
&& attr.argument.name === 'props'
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasDynamicChild = elementNode.children.some(child =>
|
||||||
|
t.isJSXExpressionContainer(child)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasDynamicChild || hasPropsSpread) {
|
||||||
|
shouldBeDisabledDueToChildren = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldBeDisabledDueToChildren && t.isJSXElement(elementNode) && elementNode.children) {
|
||||||
|
const hasEditableJsxChild = elementNode.children.some(child => {
|
||||||
|
if (t.isJSXElement(child)) {
|
||||||
|
return checkTagNameEditable(child.openingElement, EDITABLE_HTML_TAGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasEditableJsxChild) {
|
||||||
|
shouldBeDisabledDueToChildren = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldBeDisabledDueToChildren) {
|
||||||
|
const disabledAttribute = t.jsxAttribute(
|
||||||
|
t.jsxIdentifier('data-edit-disabled'),
|
||||||
|
t.stringLiteral('true')
|
||||||
|
);
|
||||||
|
|
||||||
|
openingNode.attributes.push(disabledAttribute);
|
||||||
|
attributesAdded++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition 3: Parent is non-editable if AT LEAST ONE child JSXElement is a non-editable type.
|
||||||
|
if (t.isJSXElement(elementNode) && elementNode.children && elementNode.children.length > 0) {
|
||||||
|
let hasNonEditableJsxChild = false;
|
||||||
|
for (const child of elementNode.children) {
|
||||||
|
if (t.isJSXElement(child)) {
|
||||||
|
if (!checkTagNameEditable(child.openingElement, EDITABLE_HTML_TAGS)) {
|
||||||
|
hasNonEditableJsxChild = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasNonEditableJsxChild) {
|
||||||
|
const disabledAttribute = t.jsxAttribute(
|
||||||
|
t.jsxIdentifier('data-edit-disabled'),
|
||||||
|
t.stringLiteral("true")
|
||||||
|
);
|
||||||
|
openingNode.attributes.push(disabledAttribute);
|
||||||
|
attributesAdded++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition 4: Is any ancestor JSXElement also editable?
|
||||||
|
let currentAncestorCandidatePath = path.parentPath.parentPath;
|
||||||
|
while (currentAncestorCandidatePath) {
|
||||||
|
const ancestorJsxElementPath = currentAncestorCandidatePath.isJSXElement()
|
||||||
|
? currentAncestorCandidatePath
|
||||||
|
: currentAncestorCandidatePath.findParent(p => p.isJSXElement());
|
||||||
|
|
||||||
|
if (!ancestorJsxElementPath) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkTagNameEditable(ancestorJsxElementPath.node.openingElement, EDITABLE_HTML_TAGS)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentAncestorCandidatePath = ancestorJsxElementPath.parentPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = openingNode.loc.start.line;
|
||||||
|
const column = openingNode.loc.start.column + 1;
|
||||||
|
const editId = `${webRelativeFilePath}:${line}:${column}`;
|
||||||
|
|
||||||
|
const idAttribute = t.jsxAttribute(
|
||||||
|
t.jsxIdentifier('data-edit-id'),
|
||||||
|
t.stringLiteral(editId)
|
||||||
|
);
|
||||||
|
|
||||||
|
openingNode.attributes.push(idAttribute);
|
||||||
|
attributesAdded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (attributesAdded > 0) {
|
||||||
|
const generateFunction = generate.default || generate;
|
||||||
|
const output = generateFunction(babelAst, {
|
||||||
|
sourceMaps: true,
|
||||||
|
sourceFileName: webRelativeFilePath
|
||||||
|
}, code);
|
||||||
|
|
||||||
|
return { code: output.code, map: output.map };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[vite][visual-editor] Error transforming ${id}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
// Updates source code based on the changes received from the client
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use('/api/apply-edit', async (req, res, next) => {
|
||||||
|
if (req.method !== 'POST') return next();
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk.toString(); });
|
||||||
|
|
||||||
|
req.on('end', async () => {
|
||||||
|
let absoluteFilePath = '';
|
||||||
|
try {
|
||||||
|
const { editId, newFullText } = JSON.parse(body);
|
||||||
|
|
||||||
|
if (!editId || typeof newFullText === 'undefined') {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(JSON.stringify({ error: 'Missing editId or newFullText' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedId = parseEditId(editId);
|
||||||
|
if (!parsedId) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(JSON.stringify({ error: 'Invalid editId format (filePath:line:column)' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { filePath, line, column } = parsedId;
|
||||||
|
|
||||||
|
absoluteFilePath = path.resolve(VITE_PROJECT_ROOT, filePath);
|
||||||
|
if (filePath.includes('..') || !absoluteFilePath.startsWith(VITE_PROJECT_ROOT) || absoluteFilePath.includes('node_modules')) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(JSON.stringify({ error: 'Invalid path' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalContent = fs.readFileSync(absoluteFilePath, 'utf-8');
|
||||||
|
|
||||||
|
const babelAst = parse(originalContent, {
|
||||||
|
sourceType: 'module',
|
||||||
|
plugins: ['jsx', 'typescript'],
|
||||||
|
errorRecovery: true
|
||||||
|
});
|
||||||
|
|
||||||
|
let targetNodePath = null;
|
||||||
|
const visitor = {
|
||||||
|
JSXOpeningElement(path) {
|
||||||
|
const node = path.node;
|
||||||
|
if (node.loc && node.loc.start.line === line && node.loc.start.column + 1 === column) {
|
||||||
|
targetNodePath = path;
|
||||||
|
path.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
traverseBabel.default(babelAst, visitor);
|
||||||
|
|
||||||
|
if (!targetNodePath) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(JSON.stringify({ error: 'Target node not found by line/column', editId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateFunction = generate.default || generate;
|
||||||
|
const targetOpeningElement = targetNodePath.node;
|
||||||
|
const parentElementNode = targetNodePath.parentPath?.node;
|
||||||
|
|
||||||
|
const isImageElement = targetOpeningElement.name && targetOpeningElement.name.name === 'img';
|
||||||
|
|
||||||
|
let beforeCode = '';
|
||||||
|
let afterCode = '';
|
||||||
|
let modified = false;
|
||||||
|
|
||||||
|
if (isImageElement) {
|
||||||
|
// Handle image src attribute update
|
||||||
|
const beforeOutput = generateFunction(targetOpeningElement, {});
|
||||||
|
beforeCode = beforeOutput.code;
|
||||||
|
|
||||||
|
const srcAttr = targetOpeningElement.attributes.find(attr =>
|
||||||
|
t.isJSXAttribute(attr) && attr.name && attr.name.name === 'src'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (srcAttr && t.isStringLiteral(srcAttr.value)) {
|
||||||
|
srcAttr.value = t.stringLiteral(newFullText);
|
||||||
|
modified = true;
|
||||||
|
|
||||||
|
const afterOutput = generateFunction(targetOpeningElement, {});
|
||||||
|
afterCode = afterOutput.code;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (parentElementNode && t.isJSXElement(parentElementNode)) {
|
||||||
|
const beforeOutput = generateFunction(parentElementNode, {});
|
||||||
|
beforeCode = beforeOutput.code;
|
||||||
|
|
||||||
|
parentElementNode.children = [];
|
||||||
|
if (newFullText && newFullText.trim() !== '') {
|
||||||
|
const newTextNode = t.jsxText(newFullText);
|
||||||
|
parentElementNode.children.push(newTextNode);
|
||||||
|
}
|
||||||
|
modified = true;
|
||||||
|
|
||||||
|
const afterOutput = generateFunction(parentElementNode, {});
|
||||||
|
afterCode = afterOutput.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modified) {
|
||||||
|
res.writeHead(409, { 'Content-Type': 'application/json' });
|
||||||
|
return res.end(JSON.stringify({ error: 'Could not apply changes to AST.' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = generateFunction(babelAst, {});
|
||||||
|
const newContent = output.code;
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
newFileContent: newContent,
|
||||||
|
beforeCode,
|
||||||
|
afterCode,
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Internal server error during edit application.' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
const WelcomeMessage = () => {
|
const CallToAction = () => {
|
||||||
return (
|
return (
|
||||||
<motion.p
|
<motion.p
|
||||||
className='text-sm text-white leading-5 w-full'
|
className='text-md text-white max-w-lg mx-auto'
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.5, delay: 0.8 }}
|
transition={{ duration: 0.5, delay: 0.8 }}
|
||||||
>
|
>
|
||||||
Write in the chat what you want to create.
|
Let's turn your ideas into reality.
|
||||||
</motion.p>
|
</motion.p>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WelcomeMessage;
|
export default CallToAction;
|
||||||
14
src/src/components/HeroImage.jsx
Normal file
14
src/src/components/HeroImage.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const HeroImage = () => {
|
||||||
|
return (
|
||||||
|
<div className='flex justify-center items-center'>
|
||||||
|
<img
|
||||||
|
src='https://imagedelivery.net/LqiWLm-3MGbYHtFuUbcBtA/119580eb-abd9-4191-b93a-f01938786700/public'
|
||||||
|
alt='Hostinger Horizons'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroImage;
|
||||||
@@ -4,11 +4,11 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
const carouselImages = [
|
const carouselImages = [
|
||||||
{ src: "https://images.unsplash.com/photo-1470225620780-dba8ba36b745", alt: "DJ mixant de la musique lors d'un concert vibrant" },
|
{ src: "https://files.dites-le-en-chanson.fr/images/photo-1470225620780-dba8ba36b745.jpg", alt: "DJ mixant de la musique lors d'un concert vibrant" },
|
||||||
{ src: "https://images.unsplash.com/photo-1511379938547-c1f69419868d", alt: "Gros plan sur des écouteurs posés sur un clavier d'ordinateur portable" },
|
{ src: "https://files.dites-le-en-chanson.fr/images/photo-1511379938547-c1f69419868d.jpg", alt: "Gros plan sur des écouteurs posés sur un clavier d'ordinateur portable" },
|
||||||
{ src: "https://images.unsplash.com/photo-1487180144351-b8472da7d491", alt: "Personne jouant de la guitare acoustique avec un effet de lumière bokeh" },
|
{ src: "https://files.dites-le-en-chanson.fr/images/photo-1487180144351-b8472da7d491.jpg", alt: "Personne jouant de la guitare acoustique avec un effet de lumière bokeh" },
|
||||||
{ src: "https://images.unsplash.com/photo-1507838153414-b4b713384a76", alt: "Piano à queue dans une pièce faiblement éclairée avec des partitions" },
|
{ src: "https://files.dites-le-en-chanson.fr/images/photo-1507838153414-b4b713384a76.jpg", alt: "Piano à queue dans une pièce faiblement éclairée avec des partitions" },
|
||||||
{ src: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f", alt: "Microphone de studio vintage sur fond sombre" }
|
{ src: "https://files.dites-le-en-chanson.fr/images/photo-1493225457124-a3eb161ffa5f.jpg", alt: "Microphone de studio vintage sur fond sombre" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
18
src/src/components/WelcomeMessage.jsx
Normal file
18
src/src/components/WelcomeMessage.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
const WelcomeMessage = () => {
|
||||||
|
return (
|
||||||
|
<motion.p
|
||||||
|
className='text-xl md:text-2xl text-white max-w-2xl mx-auto'
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.5 }}
|
||||||
|
>
|
||||||
|
Hello there! I'm <span className='font-semibold text-purple-300'>Horizons</span>, your AI coding companion.
|
||||||
|
I'm here to help you build amazing web application!
|
||||||
|
</motion.p>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WelcomeMessage;
|
||||||
@@ -14,7 +14,7 @@ export function Toaster() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{toasts.map(({ id, title, description, action, ...props }) => {
|
{toasts.map(({ id, title, description, action, dismiss, update, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<Toast key={id} {...props}>
|
<Toast key={id} {...props}>
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ShoppingBag, Edit2, Music, Send } from 'lucide-react';
|
import { ShoppingBag, Edit2, Music, Send } from 'lucide-react';
|
||||||
|
export const STRIPE_PUBLISHABLE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY_DEV;
|
||||||
export const STRIPE_PUBLISHABLE_KEY = 'pk_live_51RPSGmEPL3QASpovp8Q6p8ehNMW7TzSrOaV6zvPE1OtflMFN5jChQBEj5kr84wontlLOe8uiHyJBiCduzxIZwj5A00DIEVs31n';
|
|
||||||
|
|
||||||
export const products = [
|
export const products = [
|
||||||
{
|
{
|
||||||
@@ -11,7 +10,7 @@ import React from 'react';
|
|||||||
promotionPrice: 17.90,
|
promotionPrice: 17.90,
|
||||||
stripePriceId: 'price_1RVTPKEPL3QASpovq34PyAvN',
|
stripePriceId: 'price_1RVTPKEPL3QASpovq34PyAvN',
|
||||||
description: 'Une chanson complète, paroles et musique, rien que pour vous, à un prix spécial !',
|
description: 'Une chanson complète, paroles et musique, rien que pour vous, à un prix spécial !',
|
||||||
imageUrl: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/images//chanson-personnalisee.jpg'
|
imageUrl: 'https://files.dites-le-en-chanson.fr/products/chanson-personnalisee.jpg'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'prod_SQK4s1RR0xK3oe',
|
id: 'prod_SQK4s1RR0xK3oe',
|
||||||
@@ -20,7 +19,7 @@ import React from 'react';
|
|||||||
promotionPrice: null,
|
promotionPrice: null,
|
||||||
stripePriceId: 'price_1RVTQKEPL3QASpovL3Cg7K9S',
|
stripePriceId: 'price_1RVTQKEPL3QASpovL3Cg7K9S',
|
||||||
description: 'Deux chansons uniques pour deux fois plus d\'émotion.',
|
description: 'Deux chansons uniques pour deux fois plus d\'émotion.',
|
||||||
imageUrl: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/images//chansons-personnalisees.jpg'
|
imageUrl: 'https://files.dites-le-en-chanson.fr/products/chansons-personnalisees.jpg'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'prod_SQKBpI2jc1hHOQ',
|
id: 'prod_SQKBpI2jc1hHOQ',
|
||||||
@@ -29,7 +28,7 @@ import React from 'react';
|
|||||||
promotionPrice: 44.90,
|
promotionPrice: 44.90,
|
||||||
stripePriceId: 'price_1RVTXYEPL3QASpovSei3ZV48',
|
stripePriceId: 'price_1RVTXYEPL3QASpovSei3ZV48',
|
||||||
description: 'Votre chanson mise en musique et accompagnée d\'un clip photo, en promotion !',
|
description: 'Votre chanson mise en musique et accompagnée d\'un clip photo, en promotion !',
|
||||||
imageUrl: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/images//chanson-photo.jpg'
|
imageUrl: 'https://files.dites-le-en-chanson.fr/products/chanson-photo.jpg'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'prod_SQKCtsEPlwf329',
|
id: 'prod_SQKCtsEPlwf329',
|
||||||
@@ -38,7 +37,16 @@ import React from 'react';
|
|||||||
promotionPrice: null,
|
promotionPrice: null,
|
||||||
stripePriceId: 'price_1RVTXtEPL3QASpovaWjXf30q',
|
stripePriceId: 'price_1RVTXtEPL3QASpovaWjXf30q',
|
||||||
description: 'Un texte de chanson unique, écrit sur mesure pour vous.',
|
description: 'Un texte de chanson unique, écrit sur mesure pour vous.',
|
||||||
imageUrl: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/images//texte-personalise.jpg'
|
imageUrl: 'https://files.dites-le-en-chanson.fr/products/texte-personalise.jpg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prod_SQMCFqFLaNHxx0',
|
||||||
|
name: 'Test',
|
||||||
|
price: 17.90,
|
||||||
|
promotionPrice: null,
|
||||||
|
stripePriceId: 'price_1RVVUBERAUBjYKpgYuQyuI76',
|
||||||
|
description: 'Un texte de chanson unique, écrit sur mesure pour vous.',
|
||||||
|
imageUrl: 'https://files.dites-le-en-chanson.fr/products/texte-personalise.jpg'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
6
src/src/lib/supabaseClient.js
Normal file
6
src/src/lib/supabaseClient.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||||
@@ -17,6 +17,18 @@ import React, { useEffect, useState } from 'react';
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Empêcher l'exécution multiple (React StrictMode en dev)
|
||||||
|
// Utiliser sessionId comme clé pour tracker les sessions déjà traitées
|
||||||
|
const processedKey = `processed_${sessionId}`;
|
||||||
|
if (!sessionId || sessionStorage.getItem(processedKey)) {
|
||||||
|
if (!sessionId) {
|
||||||
|
setMainStatus({ type: 'error', message: "Session de paiement invalide ou manquante." });
|
||||||
|
toast({ title: "Erreur", description: "Session de paiement invalide.", variant: "destructive" });
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const processOrder = async () => {
|
const processOrder = async () => {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
setMainStatus({ type: 'error', message: "Session de paiement invalide ou manquante." });
|
setMainStatus({ type: 'error', message: "Session de paiement invalide ou manquante." });
|
||||||
@@ -36,8 +48,36 @@ import React, { useEffect, useState } from 'react';
|
|||||||
const storedOrderData = JSON.parse(storedOrderDataString);
|
const storedOrderData = JSON.parse(storedOrderDataString);
|
||||||
setOrderDetails(storedOrderData);
|
setOrderDetails(storedOrderData);
|
||||||
|
|
||||||
|
// Récupérer les données formatées pour la DB si disponibles, sinon transformer depuis storedOrderData
|
||||||
|
const orderDataForDBString = localStorage.getItem('orderDataForDB');
|
||||||
|
let orderDataForDB;
|
||||||
|
|
||||||
|
if (orderDataForDBString) {
|
||||||
|
orderDataForDB = JSON.parse(orderDataForDBString);
|
||||||
|
} else {
|
||||||
|
// Fallback: transformer depuis storedOrderData (format camelCase)
|
||||||
|
orderDataForDB = {
|
||||||
|
product_name: storedOrderData.productName || '',
|
||||||
|
price: storedOrderData.price || 0,
|
||||||
|
recipient_name: storedOrderData.recipientName || '',
|
||||||
|
song_for_whom: storedOrderData.songForWhom || '',
|
||||||
|
occasion: storedOrderData.occasion || '',
|
||||||
|
language: storedOrderData.language || '',
|
||||||
|
anecdote1: storedOrderData.anecdote1 || '',
|
||||||
|
anecdote2: storedOrderData.anecdote2 || '',
|
||||||
|
anecdote3: storedOrderData.anecdote3 || '',
|
||||||
|
voice_gender: storedOrderData.voiceGender || '',
|
||||||
|
musical_style: storedOrderData.musicalStyle || '',
|
||||||
|
mood: storedOrderData.mood || '',
|
||||||
|
customer_email: storedOrderData.email || storedOrderData.customer_email || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marquer comme en cours de traitement AVANT l'appel API
|
||||||
|
sessionStorage.setItem(processedKey, 'true');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = { orderData: { ...storedOrderData }, sessionId: sessionId };
|
const payload = { orderData: orderDataForDB, sessionId: sessionId };
|
||||||
const { data: functionResponse, error: functionError } = await supabase.functions.invoke('send-order-confirmation-email', {
|
const { data: functionResponse, error: functionError } = await supabase.functions.invoke('send-order-confirmation-email', {
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
@@ -98,13 +138,14 @@ import React, { useEffect, useState } from 'react';
|
|||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
|
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
|
||||||
|
localStorage.removeItem('orderDataForDB');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
processOrder();
|
processOrder();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [sessionId, toast]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const handleReturnHome = () => {
|
const handleReturnHome = () => {
|
||||||
localStorage.removeItem('songOrderForm');
|
localStorage.removeItem('songOrderForm');
|
||||||
@@ -8,35 +8,35 @@ const creations = [
|
|||||||
type: 'image',
|
type: 'image',
|
||||||
title: 'Pochette d\'Album Conceptuelle',
|
title: 'Pochette d\'Album Conceptuelle',
|
||||||
description: 'Une image de démonstration pour illustrer une pochette ou un visuel accompagnant une chanson.',
|
description: 'Une image de démonstration pour illustrer une pochette ou un visuel accompagnant une chanson.',
|
||||||
url: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/demo//exemple.jpg',
|
url: 'https://files.dites-le-en-chanson.fr/demo/exemple.jpg',
|
||||||
icon: <ImageIcon className="h-8 w-8 text-primary" />
|
icon: <ImageIcon className="h-8 w-8 text-primary" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'audio',
|
type: 'audio',
|
||||||
title: 'Extrait Musical Personnalisé',
|
title: 'Extrait Musical Personnalisé',
|
||||||
description: 'Écoutez un exemple de création audio, une mélodie entraînante et unique.',
|
description: 'Écoutez un exemple de création audio, une mélodie entraînante et unique.',
|
||||||
url: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/demo//exemple1.mp3',
|
url: 'https://files.dites-le-en-chanson.fr/demo/exemple1.mp3',
|
||||||
icon: <Music className="h-8 w-8 text-primary" />
|
icon: <Music className="h-8 w-8 text-primary" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'video',
|
type: 'video',
|
||||||
title: 'Clip Vidéo Émotionnel',
|
title: 'Clip Vidéo Émotionnel',
|
||||||
description: 'Visualisez un exemple de montage vidéo accompagnant une chanson, capturant l\'essence du message.',
|
description: 'Visualisez un exemple de montage vidéo accompagnant une chanson, capturant l\'essence du message.',
|
||||||
url: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/demo//exemple2.mp4',
|
url: 'https://files.dites-le-en-chanson.fr/demo/exemple2.mp4',
|
||||||
icon: <Video className="h-8 w-8 text-primary" />
|
icon: <Video className="h-8 w-8 text-primary" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'video',
|
type: 'video',
|
||||||
title: 'Vidéo Lyrique Animée',
|
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.',
|
description: 'Un autre exemple de vidéo, peut-être avec des paroles animées ou un style visuel différent.',
|
||||||
url: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/demo//exemple3.mp4',
|
url: 'https://files.dites-le-en-chanson.fr/demo/exemple3.mp4',
|
||||||
icon: <Video className="h-8 w-8 text-primary" />
|
icon: <Video className="h-8 w-8 text-primary" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'video',
|
type: 'video',
|
||||||
title: 'Performance Acoustique Filmée',
|
title: 'Performance Acoustique Filmée',
|
||||||
description: 'Découvrez une performance simple et touchante, mettant en valeur la voix et la mélodie.',
|
description: 'Découvrez une performance simple et touchante, mettant en valeur la voix et la mélodie.',
|
||||||
url: 'https://zneurmyahctnnzglnptl.supabase.co/storage/v1/object/public/demo//exemple4.mp4',
|
url: 'https://files.dites-le-en-chanson.fr/demo/exemple4.mp4',
|
||||||
icon: <Video className="h-8 w-8 text-primary" />
|
icon: <Video className="h-8 w-8 text-primary" />
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -95,11 +95,11 @@ const CreationsPage = () => {
|
|||||||
<div>
|
<div>
|
||||||
{creation.type === 'image' && (
|
{creation.type === 'image' && (
|
||||||
<div className="aspect-video w-full overflow-hidden rounded-md mb-4 border border-border">
|
<div className="aspect-video w-full overflow-hidden rounded-md mb-4 border border-border">
|
||||||
<img-replace src={creation.url} alt={creation.title} className="w-full h-full object-cover transition-transform duration-300 hover:scale-105" />
|
<img src={creation.url} alt={creation.title} className="w-full h-full object-cover transition-transform duration-300 hover:scale-105" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{creation.type === 'audio' && (
|
{creation.type === 'audio' && (
|
||||||
<audio controls className="w-full my-4 rounded-md shadow-sm">
|
<audio controls className="w-full my-16 rounded-md shadow-sm">
|
||||||
<source src={creation.url} type="audio/mpeg" />
|
<source src={creation.url} type="audio/mpeg" />
|
||||||
Votre navigateur ne supporte pas l'élément audio.
|
Votre navigateur ne supporte pas l'élément audio.
|
||||||
</audio>
|
</audio>
|
||||||
374
src/src/pages/OrderPage.jsx
Normal file
374
src/src/pages/OrderPage.jsx
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { ChevronLeft, ChevronRight, ShoppingCart } from 'lucide-react';
|
||||||
|
import Step0ProductSelection from '@/components/order/Step0ProductSelection';
|
||||||
|
import Step1Content from '@/components/order/Step1Content';
|
||||||
|
import Step2MusicalChoices from '@/components/order/Step2MusicalChoices';
|
||||||
|
import Step3Review from '@/components/order/Step3Review';
|
||||||
|
import {
|
||||||
|
initialFormData,
|
||||||
|
stepsConfig,
|
||||||
|
formValidations,
|
||||||
|
products,
|
||||||
|
STRIPE_PUBLISHABLE_KEY
|
||||||
|
} from '@/config/orderFormConfig';
|
||||||
|
import { loadStripe } from '@stripe/stripe-js';
|
||||||
|
import { supabase } from '@/lib/supabaseClient';
|
||||||
|
|
||||||
|
const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY);
|
||||||
|
|
||||||
|
const OrderPage = () => {
|
||||||
|
const [ currentStep, setCurrentStep ] = useState(0);
|
||||||
|
const [ formData, setFormData ] = useState(() => {
|
||||||
|
const savedData = localStorage.getItem('songOrderForm');
|
||||||
|
if (savedData) {
|
||||||
|
const parsedData = JSON.parse(savedData);
|
||||||
|
const selectedProductDetails = products.find(p => p.id === parsedData.selectedProduct);
|
||||||
|
if (!selectedProductDetails) {
|
||||||
|
return {...initialFormData, selectedProduct: initialFormData.selectedProduct, price: 0, stripePriceId: null};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...parsedData,
|
||||||
|
price: selectedProductDetails.promotionPrice || selectedProductDetails.price,
|
||||||
|
stripePriceId: selectedProductDetails.stripePriceId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return initialFormData;
|
||||||
|
});
|
||||||
|
const [ errors, setErrors ] = useState({});
|
||||||
|
const {toast} = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [ isProcessingPayment, setIsProcessingPayment ] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('songOrderForm', JSON.stringify(formData));
|
||||||
|
}, [ formData ]);
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const {name, value} = e.target;
|
||||||
|
setFormData(prev => ({...prev, [name]: value}));
|
||||||
|
if (errors[name]) {
|
||||||
|
setErrors(prev => ({...prev, [name]: null}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectChange = (name, value) => {
|
||||||
|
setFormData(prev => ({...prev, [name]: value}));
|
||||||
|
if (errors[name]) {
|
||||||
|
setErrors(prev => ({...prev, [name]: null}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRadioGroupChange = (name, value) => {
|
||||||
|
setFormData(prev => ({...prev, [name]: value}));
|
||||||
|
if (errors[name]) {
|
||||||
|
setErrors(prev => ({...prev, [name]: null}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProductSelect = (productId) => {
|
||||||
|
const selectedProductDetails = products.find(p => p.id === productId);
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
selectedProduct: productId,
|
||||||
|
price: selectedProductDetails?.promotionPrice || selectedProductDetails?.price || 0,
|
||||||
|
stripePriceId: selectedProductDetails?.stripePriceId
|
||||||
|
}));
|
||||||
|
if (errors.selectedProduct) {
|
||||||
|
setErrors(prev => ({...prev, selectedProduct: null}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateStep = () => {
|
||||||
|
const currentStepId = stepsConfig[currentStep].id;
|
||||||
|
const stepFields = formValidations[currentStepId];
|
||||||
|
let newErrors = {};
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
if (stepFields) {
|
||||||
|
for (const field of stepFields) {
|
||||||
|
const value = formData[field.name];
|
||||||
|
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||||
|
newErrors[field.name] = field.message;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
if (!isValid && Object.keys(newErrors).length > 0) {
|
||||||
|
const firstErrorField = Object.keys(newErrors)[0];
|
||||||
|
toast({title: "Champ requis", description: newErrors[firstErrorField], variant: "destructive"});
|
||||||
|
}
|
||||||
|
return isValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStep = () => {
|
||||||
|
if (validateStep()) {
|
||||||
|
setCurrentStep(prev => Math.min(prev + 1, stepsConfig.length - 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const prevStep = () => {
|
||||||
|
setErrors({});
|
||||||
|
setCurrentStep(prev => Math.max(prev - 1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validateStep() || !formData.selectedProduct || !formData.stripePriceId) {
|
||||||
|
toast({
|
||||||
|
title: "Erreur de commande",
|
||||||
|
description: "Veuillez sélectionner un produit et remplir tous les champs requis avant de continuer.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessingPayment(true);
|
||||||
|
toast({
|
||||||
|
title: "Préparation du paiement...",
|
||||||
|
description: "Vous allez être redirigé vers Stripe.",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stripe = await stripePromise;
|
||||||
|
|
||||||
|
if (!stripe) {
|
||||||
|
throw new Error("Stripe n'a pas pu être initialisé. Vérifiez votre clé publique.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedProductDetails = products.find(p => p.id === formData.selectedProduct);
|
||||||
|
|
||||||
|
if (!selectedProductDetails || !selectedProductDetails.stripePriceId || selectedProductDetails.stripePriceId.includes('YOUR_')) {
|
||||||
|
toast({
|
||||||
|
title: "Erreur de configuration produit",
|
||||||
|
description: "L'ID de prix Stripe pour ce produit n'est pas correctement configuré. Veuillez me fournir les bons Price IDs.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
setIsProcessingPayment(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalPrice = selectedProductDetails.promotionPrice || selectedProductDetails.price;
|
||||||
|
|
||||||
|
// Transformer les données pour correspondre à la structure de la table orders
|
||||||
|
const orderDataForDB = {
|
||||||
|
product_name: selectedProductDetails?.name,
|
||||||
|
price: finalPrice,
|
||||||
|
recipient_name: formData.recipientName,
|
||||||
|
song_for_whom: formData.songForWhom,
|
||||||
|
occasion: formData.occasion,
|
||||||
|
language: formData.language,
|
||||||
|
anecdote1: formData.anecdote1,
|
||||||
|
anecdote2: formData.anecdote2,
|
||||||
|
anecdote3: formData.anecdote3,
|
||||||
|
voice_gender: formData.voiceGender,
|
||||||
|
musical_style: formData.musicalStyle,
|
||||||
|
mood: formData.mood,
|
||||||
|
customer_email: formData.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Garder aussi les données en camelCase pour l'affichage dans ConfirmationPage
|
||||||
|
const finalFormDataForConfirmation = {
|
||||||
|
...formData,
|
||||||
|
productName: selectedProductDetails?.name,
|
||||||
|
price: finalPrice,
|
||||||
|
email: formData.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('confirmedOrderDataForConfirmationPage', JSON.stringify(finalFormDataForConfirmation));
|
||||||
|
localStorage.setItem('orderDataForDB', JSON.stringify(orderDataForDB));
|
||||||
|
|
||||||
|
// Créer la session Checkout via une fonction Supabase Edge
|
||||||
|
console.log("Création de la session Checkout Stripe...");
|
||||||
|
|
||||||
|
const {data: sessionData, error: sessionError} = await supabase.functions.invoke('create-checkout-session', {
|
||||||
|
body: JSON.stringify({
|
||||||
|
priceId: selectedProductDetails.stripePriceId,
|
||||||
|
quantity: 1,
|
||||||
|
customerEmail: formData.email,
|
||||||
|
successUrl: `${window.location.origin}/confirmation?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancelUrl: `${window.location.origin}/commander`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionError) {
|
||||||
|
console.error("Erreur lors de la création de la session:", sessionError);
|
||||||
|
toast({
|
||||||
|
title: "Erreur de paiement",
|
||||||
|
description: sessionError.message || "Impossible de créer la session de paiement. Veuillez réessayer.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
|
||||||
|
localStorage.removeItem('orderDataForDB');
|
||||||
|
setIsProcessingPayment(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier la structure de la réponse
|
||||||
|
console.log("Réponse complète de la fonction Edge:", sessionData);
|
||||||
|
|
||||||
|
// La réponse peut être directement sessionId ou dans un objet
|
||||||
|
const sessionId = sessionData?.sessionId || sessionData?.id || sessionData;
|
||||||
|
|
||||||
|
if (!sessionId || typeof sessionId !== 'string') {
|
||||||
|
console.error("Aucune session ID valide retournée:", sessionData);
|
||||||
|
toast({
|
||||||
|
title: "Erreur de paiement",
|
||||||
|
description: "La session de paiement n'a pas pu être créée. Veuillez réessayer.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
|
||||||
|
localStorage.removeItem('orderDataForDB');
|
||||||
|
setIsProcessingPayment(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rediriger vers Stripe avec le sessionId
|
||||||
|
console.log("Redirection vers Stripe avec sessionId:", sessionId);
|
||||||
|
|
||||||
|
// Vérifier si on a une URL de checkout dans la réponse
|
||||||
|
const checkoutUrl = sessionData?.url || sessionData?.checkoutUrl;
|
||||||
|
|
||||||
|
if (checkoutUrl) {
|
||||||
|
// Si on a l'URL directement, l'utiliser
|
||||||
|
console.log("Redirection vers l'URL de checkout fournie:", checkoutUrl);
|
||||||
|
window.location.href = checkoutUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinon, utiliser redirectToCheckout de Stripe.js
|
||||||
|
try {
|
||||||
|
// Mémoriser l'URL actuelle pour détecter si la redirection a eu lieu
|
||||||
|
const currentUrl = window.location.href;
|
||||||
|
|
||||||
|
const {error: redirectError} = await stripe.redirectToCheckout({
|
||||||
|
sessionId: sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (redirectError) {
|
||||||
|
console.error("Erreur Stripe lors de la redirection:", redirectError);
|
||||||
|
toast({
|
||||||
|
title: "Erreur de paiement",
|
||||||
|
description: redirectError.message || "Une erreur est survenue lors de la redirection vers Stripe.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
|
||||||
|
localStorage.removeItem('orderDataForDB');
|
||||||
|
setIsProcessingPayment(false);
|
||||||
|
} else {
|
||||||
|
// La redirection devrait se produire
|
||||||
|
console.log("Redirection initiée avec succès via redirectToCheckout");
|
||||||
|
|
||||||
|
// Vérifier après un court délai si la redirection a réellement eu lieu
|
||||||
|
// (fallback si redirectToCheckout ne fonctionne pas silencieusement)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (window.location.href === currentUrl && document.visibilityState === 'visible') {
|
||||||
|
console.warn("La redirection automatique n'a pas fonctionné, tentative alternative...");
|
||||||
|
// Essayer une redirection manuelle via l'API Stripe
|
||||||
|
// Note: Cette approche nécessite que la fonction Edge retourne l'URL de checkout
|
||||||
|
// Pour l'instant, on affiche un message à l'utilisateur
|
||||||
|
toast({
|
||||||
|
title: "Redirection en cours...",
|
||||||
|
description: "Si la redirection ne fonctionne pas, veuillez cliquer sur le lien de paiement.",
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
setIsProcessingPayment(false);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
} catch (redirectErr) {
|
||||||
|
console.error("Exception lors de la redirection:", redirectErr);
|
||||||
|
toast({
|
||||||
|
title: "Erreur de paiement",
|
||||||
|
description: redirectErr.message || "Une erreur est survenue lors de la redirection vers Stripe.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
|
||||||
|
localStorage.removeItem('orderDataForDB');
|
||||||
|
setIsProcessingPayment(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erreur lors de la préparation du paiement:", err);
|
||||||
|
toast({
|
||||||
|
title: "Erreur de paiement",
|
||||||
|
description: err.message || "Une erreur inattendue est survenue.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
localStorage.removeItem('confirmedOrderDataForConfirmationPage');
|
||||||
|
localStorage.removeItem('orderDataForDB');
|
||||||
|
setIsProcessingPayment(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressPercentage = ((currentStep + 1) / stepsConfig.length) * 100;
|
||||||
|
const currentSelectedProduct = formData.selectedProduct ? products.find(p => p.id === formData.selectedProduct) : null;
|
||||||
|
const currentPriceToPay = currentSelectedProduct ? (currentSelectedProduct.promotionPrice || currentSelectedProduct.price) : 0;
|
||||||
|
|
||||||
|
|
||||||
|
const renderStepContent = () => {
|
||||||
|
const stepId = stepsConfig[currentStep].id;
|
||||||
|
switch (stepId) {
|
||||||
|
case 'productSelection':
|
||||||
|
return <Step0ProductSelection selectedProduct={formData.selectedProduct} onProductSelect={handleProductSelect}
|
||||||
|
error={errors.selectedProduct}/>;
|
||||||
|
case 'songInfo':
|
||||||
|
return <Step1Content formData={formData} handleChange={handleChange} handleSelectChange={handleSelectChange}
|
||||||
|
errors={errors}/>;
|
||||||
|
case 'musicalChoices':
|
||||||
|
return <Step2MusicalChoices formData={formData} handleSelectChange={handleSelectChange}
|
||||||
|
handleRadioGroupChange={handleRadioGroupChange} errors={errors}/>;
|
||||||
|
case 'review':
|
||||||
|
return <Step3Review formData={formData} handleChange={handleChange} errors={errors}/>;
|
||||||
|
default:
|
||||||
|
return <div>Étape inconnue</div>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto py-8">
|
||||||
|
<Card className="shadow-2xl bg-card/80 backdrop-blur-md">
|
||||||
|
<CardHeader className="text-center border-b pb-4">
|
||||||
|
<div className="flex items-center justify-center text-3xl font-bold mb-2">
|
||||||
|
{stepsConfig[currentStep].icon}
|
||||||
|
<h1 className="gradient-text">{stepsConfig[currentStep].title}</h1>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Étape {currentStep + 1} sur {stepsConfig.length}</CardDescription>
|
||||||
|
<Progress value={progressPercentage} className="mt-4 h-3"/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6 md:p-10 min-h-[300px]">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{renderStepContent()}
|
||||||
|
</AnimatePresence>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex justify-between p-6 border-t">
|
||||||
|
<Button variant="outline" onClick={prevStep} disabled={currentStep === 0 || isProcessingPayment}
|
||||||
|
className="text-base px-6 py-3">
|
||||||
|
<ChevronLeft className="h-5 w-5 mr-2"/>
|
||||||
|
Précédent
|
||||||
|
</Button>
|
||||||
|
{currentStep < stepsConfig.length - 1 ? (
|
||||||
|
<Button onClick={nextStep} disabled={isProcessingPayment}
|
||||||
|
className="text-base px-6 py-3 bg-gradient-to-r from-primary to-accent hover:opacity-90 transition-opacity">
|
||||||
|
Suivant
|
||||||
|
<ChevronRight className="h-5 w-5 ml-2"/>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleSubmit} disabled={isProcessingPayment}
|
||||||
|
className="text-base px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-600 hover:opacity-90 transition-opacity">
|
||||||
|
{isProcessingPayment ? "Traitement..." : `Payer (${currentPriceToPay ? currentPriceToPay.toFixed(2) : '0.00'} €)`}
|
||||||
|
{!isProcessingPayment && <ShoppingCart className="h-5 w-5 ml-2"/>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrderPage;
|
||||||
@@ -151,8 +151,7 @@ function main() {
|
|||||||
let pages = [];
|
let pages = [];
|
||||||
|
|
||||||
if (!fs.existsSync(pagesDir)) {
|
if (!fs.existsSync(pagesDir)) {
|
||||||
pages.push(processPageFile(appJsxPath, []))
|
pages.push(processPageFile(appJsxPath, []));
|
||||||
pages = pages.filter(Boolean);
|
|
||||||
} else {
|
} else {
|
||||||
const routes = extractRoutes(appJsxPath);
|
const routes = extractRoutes(appJsxPath);
|
||||||
const reactFiles = findReactFiles(pagesDir);
|
const reactFiles = findReactFiles(pagesDir);
|
||||||
@@ -160,12 +159,12 @@ function main() {
|
|||||||
pages = reactFiles
|
pages = reactFiles
|
||||||
.map(filePath => processPageFile(filePath, routes))
|
.map(filePath => processPageFile(filePath, routes))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
|
||||||
|
|
||||||
if (pages.length === 0) {
|
if (pages.length === 0) {
|
||||||
console.error('❌ No pages with Helmet components found!');
|
console.error('❌ No pages with Helmet components found!');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const llmsTxtContent = generateLlmsTxt(pages);
|
const llmsTxtContent = generateLlmsTxt(pages);
|
||||||
@@ -4,7 +4,6 @@ import { createLogger, defineConfig } from 'vite';
|
|||||||
import inlineEditPlugin from './plugins/visual-editor/vite-plugin-react-inline-editor.js';
|
import inlineEditPlugin from './plugins/visual-editor/vite-plugin-react-inline-editor.js';
|
||||||
import editModeDevPlugin from './plugins/visual-editor/vite-plugin-edit-mode.js';
|
import editModeDevPlugin from './plugins/visual-editor/vite-plugin-edit-mode.js';
|
||||||
import iframeRouteRestorationPlugin from './plugins/vite-plugin-iframe-route-restoration.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 isDev = process.env.NODE_ENV !== 'production';
|
||||||
|
|
||||||
@@ -236,7 +235,7 @@ logger.error = (msg, options) => {
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
customLogger: logger,
|
customLogger: logger,
|
||||||
plugins: [
|
plugins: [
|
||||||
...(isDev ? [inlineEditPlugin(), editModeDevPlugin(), iframeRouteRestorationPlugin(), selectionModePlugin()] : []),
|
...(isDev ? [inlineEditPlugin(), editModeDevPlugin(), iframeRouteRestorationPlugin()] : []),
|
||||||
react(),
|
react(),
|
||||||
addTransformIndexHtml
|
addTransformIndexHtml
|
||||||
],
|
],
|
||||||
Reference in New Issue
Block a user