story-research-zapwall/components/ArticleEditorForm.tsx
Nicolas Cantu 90ff8282f1 feat: Implémentation système de commissions systématique et incontournable
- Création lib/platformCommissions.ts : configuration centralisée des commissions
  - Articles : 800 sats (700 auteur, 100 plateforme)
  - Avis : 70 sats (49 lecteur, 21 plateforme)
  - Sponsoring : 0.046 BTC (0.042 auteur, 0.004 plateforme)

- Validation des montants à chaque étape :
  - Publication : vérification du montant avant publication
  - Paiement : vérification du montant avant acceptation
  - Erreurs explicites si montant incorrect

- Tracking des commissions sur Nostr :
  - Tags author_amount et platform_commission dans événements
  - Interface ContentDeliveryTracking étendue
  - Traçabilité complète pour audit

- Logs structurés avec informations de commission
- Documentation complète du système

Les commissions sont maintenant systématiques, validées et traçables.
2025-12-27 21:11:09 +01:00

251 lines
7.0 KiB
TypeScript

import React from 'react'
import type { ArticleDraft } from '@/lib/articlePublisher'
import type { ArticleCategory } from '@/types/nostr'
import { ArticleField } from './ArticleField'
import { ArticleFormButtons } from './ArticleFormButtons'
import { CategorySelect } from './CategorySelect'
import { MarkdownEditor } from './MarkdownEditor'
import type { MediaRef } from '@/types/nostr'
interface ArticleEditorFormProps {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
onSubmit: (e: React.FormEvent) => void
loading: boolean
error: string | null
onCancel?: () => void
seriesOptions?: { id: string; title: string }[] | undefined
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
function CategoryField({
value,
onChange,
}: {
value: ArticleDraft['category']
onChange: (value: import('@/types/nostr').ArticleCategory | undefined) => void
}) {
return (
<CategorySelect
id="category"
label="Catégorie"
{...(value ? { value } : {})}
onChange={onChange}
required
helpText="Sélectionnez la catégorie de votre article"
/>
)
}
function ErrorAlert({ error }: { error: string | null }) {
if (!error) {
return null
}
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-sm text-red-800">{error}</p>
</div>
)
}
function buildCategoryChangeHandler(
draft: ArticleDraft,
onDraftChange: (draft: ArticleDraft) => void
): (value: ArticleCategory | undefined) => void {
return (value) => {
if (value === 'science-fiction' || value === 'scientific-research' || value === undefined) {
const nextDraft: ArticleDraft = { ...draft }
if (value) {
nextDraft.category = value
} else {
delete (nextDraft as { category?: ArticleDraft['category'] }).category
}
onDraftChange(nextDraft)
}
}
}
const ArticleFieldsLeft = ({
draft,
onDraftChange,
seriesOptions,
onSelectSeries,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
seriesOptions?: { id: string; title: string }[] | undefined
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}) => (
<div className="space-y-4">
<CategoryField
value={draft.category}
onChange={buildCategoryChangeHandler(draft, onDraftChange)}
/>
{seriesOptions && (
<SeriesSelect
draft={draft}
onDraftChange={onDraftChange}
seriesOptions={seriesOptions}
onSelectSeries={onSelectSeries}
/>
)}
<ArticleTitleField draft={draft} onDraftChange={onDraftChange} />
<ArticlePreviewField draft={draft} onDraftChange={onDraftChange} />
</div>
)
function ArticleTitleField({ draft, onDraftChange }: { draft: ArticleDraft; onDraftChange: (draft: ArticleDraft) => void }) {
return (
<ArticleField
id="title"
label="Titre"
value={draft.title}
onChange={(value) => onDraftChange({ ...draft, title: value as string })}
required
placeholder="Entrez le titre de l'article"
/>
)
}
function ArticlePreviewField({
draft,
onDraftChange,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}) {
return (
<ArticleField
id="preview"
label="Aperçu (Public)"
value={draft.preview}
onChange={(value) => onDraftChange({ ...draft, preview: value as string })}
required
type="textarea"
rows={4}
placeholder="Cet aperçu sera visible par tous gratuitement"
helpText="Ce contenu sera visible par tous"
/>
)
}
function SeriesSelect({
draft,
onDraftChange,
seriesOptions,
onSelectSeries,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
seriesOptions: { id: string; title: string }[]
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}) {
const handleChange = buildSeriesChangeHandler(draft, onDraftChange, onSelectSeries)
return (
<div>
<label htmlFor="series" className="block text-sm font-medium text-gray-700">
Série
</label>
<select
id="series"
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
value={draft.seriesId ?? ''}
onChange={handleChange}
>
<option value="">Aucune (article indépendant)</option>
{seriesOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
</div>
)
}
function buildSeriesChangeHandler(
draft: ArticleDraft,
onDraftChange: (draft: ArticleDraft) => void,
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
) {
return (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value || undefined
const nextDraft = { ...draft }
if (value) {
nextDraft.seriesId = value
} else {
delete (nextDraft as { seriesId?: string }).seriesId
}
onDraftChange(nextDraft)
onSelectSeries?.(value)
}
}
const ArticleFieldsRight = ({
draft,
onDraftChange,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}) => (
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-semibold text-gray-800">Contenu complet (Privé) Markdown + preview</div>
<MarkdownEditor
value={draft.content}
onChange={(value) => onDraftChange({ ...draft, content: value })}
onMediaAdd={(media: MediaRef) => {
const nextMedia = [...(draft.media ?? []), media]
onDraftChange({ ...draft, media: nextMedia })
}}
onBannerChange={(url: string) => {
onDraftChange({ ...draft, bannerUrl: url })
}}
/>
<p className="text-xs text-gray-500">
Les médias sont uploadés via NIP-95 (images 5Mo, vidéos 45Mo) et insérés comme URL. Le contenu reste chiffré
pour les acheteurs.
</p>
</div>
<ArticleField
id="zapAmount"
label="Sponsoring (sats)"
value={draft.zapAmount}
onChange={(value) => onDraftChange({ ...draft, zapAmount: value as number })}
required
type="number"
min={1}
helpText="Montant de sponsoring en satoshis pour débloquer le contenu complet (zap uniquement)"
/>
</div>
)
export function ArticleEditorForm({
draft,
onDraftChange,
onSubmit,
loading,
error,
onCancel,
seriesOptions,
onSelectSeries,
}: ArticleEditorFormProps) {
return (
<form onSubmit={onSubmit} className="border rounded-lg p-6 bg-white space-y-4">
<h2 className="text-2xl font-bold mb-4">Publier un nouvel article</h2>
<div className="space-y-4">
<ArticleFieldsLeft
draft={draft}
onDraftChange={onDraftChange}
{...(seriesOptions ? { seriesOptions } : {})}
{...(onSelectSeries ? { onSelectSeries } : {})}
/>
<ArticleFieldsRight draft={draft} onDraftChange={onDraftChange} />
</div>
<ErrorAlert error={error} />
<ArticleFormButtons loading={loading} {...(onCancel ? { onCancel } : {})} />
</form>
)
}