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,
"lastRunAtMs": 1779998560332,
"turnsSinceLastRun": 2,
"turnsSinceLastRun": 3,
"lastTranscriptMtimeMs": 1779998515529,
"lastProcessedGenerationId": "5e947d58-6d03-4f90-ad7c-4a97606f4d11",
"lastProcessedGenerationId": "250d4e92-b72e-4ca2-b0c1-625292c59d32",
"trialStartedAtMs": null
}

View File

@@ -13,7 +13,7 @@ on:
jobs:
ci:
name: Lint, Unit Tests & Build
runs-on: docker-host
runs-on: ubuntu-24.04
defaults:
run:
working-directory: memento-note
@@ -21,6 +21,19 @@ jobs:
- name: Checkout
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
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 { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { InlinePaywall } from '@/components/settings/inline-paywall'
export function NoteContentArea() {
const { state, actions, readOnly, fullPage, textareaRef, note, richTextEditorRef } = useNoteEditorContext()
@@ -85,13 +86,20 @@ export function NoteContentArea() {
readOnly && "cursor-default"
)}
/>
<GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
{state.quotaExceededFeature === 'auto_tag' ? (
<InlinePaywall
feature="auto_tag"
onDismiss={() => actions.setQuotaExceededFeature(null)}
/>
) : (
<GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
)}
</div>
)
}
@@ -128,13 +136,20 @@ export function NoteContentArea() {
noteTitle={state.title || note.title || undefined}
sourceUrl={note.sourceUrl}
/>
<GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
{state.quotaExceededFeature === 'auto_tag' ? (
<InlinePaywall
feature="auto_tag"
onDismiss={() => actions.setQuotaExceededFeature(null)}
/>
) : (
<GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
)}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -297,7 +297,7 @@ export function BillingPlans() {
let totalUsed = 0;
let totalLimit = 0;
if (quotas) {
Object.entries(quotas).forEach(([_, q]) => {
Object.entries(quotas as Record<string, any>).forEach(([_, q]) => {
if (q.limit > 0 && q.limit !== Infinity) {
totalUsed += q.used;
totalLimit += q.limit;
@@ -471,7 +471,7 @@ export function BillingPlans() {
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
</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 isUnlimited = q.limit === Infinity || q.limit <= 0;
@@ -516,12 +516,13 @@ export function BillingPlans() {
{isPaid && <BillingHistory />}
{/* Interval Toggle & Plan Cards */}
<div className="space-y-8 pt-6 border-t border-border/40">
<div className="text-center space-y-2">
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
{t('billing.upgradePlan') || 'Changer de plan'}
</h3>
</div>
{!isPaid && (
<div className="space-y-8 pt-6 border-t border-border/40">
<div className="text-center space-y-2">
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
{t('billing.upgradePlan') || 'Changer de plan'}
</h3>
</div>
{billingEnabled && (
<div className="flex items-center gap-2 justify-center">
@@ -603,6 +604,7 @@ export function BillingPlans() {
))}
</div>
</div>
)}
{/* 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">

View File

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