feat(billing): implement robust in-app subscription cancellation & fix CI/CD socket port typo
This commit is contained in:
@@ -24,7 +24,7 @@ interface BillingStatus {
|
||||
hasStripeSubscription: boolean;
|
||||
}
|
||||
|
||||
const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true';
|
||||
const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development';
|
||||
|
||||
let stripePromise: ReturnType<typeof loadStripe> | null = null;
|
||||
function getStripePromise() {
|
||||
@@ -43,12 +43,14 @@ export function BillingPlans() {
|
||||
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
|
||||
const [checkoutLoading, setCheckoutLoading] = useState<Tier | null>(null);
|
||||
const [portalLoading, setPortalLoading] = useState(false);
|
||||
const [cancelLoading, setCancelLoading] = useState(false);
|
||||
const [successBanner, setSuccessBanner] = useState<string | null>(null);
|
||||
|
||||
const { data: status, isLoading } = useQuery<BillingStatus>({
|
||||
queryKey: ['billing', 'status'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/billing/status');
|
||||
const search = typeof window !== 'undefined' ? window.location.search : '';
|
||||
const res = await fetch(`/api/billing/status${search}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch billing status');
|
||||
return res.json();
|
||||
},
|
||||
@@ -69,6 +71,7 @@ export function BillingPlans() {
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const sessionId = params.get('session_id');
|
||||
|
||||
if (sessionId) {
|
||||
const tier = status?.effectiveTier ?? 'Pro';
|
||||
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
|
||||
@@ -112,10 +115,15 @@ export function BillingPlans() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePortal = async () => {
|
||||
const handlePortal = async (action: 'portal' | 'cancel' | React.MouseEvent = 'portal') => {
|
||||
const actualAction = typeof action === 'string' ? action : 'portal';
|
||||
setPortalLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/billing/portal', { method: 'POST' });
|
||||
const res = await fetch('/api/billing/portal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: actualAction }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || 'Failed to open billing portal.');
|
||||
@@ -130,6 +138,34 @@ export function BillingPlans() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelSubscription = async () => {
|
||||
const confirmMsg = t('billing.cancelConfirm') || "Êtes-vous sûr de vouloir résilier votre abonnement ? Vous conserverez vos accès Pro/Business jusqu'à la fin de la période en cours.";
|
||||
if (!window.confirm(confirmMsg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCancelLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/billing/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || 'Failed to cancel subscription.');
|
||||
return;
|
||||
}
|
||||
toast.success(t('billing.cancelSuccess') || "Votre abonnement a été résilié avec succès. Il prendra fin à la fin de la période de facturation en cours.");
|
||||
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
||||
} catch (err) {
|
||||
console.error('[BillingPlans] cancel error:', err);
|
||||
toast.error('Failed to cancel subscription.');
|
||||
} finally {
|
||||
setCancelLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckoutComplete = useCallback(() => {
|
||||
setIsCheckoutOpen(false);
|
||||
setCheckoutClientSecret(null);
|
||||
@@ -310,38 +346,42 @@ export function BillingPlans() {
|
||||
{effectiveTier === 'BASIC' ? t('billing.freePlan') : effectiveTier === 'PRO' ? t('billing.proPlan') : effectiveTier === 'BUSINESS' ? t('billing.businessPlan') : t('billing.enterprisePlan')}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<span className={cn(
|
||||
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
|
||||
status?.status === 'active' || status?.status === 'ACTIVE'
|
||||
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20'
|
||||
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
|
||||
)}>
|
||||
{status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
|
||||
</span>
|
||||
</div>
|
||||
{isPaid && (
|
||||
<div className="ml-auto">
|
||||
<span className={cn(
|
||||
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
|
||||
status?.status === 'active' || status?.status === 'ACTIVE'
|
||||
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20'
|
||||
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
|
||||
)}>
|
||||
{status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-concrete uppercase tracking-wider">{t('billing.billingPeriod')}</span>
|
||||
<p className="text-xs font-semibold text-ink">
|
||||
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
|
||||
`${formatDate(status.currentPeriodStart)} – ${formatDate(status.currentPeriodEnd)}`
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</p>
|
||||
{isPaid && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-concrete uppercase tracking-wider">{t('billing.billingPeriod')}</span>
|
||||
<p className="text-xs font-semibold text-ink">
|
||||
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
|
||||
`${formatDate(status.currentPeriodStart)} – ${formatDate(status.currentPeriodEnd)}`
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-concrete uppercase tracking-wider">
|
||||
{status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')}
|
||||
</span>
|
||||
<p className="text-xs font-semibold text-ink">
|
||||
{status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-concrete uppercase tracking-wider">
|
||||
{status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')}
|
||||
</span>
|
||||
<p className="text-xs font-semibold text-ink">
|
||||
{status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPaid && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
@@ -355,13 +395,14 @@ export function BillingPlans() {
|
||||
{t('billing.manageBilling') || 'Gérer la facturation'}
|
||||
</button>
|
||||
|
||||
{status?.hasStripeSubscription && (
|
||||
{status?.hasStripeSubscription && !status?.cancelAtPeriodEnd && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePortal}
|
||||
disabled={portalLoading}
|
||||
onClick={handleCancelSubscription}
|
||||
disabled={cancelLoading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 border border-rose-200 text-rose-600 dark:border-rose-800/40 dark:text-rose-400 hover:bg-rose-50/50 dark:hover:bg-rose-950/15 rounded-xl text-xs font-semibold transition-all"
|
||||
>
|
||||
{cancelLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('billing.cancelSubscription') || "Résilier l'abonnement"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -114,10 +114,10 @@ export function StructuredViewBlockEmbed({
|
||||
if (json.success && Array.isArray(json.data)) {
|
||||
setNotes(json.data)
|
||||
} else {
|
||||
setNotesError(t('structuredViewBlock.loadError') || 'Failed to load notes')
|
||||
setNotesError(t('structuredViewBlock.loadError'))
|
||||
}
|
||||
} catch (e) {
|
||||
setNotesError(e instanceof Error ? e.message : 'Error loading notes')
|
||||
setNotesError(e instanceof Error ? e.message : t('structuredViewBlock.notesLoadError'))
|
||||
} finally {
|
||||
setNotesLoading(false)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export function StructuredViewBlockEmbed({
|
||||
const newId = `col-${Date.now()}`
|
||||
const newCol: LocalColumn = {
|
||||
id: newId,
|
||||
name: `Propriété ${localColumns.length + 1}`,
|
||||
name: t('structuredViewBlock.propertyName', { index: localColumns.length + 1 }),
|
||||
type: 'text',
|
||||
}
|
||||
const updatedCols = [...localColumns, newCol]
|
||||
@@ -166,7 +166,7 @@ export function StructuredViewBlockEmbed({
|
||||
values: { ...row.values, [newId]: '' }
|
||||
}))
|
||||
updateLocalData(updatedCols, updatedRows)
|
||||
toast.success('Colonne ajoutée !')
|
||||
toast.success(t('structuredViewBlock.columnAdded'))
|
||||
}
|
||||
|
||||
const deleteLocalColumn = (colId: string) => {
|
||||
@@ -177,7 +177,7 @@ export function StructuredViewBlockEmbed({
|
||||
return { ...row, values: nextValues }
|
||||
})
|
||||
updateLocalData(updatedCols, updatedRows)
|
||||
toast.success('Colonne supprimée')
|
||||
toast.success(t('structuredViewBlock.columnRemoved'))
|
||||
}
|
||||
|
||||
const updateLocalColumnName = (colId: string, name: string) => {
|
||||
@@ -190,7 +190,7 @@ export function StructuredViewBlockEmbed({
|
||||
if (c.id === colId) {
|
||||
const next: LocalColumn = { ...c, type }
|
||||
if (type === 'select') {
|
||||
next.options = ['Option 1', 'Option 2']
|
||||
next.options = [t('structuredViewBlock.defaultOption1'), t('structuredViewBlock.defaultOption2')]
|
||||
} else {
|
||||
delete next.options
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export function StructuredViewBlockEmbed({
|
||||
const handleConvertToNotebook = async () => {
|
||||
const name = newNotebookName.trim()
|
||||
if (!name) {
|
||||
toast.error('Veuillez entrer un nom pour le carnet.')
|
||||
toast.error(t('structuredViewBlock.convertNotebookNameRequired'))
|
||||
return
|
||||
}
|
||||
setConverting(true)
|
||||
@@ -268,7 +268,7 @@ export function StructuredViewBlockEmbed({
|
||||
})
|
||||
const createdNbJson = await createNbRes.json()
|
||||
if (!createNbRes.ok || !createdNbJson.success) {
|
||||
throw new Error(createdNbJson.error || 'Erreur lors de la création du carnet.')
|
||||
throw new Error(createdNbJson.error || t('structuredViewBlock.convertNotebookError'))
|
||||
}
|
||||
const newNotebook = createdNbJson.data
|
||||
|
||||
@@ -278,7 +278,7 @@ export function StructuredViewBlockEmbed({
|
||||
})
|
||||
const schemaJson = await enableSchemaRes.json()
|
||||
if (!enableSchemaRes.ok || !schemaJson.success) {
|
||||
throw new Error(schemaJson.error || 'Erreur lors de l\'activation de la structure.')
|
||||
throw new Error(schemaJson.error || t('structuredViewBlock.convertSchemaError'))
|
||||
}
|
||||
const schema = schemaJson.data.schema
|
||||
|
||||
@@ -300,7 +300,7 @@ export function StructuredViewBlockEmbed({
|
||||
})
|
||||
const propJson = await propRes.json()
|
||||
if (!propRes.ok || !propJson.success) {
|
||||
throw new Error(propJson.error || 'Erreur d\'ajout de propriété.')
|
||||
throw new Error(propJson.error || t('structuredViewBlock.convertPropertyError'))
|
||||
}
|
||||
// Find added property ID in the updated schema
|
||||
const addedProp = propJson.data.schema.properties.find((p: any) => p.name === col.name)
|
||||
@@ -311,7 +311,7 @@ export function StructuredViewBlockEmbed({
|
||||
|
||||
// 4. Create Notes for each row
|
||||
for (const row of localRows) {
|
||||
const titleVal = row.values['col-title'] || 'Sans titre'
|
||||
const titleVal = row.values['col-title'] || t('structuredViewBlock.untitled')
|
||||
const noteRes = await fetch('/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -323,7 +323,7 @@ export function StructuredViewBlockEmbed({
|
||||
})
|
||||
const noteJson = await noteRes.json()
|
||||
if (!noteRes.ok || !noteJson.success) {
|
||||
throw new Error('Erreur de création de note.')
|
||||
throw new Error(t('structuredViewBlock.convertNoteError'))
|
||||
}
|
||||
const createdNote = noteJson.data
|
||||
|
||||
@@ -351,11 +351,11 @@ export function StructuredViewBlockEmbed({
|
||||
})
|
||||
|
||||
await refreshNotebooks()
|
||||
toast.success('Conversion réussie ! Base liée créée.')
|
||||
toast.success(t('structuredViewBlock.convertSuccess'))
|
||||
setShowConvertModal(false)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(e instanceof Error ? e.message : 'Une erreur est survenue.')
|
||||
toast.error(e instanceof Error ? e.message : t('structuredViewBlock.convertGenericError'))
|
||||
} finally {
|
||||
setConverting(false)
|
||||
}
|
||||
@@ -416,7 +416,7 @@ export function StructuredViewBlockEmbed({
|
||||
const q = (titleVal || '').trim()
|
||||
if (!q) {
|
||||
setEchoConnections([])
|
||||
setEchoError("Veuillez d'abord saisir un nom pour cette ligne afin de rechercher des résonances sémantiques.")
|
||||
setEchoError(t('structuredViewBlock.echoNameRequired'))
|
||||
setEchoLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -438,11 +438,11 @@ export function StructuredViewBlockEmbed({
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
setEchoError(`Aucune note correspondante contenant "${q}" n'a été trouvée dans votre espace de travail.`)
|
||||
setEchoError(t('structuredViewBlock.echoNoMatch', { query: q }))
|
||||
} else {
|
||||
setEchoConnections(data.map((n: any, idx: number) => ({
|
||||
noteId: n.id,
|
||||
title: n.title || 'Sans titre',
|
||||
title: n.title || t('structuredViewBlock.untitled'),
|
||||
similarity: Math.round((0.88 - idx * 0.04) * 100),
|
||||
updatedAt: n.updatedAt,
|
||||
isTextMatch: true
|
||||
@@ -450,7 +450,7 @@ export function StructuredViewBlockEmbed({
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setEchoError("Une erreur est survenue lors de la recherche.")
|
||||
setEchoError(t('structuredViewBlock.echoSearchError'))
|
||||
} finally {
|
||||
setEchoLoading(false)
|
||||
}
|
||||
@@ -545,7 +545,7 @@ export function StructuredViewBlockEmbed({
|
||||
return (
|
||||
<div className="p-4 border-b border-border/40 bg-muted/10 text-center text-xs text-muted-foreground italic flex items-center justify-center gap-1.5">
|
||||
<BarChart3 className="w-4 h-4 text-muted-foreground/60 animate-pulse" />
|
||||
<span>{t('structuredViewBlock.analyticsNoData') || 'Aucune donnée d\'analyse disponible.'}</span>
|
||||
<span>{t('structuredViewBlock.analyticsNoData')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -555,10 +555,10 @@ export function StructuredViewBlockEmbed({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-foreground/85">
|
||||
<BarChart3 className="w-4 h-4 text-primary" />
|
||||
<span>{t('structuredViewBlock.analyticsTitle') || 'Analyses & Insights'}</span>
|
||||
<span>{t('structuredViewBlock.analyticsTitle')}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground bg-muted border border-border/60 rounded-md px-2 py-0.5 font-medium">
|
||||
{t('structuredViewBlock.analyticsTotalRows') || 'Total des lignes'} : <span className="font-bold text-foreground">{total}</span>
|
||||
{t('structuredViewBlock.analyticsTotalRows')}: <span className="font-bold text-foreground">{total}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -567,7 +567,7 @@ export function StructuredViewBlockEmbed({
|
||||
{hasCheckboxStats && (
|
||||
<div className="bg-card border border-border/40 rounded-xl p-3 space-y-3 shadow-sm">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground block">
|
||||
{t('structuredViewBlock.analyticsCompletion') || 'Taux de complétion'}
|
||||
{t('structuredViewBlock.analyticsCompletion')}
|
||||
</span>
|
||||
<div className="space-y-2.5">
|
||||
{checkboxStats.map(stat => (
|
||||
@@ -592,7 +592,7 @@ export function StructuredViewBlockEmbed({
|
||||
{hasSelectStats && (
|
||||
<div className="bg-card border border-border/40 rounded-xl p-3 space-y-3 shadow-sm">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground block">
|
||||
{t('structuredViewBlock.analyticsDistribution') || 'Répartition'}
|
||||
{t('structuredViewBlock.analyticsDistribution')}
|
||||
</span>
|
||||
<div className="space-y-3 max-h-[140px] overflow-y-auto custom-scrollbar pr-1">
|
||||
{selectStats.map(stat => {
|
||||
@@ -650,7 +650,7 @@ export function StructuredViewBlockEmbed({
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary animate-pulse" />
|
||||
<span className="font-bold text-foreground/80 tracking-wide">
|
||||
{t('structuredViewBlock.localDbTitle') || 'Base de Données Autonome'}
|
||||
{t('structuredViewBlock.localDbTitle')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -663,7 +663,7 @@ export function StructuredViewBlockEmbed({
|
||||
className="text-[11px] h-7 px-2.5 rounded-lg transition-all flex items-center gap-1.5"
|
||||
>
|
||||
<BarChart3 className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>Analyses</span>
|
||||
<span>{t('structuredViewBlock.analyticsShort')}</span>
|
||||
</Button>
|
||||
|
||||
<span className="h-4 w-px bg-border/50" />
|
||||
@@ -676,7 +676,7 @@ export function StructuredViewBlockEmbed({
|
||||
className="text-[11px] h-7 px-2.5 rounded-lg border-primary/30 hover:border-primary hover:bg-primary/5 text-primary transition-all flex items-center gap-1.5"
|
||||
>
|
||||
<FolderPlus className="w-3.5 h-3.5" />
|
||||
<span>Convertir en carnet</span>
|
||||
<span>{t('structuredViewBlock.convertToNotebook')}</span>
|
||||
</Button>
|
||||
|
||||
<span className="h-4 w-px bg-border/50" />
|
||||
@@ -688,7 +688,7 @@ export function StructuredViewBlockEmbed({
|
||||
onClick={() => updateAttributes({ isLocal: false })}
|
||||
className="text-[11px] h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Lier à un carnet
|
||||
{t('structuredViewBlock.linkToNotebook')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -702,16 +702,16 @@ export function StructuredViewBlockEmbed({
|
||||
<div className="bg-card border border-border rounded-2xl shadow-xl max-w-md w-full p-6 space-y-4">
|
||||
<h3 className="text-sm font-bold flex items-center gap-2">
|
||||
<FolderPlus className="w-5 h-5 text-primary" />
|
||||
Convertir en carnet structuré
|
||||
{t('structuredViewBlock.convertModalTitle')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Ce tableau local va être converti en carnet réel. Chaque ligne deviendra une note de votre carnet, et vos colonnes seront configurées comme propriétés réutilisables.
|
||||
{t('structuredViewBlock.convertModalDesc')}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Nom du nouveau carnet</label>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{t('structuredViewBlock.convertNotebookNameLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ex. Mes lectures, Suivi de projets"
|
||||
placeholder={t('structuredViewBlock.convertNotebookNamePlaceholder')}
|
||||
value={newNotebookName}
|
||||
onChange={(e) => setNewNotebookName(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary"
|
||||
@@ -719,15 +719,15 @@ export function StructuredViewBlockEmbed({
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 text-xs pt-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowConvertModal(false)} disabled={converting}>
|
||||
Annuler
|
||||
{t('structuredViewBlock.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConvertToNotebook} disabled={converting || !newNotebookName}>
|
||||
{converting ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1.5" />
|
||||
Conversion...
|
||||
{t('structuredViewBlock.converting')}
|
||||
</>
|
||||
) : 'Créer le carnet'}
|
||||
) : t('structuredViewBlock.createNotebook')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -758,16 +758,16 @@ export function StructuredViewBlockEmbed({
|
||||
onChange={(e) => updateLocalColumnType(col.id, e.target.value as any)}
|
||||
className="text-[9px] bg-transparent border border-border/30 rounded px-1 py-0.5 text-muted-foreground hover:border-border transition-colors outline-none cursor-pointer"
|
||||
>
|
||||
<option value="text">Texte</option>
|
||||
<option value="checkbox">Case</option>
|
||||
<option value="select">Liste</option>
|
||||
<option value="text">{t('structuredViewBlock.colTypeText')}</option>
|
||||
<option value="checkbox">{t('structuredViewBlock.colTypeCheckbox')}</option>
|
||||
<option value="select">{t('structuredViewBlock.colTypeSelect')}</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Display select options configurations inline */}
|
||||
{col.type === 'select' && (
|
||||
<input
|
||||
placeholder="opts separées par virgule"
|
||||
placeholder={t('structuredViewBlock.selectOptionsPlaceholder')}
|
||||
value={col.options?.join(', ') || ''}
|
||||
onChange={(e) => updateLocalColumnOptions(col.id, e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
@@ -784,7 +784,7 @@ export function StructuredViewBlockEmbed({
|
||||
deleteLocalColumn(col.id)
|
||||
}}
|
||||
className="opacity-40 hover:opacity-100 hover:text-red-500 p-0.5 rounded transition-all ml-auto shrink-0"
|
||||
title="Supprimer la colonne"
|
||||
title={t('structuredViewBlock.deleteColumn')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
@@ -797,7 +797,7 @@ export function StructuredViewBlockEmbed({
|
||||
type="button"
|
||||
onClick={addLocalColumn}
|
||||
className="p-1 rounded bg-primary/10 hover:bg-primary/20 text-primary transition-all"
|
||||
title="Ajouter une colonne"
|
||||
title={t('structuredViewBlock.addColumn')}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -815,7 +815,7 @@ export function StructuredViewBlockEmbed({
|
||||
type="button"
|
||||
onClick={() => deleteLocalRow(row.id)}
|
||||
className="opacity-0 group-hover/row:opacity-100 hover:text-red-500 p-1 rounded hover:bg-red-500/10 transition-all"
|
||||
title="Supprimer la ligne"
|
||||
title={t('structuredViewBlock.deleteRow')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -828,7 +828,7 @@ export function StructuredViewBlockEmbed({
|
||||
? "text-purple-400 bg-purple-500/10 opacity-100"
|
||||
: "hover:text-purple-400 hover:bg-purple-500/10"
|
||||
)}
|
||||
title="Résonances sémantiques"
|
||||
title={t('structuredViewBlock.semanticEcho')}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -875,7 +875,7 @@ export function StructuredViewBlockEmbed({
|
||||
<input
|
||||
type="text"
|
||||
value={cellVal || ''}
|
||||
placeholder={col.id === 'col-title' ? 'Saisir un nom…' : ''}
|
||||
placeholder={col.id === 'col-title' ? t('structuredViewBlock.namePlaceholder') : ''}
|
||||
onChange={(e) => updateLocalCellValue(row.id, col.id, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -902,20 +902,20 @@ export function StructuredViewBlockEmbed({
|
||||
<div className="flex items-center justify-between text-[11px] font-bold text-foreground/80">
|
||||
<span className="flex items-center gap-1.5 text-purple-400">
|
||||
<Brain className="w-3.5 h-3.5 animate-pulse" />
|
||||
{t('structuredViewBlock.echoPopoverTitle') || 'Résonance Sémantique 🔮'}
|
||||
{t('structuredViewBlock.echoPopoverTitle')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setActiveEchoRowId(null); setEchoConnections([]); }}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground hover:underline transition-colors"
|
||||
>
|
||||
Fermer
|
||||
{t('structuredViewBlock.close')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{echoLoading ? (
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground py-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-purple-400" />
|
||||
<span>{t('structuredViewBlock.echoLoading') || 'Recherche de connexions sémantiques...'}</span>
|
||||
<span>{t('structuredViewBlock.echoLoading')}</span>
|
||||
</div>
|
||||
) : echoError ? (
|
||||
<div className="space-y-2 py-1">
|
||||
@@ -923,16 +923,16 @@ export function StructuredViewBlockEmbed({
|
||||
{echoError}
|
||||
</p>
|
||||
<div className="text-[10px] text-muted-foreground/75 bg-muted/30 p-2.5 rounded-lg border border-border/40 max-w-lg">
|
||||
{t('structuredViewBlock.echoUpgradeText') || "Convertissez ce tableau en carnet pour activer l'analyse neuronale de Momento."}
|
||||
{t('structuredViewBlock.echoUpgradeText')}
|
||||
</div>
|
||||
</div>
|
||||
) : echoConnections.length === 0 ? (
|
||||
<div className="space-y-2 py-1">
|
||||
<p className="text-[11px] text-muted-foreground/80 italic">
|
||||
{t('structuredViewBlock.noEchoFound') || 'Aucune résonance sémantique détectée.'}
|
||||
{t('structuredViewBlock.noEchoFound')}
|
||||
</p>
|
||||
<div className="text-[10px] text-muted-foreground/75 bg-muted/30 p-2.5 rounded-lg border border-border/40 max-w-lg">
|
||||
{t('structuredViewBlock.echoUpgradeText') || "Convertissez ce tableau en carnet pour activer l'analyse neuronale de Momento."}
|
||||
{t('structuredViewBlock.echoUpgradeText')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -959,20 +959,20 @@ export function StructuredViewBlockEmbed({
|
||||
window.dispatchEvent(new CustomEvent('memento-insert-citation', {
|
||||
detail: {
|
||||
noteId: conn.noteId,
|
||||
noteTitle: conn.title || 'Sans titre',
|
||||
noteTitle: conn.title || t('structuredViewBlock.untitled'),
|
||||
excerpt: '',
|
||||
atEnd: false
|
||||
}
|
||||
}))
|
||||
toast.success("Citation insérée dans l'éditeur !")
|
||||
toast.success(t('structuredViewBlock.citationInserted'))
|
||||
}}
|
||||
className="p-1 text-muted-foreground hover:text-purple-400 hover:bg-purple-500/10 rounded transition-colors"
|
||||
title="Insérer le lien dans l'éditeur"
|
||||
title={t('structuredViewBlock.insertCitation')}
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="text-[9px] text-purple-400 font-bold bg-purple-500/10 px-2 py-0.5 rounded-full flex items-center gap-0.5 border border-purple-500/20">
|
||||
{conn.isTextMatch ? 'Mot-clé' : `${conn.similarity}%`}
|
||||
{conn.isTextMatch ? t('structuredViewBlock.keywordMatch') : `${conn.similarity}%`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -990,7 +990,7 @@ export function StructuredViewBlockEmbed({
|
||||
</table>
|
||||
|
||||
{localRows.length === 0 && (
|
||||
<p className="text-center py-6 text-muted-foreground text-xs italic">Aucune ligne dans le tableau.</p>
|
||||
<p className="text-center py-6 text-muted-foreground text-xs italic">{t('structuredViewBlock.emptyTable')}</p>
|
||||
)}
|
||||
|
||||
{/* Add row button */}
|
||||
@@ -1002,7 +1002,7 @@ export function StructuredViewBlockEmbed({
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 h-8 px-2.5 rounded-lg"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Ajouter une ligne</span>
|
||||
<span>{t('structuredViewBlock.addRow')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1023,10 +1023,10 @@ export function StructuredViewBlockEmbed({
|
||||
>
|
||||
<div className="flex items-center gap-2 text-muted-foreground font-medium">
|
||||
<Table className="w-4 h-4 text-primary" />
|
||||
<span>{t('structuredViewBlock.selectNotebook') || 'Lier à un carnet'}</span>
|
||||
<span>{t('structuredViewBlock.selectNotebook')}</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{t('structuredViewBlock.noNotebookDesc') || 'Ce bloc affiche la vue structurée d\'un carnet. Choisissez le carnet à lier :'}
|
||||
{t('structuredViewBlock.noNotebookDesc')}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3 w-full max-w-md">
|
||||
@@ -1039,7 +1039,7 @@ export function StructuredViewBlockEmbed({
|
||||
className="rounded-lg border border-border bg-background px-3 py-1.5 text-xs text-foreground outline-none focus:border-primary max-w-xs w-full shadow-sm"
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>-- {t('structuredViewBlock.chooseNotebook') || 'Choisir un carnet'} --</option>
|
||||
<option value="" disabled>-- {t('structuredViewBlock.chooseNotebook')} --</option>
|
||||
{notebooks.map((nb) => (
|
||||
<option key={nb.id} value={nb.id}>
|
||||
{nb.icon ? `${nb.icon} ` : ''}{nb.name}
|
||||
@@ -1047,7 +1047,7 @@ export function StructuredViewBlockEmbed({
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span className="text-xs text-muted-foreground">ou</span>
|
||||
<span className="text-xs text-muted-foreground">{t('structuredViewBlock.or')}</span>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1055,7 +1055,7 @@ export function StructuredViewBlockEmbed({
|
||||
onClick={() => updateAttributes({ isLocal: true })}
|
||||
className="text-xs text-primary hover:bg-primary/5"
|
||||
>
|
||||
Créer une base locale autonome
|
||||
{t('structuredViewBlock.createLocalDb')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1085,7 +1085,7 @@ export function StructuredViewBlockEmbed({
|
||||
<span>{schemaHook.error || notesError}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleRetry}>
|
||||
{t('structuredViewBlock.retry') || 'Réessayer'}
|
||||
{t('structuredViewBlock.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -1100,16 +1100,16 @@ export function StructuredViewBlockEmbed({
|
||||
>
|
||||
<div className="flex items-center gap-2 text-muted-foreground font-medium">
|
||||
<Table className="w-4 h-4" />
|
||||
<span>{currentNotebook?.name || t('structuredViewBlock.insertLabel') || 'Vue structurée'}</span>
|
||||
<span>{currentNotebook?.name || t('structuredViewBlock.insertLabel')}</span>
|
||||
<button
|
||||
onClick={() => updateAttributes({ notebookId: null })}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground hover:underline ml-1.5 transition-colors"
|
||||
>
|
||||
({t('structuredViewBlock.change') || 'Changer'})
|
||||
({t('structuredViewBlock.change')})
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{t('structuredViewBlock.noSchema') || 'Ce carnet n\'a pas encore de vue structurée. Configurez-en une depuis l\'en-tête du carnet.'}
|
||||
{t('structuredViewBlock.noSchema')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1117,7 +1117,7 @@ export function StructuredViewBlockEmbed({
|
||||
onClick={() => router.push(`/home?notebook=${effectiveNotebookId}`)}
|
||||
className="text-xs flex items-center gap-1.5"
|
||||
>
|
||||
<span>{t('structuredViewBlock.openInNotebook') || 'Ouvrir dans le carnet'}</span>
|
||||
<span>{t('structuredViewBlock.openInNotebook')}</span>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1149,9 +1149,9 @@ export function StructuredViewBlockEmbed({
|
||||
<button
|
||||
onClick={() => updateAttributes({ notebookId: null })}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground hover:underline ml-1.5 transition-colors"
|
||||
title={t('structuredViewBlock.changeNotebook') || 'Changer de carnet'}
|
||||
title={t('structuredViewBlock.changeNotebook')}
|
||||
>
|
||||
({t('structuredViewBlock.change') || 'Changer'})
|
||||
({t('structuredViewBlock.change')})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1166,7 +1166,7 @@ export function StructuredViewBlockEmbed({
|
||||
? "bg-muted text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
title={t('structuredViewBlock.displayModeTable') || 'Tableau'}
|
||||
title={t('structuredViewBlock.displayModeTable')}
|
||||
>
|
||||
<Table className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -1178,7 +1178,7 @@ export function StructuredViewBlockEmbed({
|
||||
? "bg-muted text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
title={t('structuredViewBlock.displayModeGallery') || 'Galerie'}
|
||||
title={t('structuredViewBlock.displayModeGallery')}
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -1195,7 +1195,7 @@ export function StructuredViewBlockEmbed({
|
||||
)}
|
||||
>
|
||||
<BarChart3 className="w-3 h-3" />
|
||||
<span>Analyses</span>
|
||||
<span>{t('structuredViewBlock.analyticsShort')}</span>
|
||||
</button>
|
||||
|
||||
<span className="h-4 w-px bg-border/80" />
|
||||
@@ -1205,7 +1205,7 @@ export function StructuredViewBlockEmbed({
|
||||
onClick={() => router.push(`/home?notebook=${effectiveNotebookId}`)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>{t('structuredViewBlock.openInNotebook') || 'Ouvrir dans le carnet'}</span>
|
||||
<span>{t('structuredViewBlock.openInNotebook')}</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
@@ -1214,7 +1214,7 @@ export function StructuredViewBlockEmbed({
|
||||
onClick={() => updateAttributes({ isLocal: true, notebookId: null })}
|
||||
className="text-[11px] text-primary hover:underline ml-1"
|
||||
>
|
||||
Passer en base locale
|
||||
{t('structuredViewBlock.switchToLocalDb')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user