v1.0
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* DesignTip - AI-powered design advice card that fades in below the 3D preview.
|
||||
* Shows contextual tips, compliments, and trends based on current configuration.
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import styles from './DesignTip.module.css';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
const CATEGORY_ICONS = {
|
||||
compliment: '✨',
|
||||
conseil: '💡',
|
||||
tendance: '🎯'
|
||||
};
|
||||
|
||||
export default function DesignTip({ config }) {
|
||||
const [tip, setTip] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const debounceRef = useRef(null);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
const fetchTip = useCallback(async (cfg) => {
|
||||
// Cancel previous request
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const body = {
|
||||
config: {
|
||||
width: cfg.totalWidth,
|
||||
height: cfg.height,
|
||||
depth: cfg.depth,
|
||||
installationType: cfg.installationType,
|
||||
columns: cfg.modules?.map(m => ({
|
||||
type: m.type || m.moduleCode,
|
||||
width: m.width,
|
||||
shelves: m.shelves,
|
||||
drawers: m.drawers,
|
||||
hasRod: m.hasRod
|
||||
})),
|
||||
materialName: cfg.finishLabel || null,
|
||||
doorType: cfg.doorType
|
||||
}
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ai/design-tips`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('API error');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.tip) {
|
||||
setTip(data);
|
||||
setVisible(true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
// Silently fail — tip is optional
|
||||
setVisible(false);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debounced fetch on config change
|
||||
useEffect(() => {
|
||||
if (!config || !config.modules?.length) return;
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
fetchTip(config);
|
||||
}, 2000); // 2s debounce to avoid spamming on rapid changes
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [
|
||||
config?.totalWidth,
|
||||
config?.height,
|
||||
config?.modules?.length,
|
||||
config?.modules?.map(m => m.type).join(','),
|
||||
config?.finishId,
|
||||
config?.doorType,
|
||||
fetchTip
|
||||
]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!visible && !loading) return null;
|
||||
|
||||
return (
|
||||
<div className={`${styles.container} ${visible && tip ? styles.visible : ''}`}>
|
||||
{loading && !tip && (
|
||||
<div className={styles.shimmer}>
|
||||
<div className={styles.shimmerLine} />
|
||||
<div className={styles.shimmerLineShort} />
|
||||
</div>
|
||||
)}
|
||||
{tip && (
|
||||
<div className={styles.tipContent}>
|
||||
<span className={styles.icon}>{CATEGORY_ICONS[tip.category] || '✨'}</span>
|
||||
<p className={styles.text}>{tip.tip}</p>
|
||||
<span className={styles.badge}>IA</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user