chore(ci): correct Gitea runner to runs-on ubuntu-24.04 and feat(billing): implement US-3.7 billing/subscription UX
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled

This commit is contained in:
Antigravity
2026-05-28 21:39:08 +00:00
parent 4bfa7c6b69
commit 3b2570d981
11 changed files with 180 additions and 71 deletions

View File

@@ -1,8 +1,8 @@
{ {
"version": 1, "version": 1,
"lastRunAtMs": 1779998560332, "lastRunAtMs": 1779998560332,
"turnsSinceLastRun": 2, "turnsSinceLastRun": 3,
"lastTranscriptMtimeMs": 1779998515529, "lastTranscriptMtimeMs": 1779998515529,
"lastProcessedGenerationId": "5e947d58-6d03-4f90-ad7c-4a97606f4d11", "lastProcessedGenerationId": "250d4e92-b72e-4ca2-b0c1-625292c59d32",
"trialStartedAtMs": null "trialStartedAtMs": null
} }

View File

@@ -13,7 +13,7 @@ on:
jobs: jobs:
ci: ci:
name: Lint, Unit Tests & Build name: Lint, Unit Tests & Build
runs-on: docker-host runs-on: ubuntu-24.04
defaults: defaults:
run: run:
working-directory: memento-note working-directory: memento-note
@@ -21,6 +21,19 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Cache node_modules
uses: actions/cache@v3
with:
path: memento-note/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('memento-note/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps

View File

@@ -12,6 +12,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { X, Plus } from 'lucide-react' import { X, Plus } from 'lucide-react'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { InlinePaywall } from '@/components/settings/inline-paywall'
export function NoteContentArea() { export function NoteContentArea() {
const { state, actions, readOnly, fullPage, textareaRef, note, richTextEditorRef } = useNoteEditorContext() const { state, actions, readOnly, fullPage, textareaRef, note, richTextEditorRef } = useNoteEditorContext()
@@ -85,13 +86,20 @@ export function NoteContentArea() {
readOnly && "cursor-default" readOnly && "cursor-default"
)} )}
/> />
<GhostTags {state.quotaExceededFeature === 'auto_tag' ? (
suggestions={state.filteredSuggestions} <InlinePaywall
addedTags={state.labels} feature="auto_tag"
isAnalyzing={state.isAnalyzingSuggestions} onDismiss={() => actions.setQuotaExceededFeature(null)}
onSelectTag={actions.handleSelectGhostTag} />
onDismissTag={actions.handleDismissGhostTag} ) : (
/> <GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
)}
</div> </div>
) )
} }
@@ -128,13 +136,20 @@ export function NoteContentArea() {
noteTitle={state.title || note.title || undefined} noteTitle={state.title || note.title || undefined}
sourceUrl={note.sourceUrl} sourceUrl={note.sourceUrl}
/> />
<GhostTags {state.quotaExceededFeature === 'auto_tag' ? (
suggestions={state.filteredSuggestions} <InlinePaywall
addedTags={state.labels} feature="auto_tag"
isAnalyzing={state.isAnalyzingSuggestions} onDismiss={() => actions.setQuotaExceededFeature(null)}
onSelectTag={actions.handleSelectGhostTag} />
onDismissTag={actions.handleDismissGhostTag} ) : (
/> <GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
)}
</div> </div>
) )
} }

View File

@@ -50,6 +50,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
} }
}, [session?.user?.id]) }, [session?.user?.id])
const [quotaExceededFeature, setQuotaExceededFeature] = useState<string | null>(null)
const [title, setTitle] = useState(note.title || '') const [title, setTitle] = useState(note.title || '')
const contentRef = useRef(note.content) const contentRef = useRef(note.content)
const [content, setContentState] = useState(note.content) const [content, setContentState] = useState(note.content)
@@ -93,6 +94,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const prev = prevNoteRef.current const prev = prevNoteRef.current
if (note.id !== prev.id) { if (note.id !== prev.id) {
setQuotaExceededFeature(null)
setTitle(note.title || '') setTitle(note.title || '')
setContentImmediate(note.content) setContentImmediate(note.content)
setCheckItems(note.checkItems || []) setCheckItems(note.checkItems || [])
@@ -165,7 +167,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const { suggestions, isAnalyzing: isAnalyzingSuggestions } = useAutoTagging({ const { suggestions, isAnalyzing: isAnalyzingSuggestions } = useAutoTagging({
content: content, content: content,
notebookId: note.notebookId, notebookId: note.notebookId,
enabled: autoTaggingEnabled enabled: autoTaggingEnabled,
onQuotaExceeded: () => setQuotaExceededFeature('auto_tag')
}) })
const [showReminderDialog, setShowReminderDialog] = useState(false) const [showReminderDialog, setShowReminderDialog] = useState(false)
@@ -382,6 +385,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 402) {
setQuotaExceededFeature('auto_title')
return
}
const errorData = await response.json() const errorData = await response.json()
throw new Error(errorData.error || t('ai.titleGenerationError')) throw new Error(errorData.error || t('ai.titleGenerationError'))
} }
@@ -454,6 +461,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 402) {
setQuotaExceededFeature('reformulate')
return
}
const errorData = await response.json() const errorData = await response.json()
throw new Error(errorData.error || t('ai.reformulationError')) throw new Error(errorData.error || t('ai.reformulationError'))
} }
@@ -490,6 +501,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option: 'clarify' }) body: JSON.stringify({ text: content, option: 'clarify' })
}) })
if (response.status === 402) {
setQuotaExceededFeature('reformulate')
return
}
const data = await response.json() const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed')) if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
setContentImmediate(data.reformulatedText || data.text) setContentImmediate(data.reformulatedText || data.text)
@@ -519,6 +534,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option: 'shorten' }) body: JSON.stringify({ text: content, option: 'shorten' })
}) })
if (response.status === 402) {
setQuotaExceededFeature('reformulate')
return
}
const data = await response.json() const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed')) if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
setContentImmediate(data.reformulatedText || data.text) setContentImmediate(data.reformulatedText || data.text)
@@ -548,6 +567,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option: 'improve' }) body: JSON.stringify({ text: content, option: 'improve' })
}) })
if (response.status === 402) {
setQuotaExceededFeature('reformulate')
return
}
const data = await response.json() const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.improveFailed')) if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
setContentImmediate(data.reformulatedText || data.text) setContentImmediate(data.reformulatedText || data.text)
@@ -864,13 +887,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
isMarkdown, isMarkdown,
allImages, allImages,
colorClasses, colorClasses,
quotaExceededFeature,
}), [ }), [
title, content, checkItems, labels, images, links, newLabel, color, size, title, content, checkItems, labels, images, links, newLabel, color, size,
showMarkdownPreview, removedImageUrls, isSaving, isDirty, isProcessingAI, aiOpen, infoOpen, showMarkdownPreview, removedImageUrls, isSaving, isDirty, isProcessingAI, aiOpen, infoOpen,
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating, isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder, reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions, showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses isAnalyzingSuggestions, isMarkdown, allImages, colorClasses, quotaExceededFeature
]) ])
const actions: NoteEditorActions = useMemo(() => ({ const actions: NoteEditorActions = useMemo(() => ({
@@ -924,13 +948,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setIsGeneratingTitles, setIsGeneratingTitles,
setIsAnalyzingSuggestions: (_a) => { /* handled by useAutoTagging */ }, setIsAnalyzingSuggestions: (_a) => { /* handled by useAutoTagging */ },
setPreviousContentForCopilot, setPreviousContentForCopilot,
setQuotaExceededFeature,
}), [ }), [
handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem, handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem,
handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload, handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload,
handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave, handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave,
handleRemoveReminder, handleGenerateTitles, handleSelectTitle, handleReformulate, handleRemoveReminder, handleGenerateTitles, handleSelectTitle, handleReformulate,
handleApplyRefactor, handleClarifyDirect, handleShortenDirect, handleImproveDirect, handleApplyRefactor, handleClarifyDirect, handleShortenDirect, handleImproveDirect,
handleTransformMarkdown, handleSave, handleSaveInPlace, handleMakeCopy handleTransformMarkdown, handleSave, handleSaveInPlace, handleMakeCopy, setQuotaExceededFeature
]) ])
const value: NoteEditorContextValue = useMemo(() => ({ const value: NoteEditorContextValue = useMemo(() => ({

View File

@@ -4,6 +4,7 @@ import { useNoteEditorContext } from './note-editor-context'
import { NoteTitleBlock } from './note-title-block' import { NoteTitleBlock } from './note-title-block'
import { NoteContentArea } from './note-content-area' import { NoteContentArea } from './note-content-area'
import { NoteMetadataSection } from './note-metadata-section' import { NoteMetadataSection } from './note-metadata-section'
import { InlinePaywall } from '@/components/settings/inline-paywall'
import { EditorImages } from '@/components/editor-images' import { EditorImages } from '@/components/editor-images'
import { ComparisonModal } from '@/components/comparison-modal' import { ComparisonModal } from '@/components/comparison-modal'
import { FusionModal } from '@/components/fusion-modal' import { FusionModal } from '@/components/fusion-modal'
@@ -107,6 +108,12 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
)} )}
{/* Content Area */} {/* Content Area */}
{state.quotaExceededFeature === 'reformulate' && (
<InlinePaywall
feature="reformulate"
onDismiss={() => actions.setQuotaExceededFeature(null)}
/>
)}
<NoteContentArea /> <NoteContentArea />
{/* Metadata Section */} {/* Metadata Section */}

View File

@@ -17,6 +17,7 @@ import { ChevronRight } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Note } from '@/lib/types' import { Note } from '@/lib/types'
import { GhostTags } from '@/components/ghost-tags' import { GhostTags } from '@/components/ghost-tags'
import { InlinePaywall } from '@/components/settings/inline-paywall'
import { LabelBadge } from '@/components/label-badge' import { LabelBadge } from '@/components/label-badge'
import { NoteAttachments } from '@/components/note-attachments' import { NoteAttachments } from '@/components/note-attachments'
import { DocumentQAOverlay } from '@/components/document-qa-overlay' import { DocumentQAOverlay } from '@/components/document-qa-overlay'
@@ -88,7 +89,7 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
{/* Title */} {/* Title */}
<NoteTitleBlock /> <NoteTitleBlock />
{(state.labels.length > 0 || (state.filteredSuggestions.length > 0)) && ( {(state.labels.length > 0 || state.filteredSuggestions.length > 0 || state.quotaExceededFeature === 'auto_tag') && (
<div className="flex flex-wrap gap-2 pt-2"> <div className="flex flex-wrap gap-2 pt-2">
{state.labels.map((label) => ( {state.labels.map((label) => (
<LabelBadge <LabelBadge
@@ -99,13 +100,20 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
/> />
))} ))}
{!readOnly && ( {!readOnly && (
<GhostTags state.quotaExceededFeature === 'auto_tag' ? (
suggestions={state.filteredSuggestions} <InlinePaywall
addedTags={state.labels} feature="auto_tag"
isAnalyzing={state.isAnalyzingSuggestions} onDismiss={() => actions.setQuotaExceededFeature(null)}
onSelectTag={actions.handleSelectGhostTag} />
onDismissTag={actions.handleDismissGhostTag} ) : (
/> <GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
)
)} )}
</div> </div>
)} )}
@@ -124,6 +132,12 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
{/* Content area — max-w-3xl for wider reading column */} {/* Content area — max-w-3xl for wider reading column */}
<div className="max-w-3xl mx-auto w-full space-y-8 pb-32"> <div className="max-w-3xl mx-auto w-full space-y-8 pb-32">
{state.quotaExceededFeature === 'reformulate' && (
<InlinePaywall
feature="reformulate"
onDismiss={() => actions.setQuotaExceededFeature(null)}
/>
)}
<NoteContentArea /> <NoteContentArea />
{!readOnly && ( {!readOnly && (

View File

@@ -3,6 +3,7 @@
import { useNoteEditorContext } from './note-editor-context' import { useNoteEditorContext } from './note-editor-context'
import { LabelBadge } from '../label-badge' import { LabelBadge } from '../label-badge'
import { GhostTags } from '../ghost-tags' import { GhostTags } from '../ghost-tags'
import { InlinePaywall } from '@/components/settings/inline-paywall'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export function NoteMetadataSection() { export function NoteMetadataSection() {
@@ -31,13 +32,20 @@ export function NoteMetadataSection() {
{/* Ghost Tags - only show in dialog mode */} {/* Ghost Tags - only show in dialog mode */}
{!readOnly && !state.isMarkdown && ( {!readOnly && !state.isMarkdown && (
<GhostTags state.quotaExceededFeature === 'auto_tag' ? (
suggestions={state.filteredSuggestions} <InlinePaywall
addedTags={state.labels} feature="auto_tag"
isAnalyzing={state.isAnalyzingSuggestions} onDismiss={() => actions.setQuotaExceededFeature(null)}
onSelectTag={actions.handleSelectGhostTag} />
onDismissTag={actions.handleDismissGhostTag} ) : (
/> <GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
)
)} )}
{/* Color indicator */} {/* Color indicator */}

View File

@@ -8,6 +8,7 @@ import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from 'sonner' import { toast } from 'sonner'
import { resolveTitleDirection, resolveTitleLang } from '@/lib/clip/rtl-content' import { resolveTitleDirection, resolveTitleLang } from '@/lib/clip/rtl-content'
import { InlinePaywall } from '@/components/settings/inline-paywall'
export function NoteTitleBlock() { export function NoteTitleBlock() {
const { note, state, actions, readOnly, fullPage } = useNoteEditorContext() const { note, state, actions, readOnly, fullPage } = useNoteEditorContext()
@@ -30,6 +31,12 @@ export function NoteTitleBlock() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{state.quotaExceededFeature === 'auto_title' && (
<InlinePaywall
feature="auto_title"
onDismiss={() => actions.setQuotaExceededFeature(null)}
/>
)}
{/* Title — auto-resizing textarea, adaptive size */} {/* Title — auto-resizing textarea, adaptive size */}
<div className="group relative"> <div className="group relative">
<textarea <textarea
@@ -84,6 +91,10 @@ export function NoteTitleBlock() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: plain }), body: JSON.stringify({ content: plain }),
}) })
if (res.status === 402) {
actions.setQuotaExceededFeature('auto_title')
return
}
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
const s = data.suggestions?.[0]?.title ?? '' const s = data.suggestions?.[0]?.title ?? ''
@@ -127,32 +138,40 @@ export function NoteTitleBlock() {
// Dialog mode title block // Dialog mode title block
return ( return (
<div className="relative"> <div className="space-y-2">
<input <div className="relative">
dir={titleDir} <input
lang={titleLang} dir={titleDir}
placeholder={t('notes.titlePlaceholder')} lang={titleLang}
value={state.title} placeholder={t('notes.titlePlaceholder')}
onChange={(e) => actions.setTitle(e.target.value)} value={state.title}
disabled={readOnly} onChange={(e) => actions.setTitle(e.target.value)}
className={cn( disabled={readOnly}
'w-full text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent', className={cn(
titleIsRtl ? 'text-right font-[family-name:var(--font-sans)] ps-10' : 'text-left pe-10', 'w-full text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent',
readOnly && 'cursor-default', titleIsRtl ? 'text-right font-[family-name:var(--font-sans)] ps-10' : 'text-left pe-10',
)} readOnly && 'cursor-default',
/> )}
<button />
onClick={actions.handleGenerateTitles} <button
disabled={state.isGeneratingTitles || readOnly} onClick={actions.handleGenerateTitles}
className="absolute right-0 top-1/2 -translate-y-1/2 p-1 hover:bg-purple-100 dark:hover:bg-purple-900 rounded transition-colors" disabled={state.isGeneratingTitles || readOnly}
title={state.isGeneratingTitles ? t('ai.titleGenerating') : t('ai.titleGenerateWithAI')} className="absolute right-0 top-1/2 -translate-y-1/2 p-1 hover:bg-purple-100 dark:hover:bg-purple-900 rounded transition-colors"
> title={state.isGeneratingTitles ? t('ai.titleGenerating') : t('ai.titleGenerateWithAI')}
{state.isGeneratingTitles ? ( >
<div className="w-4 h-4 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" /> {state.isGeneratingTitles ? (
) : ( <div className="w-4 h-4 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
<Sparkles className="w-4 h-4 text-purple-600 hover:text-purple-700 dark:text-purple-400" /> ) : (
)} <Sparkles className="w-4 h-4 text-purple-600 hover:text-purple-700 dark:text-purple-400" />
</button> )}
</button>
</div>
{state.quotaExceededFeature === 'auto_title' && (
<InlinePaywall
feature="auto_title"
onDismiss={() => actions.setQuotaExceededFeature(null)}
/>
)}
</div> </div>
) )
} }

View File

@@ -51,6 +51,7 @@ export interface NoteEditorState {
isMarkdown: boolean isMarkdown: boolean
allImages: string[] allImages: string[]
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS] colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
quotaExceededFeature: string | null
} }
export interface NoteEditorActions { export interface NoteEditorActions {
@@ -119,6 +120,7 @@ export interface NoteEditorActions {
setIsGeneratingTitles: (generating: boolean) => void setIsGeneratingTitles: (generating: boolean) => void
setIsAnalyzingSuggestions: (analyzing: boolean) => void setIsAnalyzingSuggestions: (analyzing: boolean) => void
setPreviousContentForCopilot: (content: string | null) => void setPreviousContentForCopilot: (content: string | null) => void
setQuotaExceededFeature: (feature: string | null) => void
} }
export interface NoteEditorContextValue { export interface NoteEditorContextValue {

View File

@@ -297,7 +297,7 @@ export function BillingPlans() {
let totalUsed = 0; let totalUsed = 0;
let totalLimit = 0; let totalLimit = 0;
if (quotas) { if (quotas) {
Object.entries(quotas).forEach(([_, q]) => { Object.entries(quotas as Record<string, any>).forEach(([_, q]) => {
if (q.limit > 0 && q.limit !== Infinity) { if (q.limit > 0 && q.limit !== Infinity) {
totalUsed += q.used; totalUsed += q.used;
totalLimit += q.limit; totalLimit += q.limit;
@@ -471,7 +471,7 @@ export function BillingPlans() {
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" /> <Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
</div> </div>
)} )}
{quotas && Object.entries(quotas).map(([key, q]) => { {quotas && Object.entries(quotas as Record<string, any>).map(([key, q]) => {
const pct = q.limit > 0 && q.limit !== Infinity ? (q.used / q.limit) * 100 : 0; const pct = q.limit > 0 && q.limit !== Infinity ? (q.used / q.limit) * 100 : 0;
const isUnlimited = q.limit === Infinity || q.limit <= 0; const isUnlimited = q.limit === Infinity || q.limit <= 0;
@@ -516,12 +516,13 @@ export function BillingPlans() {
{isPaid && <BillingHistory />} {isPaid && <BillingHistory />}
{/* Interval Toggle & Plan Cards */} {/* Interval Toggle & Plan Cards */}
<div className="space-y-8 pt-6 border-t border-border/40"> {!isPaid && (
<div className="text-center space-y-2"> <div className="space-y-8 pt-6 border-t border-border/40">
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete"> <div className="text-center space-y-2">
{t('billing.upgradePlan') || 'Changer de plan'} <h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
</h3> {t('billing.upgradePlan') || 'Changer de plan'}
</div> </h3>
</div>
{billingEnabled && ( {billingEnabled && (
<div className="flex items-center gap-2 justify-center"> <div className="flex items-center gap-2 justify-center">
@@ -603,6 +604,7 @@ export function BillingPlans() {
))} ))}
</div> </div>
</div> </div>
)}
{/* Footer Info */} {/* Footer Info */}
<div className="bg-slate-50 dark:bg-black/20 rounded-[32px] p-8 border border-border/40 flex flex-col md:flex-row items-center justify-between gap-8"> <div className="bg-slate-50 dark:bg-black/20 rounded-[32px] p-8 border border-border/40 flex flex-col md:flex-row items-center justify-between gap-8">

View File

@@ -8,9 +8,10 @@ interface UseAutoTaggingProps {
content: string; content: string;
notebookId?: string | null; notebookId?: string | null;
enabled?: boolean; enabled?: boolean;
onQuotaExceeded?: () => void;
} }
export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoTaggingProps) { export function useAutoTagging({ content, notebookId, enabled = true, onQuotaExceeded }: UseAutoTaggingProps) {
const { language } = useLanguage(); const { language } = useLanguage();
const { hasAiConsent, requestAiConsent } = useAiConsent(); const { hasAiConsent, requestAiConsent } = useAiConsent();
const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]); const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]);
@@ -62,6 +63,9 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
if (!response.ok) { if (!response.ok) {
if (response.status === 402 && onQuotaExceeded) {
onQuotaExceeded();
}
throw new Error('Error during analysis'); throw new Error('Error during analysis');
} }
@@ -75,7 +79,7 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
setIsAnalyzing(false); setIsAnalyzing(false);
} }
} }
}, [hasAiConsent, requestAiConsent]); }, [hasAiConsent, requestAiConsent, onQuotaExceeded]);
// Trigger on content change // Trigger on content change
useEffect(() => { useEffect(() => {